Diff stage: snapshot/live collection modes, to_add + to_update outputs

compute_diff is a pure function over matches + collection items: new
games land in to_add.csv (carrying a confident version when matching
produced one); owned version-less entries with version_auto/approved
matches produce additive to_update.csv rows keyed by collid, consuming
distinct collids when photos show two editions; entries that already
carry a version are never touched — disagreements are reported in the
summary. Unseen collection entries are listed informationally. Live API
mode activates when BGG_API_TOKEN + username exist; otherwise the two
hand-pulled snapshot XMLs (real 2018 collection, 79 unique copies after
collid dedupe) are used, and they double as parsing fixtures in tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-01 14:06:23 -04:00
parent 4f2ae5f525
commit 3543005236
9 changed files with 2597 additions and 4 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ The first `/collection` call typically returns **HTTP 202** with a "please retry
- Login with `BGG_USERNAME` / `BGG_PASSWORD` env vars; persist Playwright storage state locally (gitignored) so repeat runs skip login. Never write credentials to disk, logs, or error messages. - Login with `BGG_USERNAME` / `BGG_PASSWORD` env vars; persist Playwright storage state locally (gitignored) so repeat runs skip login. Never write credentials to disk, logs, or error messages.
- Per game: navigate to the game page → "Add to Collection" flow → status **Owned** → if a version_id is known, set it in the collection item's version picker → save. Never guess a version — omit it when unknown. The flow has been walked and documented: see `docs/bgg-upload-flow.md` for the dialog structure, version-picker behavior (paginated, no search — match by canonical version NAME from the XML API), and observed automation gotchas (stale elements, hidden-not-removed dialogs, hydration races). - Per game: navigate to the game page → "Add to Collection" flow → status **Owned** → if a version_id is known, set it in the collection item's version picker → save. Never guess a version — omit it when unknown. The flow has been walked and documented: see `docs/bgg-upload-flow.md` for the dialog structure, version-picker behavior (paginated, no search — match by canonical version NAME from the XML API), and observed automation gotchas (stale elements, hidden-not-removed dialogs, hydration races).
- A second copy of an owned game must be a NEW collection entry (new collid), not an edit of the existing item. - A second copy of an owned game must be a NEW collection entry (new collid), not an edit of the existing item. Conversely, version UPGRADES from to_update.csv must edit the EXISTING item (same collid) — additive only: fill the empty version field, change nothing else, and skip any entry that already has a version.
- Expect UI fragility: wrap each game in its own try/except, log the failure to `upload_log.csv`, and continue. `--retry-failed` re-attempts failures; `--dry-run` logs without touching the site. - Expect UI fragility: wrap each game in its own try/except, log the failure to `upload_log.csv`, and continue. `--retry-failed` re-attempts failures; `--dry-run` logs without touching the site.
- Idempotency: skip IDs already logged `added`; `--verify` re-fetches the collection to confirm. - Idempotency: skip IDs already logged `added`; `--verify` re-fetches the collection to confirm.
+7 -2
View File
@@ -77,13 +77,16 @@ Each stage reads the previous stage's artifact and writes its own. Re-running a
- Fetch my current collection: `https://boardgamegeek.com/xmlapi2/collection?username=<me>&own=1` (handle the 202-retry queue; also pass `&subtype=boardgameexpansion` in a second call — the collection endpoint excludes expansions from the default subtype). - Fetch my current collection: `https://boardgamegeek.com/xmlapi2/collection?username=<me>&own=1` (handle the 202-retry queue; also pass `&subtype=boardgameexpansion` in a second call — the collection endpoint excludes expansions from the default subtype).
- Output `to_add.csv`: approved/auto matches whose IDs are **not** already in the collection. - Output `to_add.csv`: approved/auto matches whose IDs are **not** already in the collection.
- **Multiple editions of the same game**: collection items are identified by `collid` (one per copy), not just `objectid`. If matches contain two entries for the same `bgg_id` with different `version_id`s, both belong in the collection as separate entries. Diff logic: a (bgg_id, version_id) pair is "already owned" only if a collection item matches both; a bare bgg_id with `version_unknown` is "already owned" if any copy of that game exists. - **Multiple editions of the same game**: collection items are identified by `collid` (one per copy), not just `objectid`. If matches contain two entries for the same `bgg_id` with different `version_id`s, both belong in the collection as separate entries. Diff logic: a (bgg_id, version_id) pair is "already owned" only if a collection item matches both; a bare bgg_id with `version_unknown` is "already owned" if any copy of that game exists.
- Print a summary: N recognized, N already owned, N to add (including second editions), N rejected/unmatched. - **Improvement pass (to_update)**: for games already owned whose collection entry has NO version set, where photo matching produced a `version_auto`/`version_approved` — emit `to_update.csv` (`collid, bgg_id, bgg_name, version_id, version_name`). This upgrades the hand-entered 2018 entries with edition data from the shelves. Strictly additive: only fill empty version fields; if the collection entry already has a version, never touch it (even if the photo disagrees — report the disagreement in the summary instead).
- Informational only: list collection entries not seen in any photo (possible missing/loaned/sold games) in the summary. No action taken.
- Print a summary: N recognized, N already owned, N to add (including second editions), N version updates, N rejected/unmatched.
### Stage 5 — `upload`: Add games via Playwright ### Stage 5 — `upload`: Add games via Playwright
- Log in to boardgamegeek.com with credentials from env vars (`BGG_USERNAME`, `BGG_PASSWORD`). Never write credentials to disk or logs. Persist the browser session/storage state locally so repeat runs don't re-login. - Log in to boardgamegeek.com with credentials from env vars (`BGG_USERNAME`, `BGG_PASSWORD`). Never write credentials to disk or logs. Persist the browser session/storage state locally so repeat runs don't re-login.
- For each row in `to_add.csv`: navigate to the game page, use the "Add to Collection" flow, set status **Owned**, and — when a `version_id` is present — set the specific version in the collection item's version picker before saving. Manually walk this flow once and document the selectors before automating; the version UI is the most fragile part. - For each row in `to_add.csv`: navigate to the game page, use the "Add to Collection" flow, set status **Owned**, and — when a `version_id` is present — set the specific version in the collection item's version picker before saving. Manually walk this flow once and document the selectors before automating; the version UI is the most fragile part.
- Adding a second copy of an already-owned game must create a NEW collection entry, not edit the existing one. - Adding a second copy of an already-owned game must create a NEW collection entry, not edit the existing one.
- **Update mode** (rows from `to_update.csv`): open the EXISTING collection entry (keyed by `collid`) rather than the add flow, set the version, save. Must never create a duplicate entry and never change any other field of the entry. Verify the already-owned dialog behavior manually first — flagged as unverified in `docs/bgg-upload-flow.md`.
- Log every attempt to `upload_log.csv`: `bgg_id, name, status (added|already_present|failed), timestamp, error`. - Log every attempt to `upload_log.csv`: `bgg_id, name, status (added|already_present|failed), timestamp, error`.
- Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`. - Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`.
- Deliberately slow: 24 s randomized delay between games. This is a real account on a community site — behave like a polite human. - Deliberately slow: 24 s randomized delay between games. This is a real account on a community site — behave like a polite human.
@@ -103,7 +106,8 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
- `titles.json` — extraction output (stage 1) - `titles.json` — extraction output (stage 1)
- `bgg_cache/` — cached XML API responses - `bgg_cache/` — cached XML API responses
- `matches.csv` — the master matching table (stages 23) - `matches.csv` — the master matching table (stages 23)
- `to_add.csv` — upload queue (stage 4) - `to_add.csv` — upload queue, new entries (stage 4)
- `to_update.csv` — version upgrades for existing version-less entries (stage 4)
- `upload_log.csv` — audit trail (stage 5) - `upload_log.csv` — audit trail (stage 5)
- `games.json` — full game + version metadata (stage 6); seed data for the future frontend - `games.json` — full game + version metadata (stage 6); seed data for the future frontend
@@ -130,6 +134,7 @@ All artifacts are flat files in a `data/` directory — human-readable, git-frie
6. Credentials never appear in any file, log, or error message. 6. Credentials never appear in any file, log, or error message.
7. Where photos show legible edition cues, the matched version survives to the BGG collection entry; where they don't, the entry is added version-less rather than with a guessed version. 7. Where photos show legible edition cues, the matched version survives to the BGG collection entry; where they don't, the entry is added version-less rather than with a guessed version.
8. A game I own in two editions ends up as two distinct collection entries, and `games.json` contains full metadata for every game in the collection. 8. A game I own in two editions ends up as two distinct collection entries, and `games.json` contains full metadata for every game in the collection.
9. Version upgrades land on existing collection entries (same `collid`) with no duplicate entries created and no non-version fields changed; entries that already have a version are never modified.
## Suggested Build Order ## Suggested Build Order
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<items totalitems="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse" pubdate="Sat, 01 Aug 2026 17:53:03 +0000">
<item objecttype="thing" objectid="177" subtype="boardgameexpansion" collid="53429542">
<name sortindex="1">Advanced Civilization</name>
<yearpublished>1991</yearpublished>
<image>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__original/img/--qlhvNNj8zF9cWLlihdTg0jzmE=/0x0/filters:format(jpeg)/pic87459.jpg</image>
<thumbnail>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__small/img/XDH667tVl2KjQ6szNjT7ceEmh5E=/fit-in/200x150/filters:strip_icc()/pic87459.jpg</thumbnail>
<stats minplayers="2" maxplayers="8" minplaytime="360" maxplaytime="480" playingtime="480" numowned="3951">
<rating value="N/A">
<usersrated value="3412"/>
<average value="8.00904"/>
<bayesaverage value="6.91645"/>
<stddev value="1.65908"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.91645"/>
<rank type="family" id="5497" name="strategygames" friendlyname="Strategy Game Rank" value="Not Ranked" bayesaverage="7.07351"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:29:32"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="169784" subtype="boardgameexpansion" collid="53429953">
<name sortindex="1">Castle Panic: The Dark Titan</name>
<yearpublished>2015</yearpublished>
<image>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__original/img/Pe0unjKPwaO0cJPd9QI_Eo-xuRU=/0x0/filters:format(jpeg)/pic6967876.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__small/img/RTQtsNpxLidMnDPDGTTf-AiP9OQ=/fit-in/200x150/filters:strip_icc()/pic6967876.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="60" maxplaytime="60" playingtime="60" numowned="4511">
<rating value="N/A">
<usersrated value="1049"/>
<average value="7.2278"/>
<bayesaverage value="6.00948"/>
<stddev value="1.15443"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.00948"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:44:09"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="104590" subtype="boardgameexpansion" collid="53430550">
<name sortindex="1">Castle Panic: The Wizard's Tower</name>
<yearpublished>2011</yearpublished>
<image>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__original/img/S5nr9djtA4cUDvi7dmtTiYhut1Y=/0x0/filters:format(jpeg)/pic6966120.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__small/img/VzbEp7j_wYr0KiXfvsYITTAhnSk=/fit-in/200x150/filters:strip_icc()/pic6966120.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="90" maxplaytime="90" playingtime="90" numowned="10158">
<rating value="N/A">
<usersrated value="3587"/>
<average value="7.43829"/>
<bayesaverage value="6.63785"/>
<stddev value="1.18971"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.63785"/>
<rank type="family" id="5499" name="familygames" friendlyname="Family Game Rank" value="Not Ranked" bayesaverage="6.78135"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 13:04:56"/>
<numplays>0</numplays>
</item>
</items>
+4 -1
View File
@@ -67,7 +67,10 @@ def review(config: ConfigOpt = None) -> None:
@app.command() @app.command()
def diff(config: ConfigOpt = None) -> None: def diff(config: ConfigOpt = None) -> None:
"""Stage 4: diff approved matches against the existing BGG collection.""" """Stage 4: diff approved matches against the existing BGG collection."""
_not_implemented("diff", 4) from bggpipe.diff import run_diff
cfg = load_config(config)
run_diff(cfg)
@app.command() @app.command()
+213
View File
@@ -0,0 +1,213 @@
"""Stage 4 — diff approved/auto matches against the existing BGG collection.
Two collection sources:
- live API (default once BGG_API_TOKEN exists): /collection base + expansion
subtype calls, merged by collid;
- snapshot files (fallback): data/collection_snapshot_base.xml +
data/collection_snapshot_expansions.xml, pulled manually via the
logged-in-user exemption.
Outputs both artifacts:
- to_add.csv — recognized games not in the collection (new entries);
- to_update.csv — owned, VERSION-LESS entries where matching produced a
confident version (version_auto/version_approved). Strictly additive:
entries that already carry a version are never touched — a photo/version
disagreement is reported in the summary instead.
"""
from __future__ import annotations
import csv
import os
from dataclasses import dataclass, field
from pathlib import Path
import typer
from bggpipe.bgg_client import BGGClient
from bggpipe.config import Config
from bggpipe.models import CollectionItem, parse_collection
from bggpipe.resolve import read_matches
TO_ADD_COLUMNS = [
"bgg_id",
"bgg_name",
"year",
"type",
"version_id",
"version_name",
"title_raw",
"source_photos",
]
TO_UPDATE_COLUMNS = ["collid", "bgg_id", "bgg_name", "version_id", "version_name"]
SNAPSHOT_FILES = ("collection_snapshot_base.xml", "collection_snapshot_expansions.xml")
_CONFIDENT_VERSION = ("version_auto", "version_approved")
@dataclass
class DiffResult:
to_add: list[dict] = field(default_factory=list)
to_update: list[dict] = field(default_factory=list)
already_owned: list[str] = field(default_factory=list) # title_raw
disagreements: list[str] = field(default_factory=list)
unseen: list[CollectionItem] = field(default_factory=list)
pending: list[str] = field(default_factory=list) # ambiguous/unmatched titles
rejected: int = 0
recognized: int = 0
def load_snapshot_collection(data_dir: Path) -> list[CollectionItem]:
"""Merge the base + expansions snapshot files, deduping by collid (the
same physical copy can appear in both responses)."""
items: list[CollectionItem] = []
seen: set[int] = set()
for name in SNAPSHOT_FILES:
path = data_dir / name
if not path.exists():
raise FileNotFoundError(
f"{path} not found — pull your collection while logged in "
"(no registration needed for your own collection) or set "
"BGG_API_TOKEN for live mode."
)
for item in parse_collection(path.read_text()):
if item.own and item.coll_id not in seen:
seen.add(item.coll_id)
items.append(item)
return items
def compute_diff(rows: list[dict], collection: list[CollectionItem]) -> DiffResult:
by_object: dict[int, list[CollectionItem]] = {}
for item in collection:
by_object.setdefault(item.object_id, []).append(item)
result = DiffResult()
seen_object_ids: set[int] = set()
consumed_collids: set[int] = set()
for row in rows:
status = row["match_status"]
if status == "rejected":
result.rejected += 1
continue
if status not in ("auto", "approved") or not row["bgg_id"]:
result.pending.append(row["title_raw"])
continue
result.recognized += 1
bgg_id = int(row["bgg_id"])
copies = by_object.get(bgg_id, [])
if copies:
seen_object_ids.add(bgg_id)
confident = row["version_status"] in _CONFIDENT_VERSION and row["version_id"]
version_id = int(row["version_id"]) if confident else None
if not copies:
result.to_add.append(
{
"bgg_id": row["bgg_id"],
"bgg_name": row["bgg_name"],
"year": row["year"],
"type": row["type"],
"version_id": row["version_id"] if confident else "",
"version_name": row["version_name"] if confident else "",
"title_raw": row["title_raw"],
"source_photos": row["source_photos"],
}
)
continue
if not confident:
# bare id with unknown version: owned if any copy exists
result.already_owned.append(row["title_raw"])
continue
if any(c.version_id == version_id for c in copies):
result.already_owned.append(row["title_raw"])
continue
versionless = [
c
for c in copies
if c.version_id is None and c.coll_id not in consumed_collids
]
if versionless:
target = versionless[0]
consumed_collids.add(target.coll_id)
result.already_owned.append(row["title_raw"])
result.to_update.append(
{
"collid": str(target.coll_id),
"bgg_id": row["bgg_id"],
"bgg_name": row["bgg_name"] or target.name,
"version_id": row["version_id"],
"version_name": row["version_name"],
}
)
else:
# every copy already carries a (different) version — never touch it
result.already_owned.append(row["title_raw"])
result.disagreements.append(
f"{row['title_raw']}: photo suggests version "
f"{row['version_name']!r} ({row['version_id']}) but the "
"collection entry already has a version set — not changing it"
)
result.unseen = [
item for item in collection if item.object_id not in seen_object_ids
]
return result
def _write_csv(path: Path, columns: list[str], rows: list[dict]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore")
writer.writeheader()
writer.writerows(rows)
def run_diff(cfg: Config, *, client: BGGClient | None = None) -> DiffResult:
rows = read_matches(cfg.matches_path)
if not rows:
typer.echo(f"{cfg.matches_path} is empty — run `bggpipe resolve` first.")
raise typer.Exit(code=1)
if os.environ.get("BGG_API_TOKEN") and cfg.bgg_username:
typer.echo("Fetching live collection from BGG…")
client = client or BGGClient(cfg.cache_dir, cfg.rate_limit_seconds)
collection = client.collection_full(cfg.bgg_username)
else:
typer.echo(
"No BGG_API_TOKEN — using collection snapshot files in "
f"{cfg.data_dir}/ (live mode takes over once the token exists)."
)
collection = load_snapshot_collection(cfg.data_dir)
result = compute_diff(rows, collection)
_write_csv(cfg.data_dir / "to_add.csv", TO_ADD_COLUMNS, result.to_add)
_write_csv(cfg.data_dir / "to_update.csv", TO_UPDATE_COLUMNS, result.to_update)
typer.echo(
f"\n{result.recognized} recognized · {len(result.already_owned)} already "
f"owned · {len(result.to_add)} to add · {len(result.to_update)} version "
f"update(s) · {len(result.pending)} pending review · "
f"{result.rejected} rejected"
)
if result.disagreements:
typer.echo("\nVersion disagreements (left untouched):")
for line in result.disagreements:
typer.echo(f" - {line}")
if result.pending:
typer.echo("\nStill pending review: " + ", ".join(result.pending))
if result.unseen:
typer.echo(
f"\nIn your collection but not seen in any photo "
f"({len(result.unseen)} — informational only):"
)
for item in result.unseen:
typer.echo(f" - {item.name} ({item.object_id})")
return result
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<items totalitems="3" termsofuse="https://boardgamegeek.com/xmlapi/termsofuse" pubdate="Sat, 01 Aug 2026 17:53:03 +0000">
<item objecttype="thing" objectid="177" subtype="boardgameexpansion" collid="53429542">
<name sortindex="1">Advanced Civilization</name>
<yearpublished>1991</yearpublished>
<image>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__original/img/--qlhvNNj8zF9cWLlihdTg0jzmE=/0x0/filters:format(jpeg)/pic87459.jpg</image>
<thumbnail>https://cf.geekdo-images.com/9ROB7NpxefRUmpR9UroQ8w__small/img/XDH667tVl2KjQ6szNjT7ceEmh5E=/fit-in/200x150/filters:strip_icc()/pic87459.jpg</thumbnail>
<stats minplayers="2" maxplayers="8" minplaytime="360" maxplaytime="480" playingtime="480" numowned="3951">
<rating value="N/A">
<usersrated value="3412"/>
<average value="8.00904"/>
<bayesaverage value="6.91645"/>
<stddev value="1.65908"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.91645"/>
<rank type="family" id="5497" name="strategygames" friendlyname="Strategy Game Rank" value="Not Ranked" bayesaverage="7.07351"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:29:32"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="169784" subtype="boardgameexpansion" collid="53429953">
<name sortindex="1">Castle Panic: The Dark Titan</name>
<yearpublished>2015</yearpublished>
<image>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__original/img/Pe0unjKPwaO0cJPd9QI_Eo-xuRU=/0x0/filters:format(jpeg)/pic6967876.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ne5GT5tNupCbGeN4maV0aw__small/img/RTQtsNpxLidMnDPDGTTf-AiP9OQ=/fit-in/200x150/filters:strip_icc()/pic6967876.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="60" maxplaytime="60" playingtime="60" numowned="4511">
<rating value="N/A">
<usersrated value="1049"/>
<average value="7.2278"/>
<bayesaverage value="6.00948"/>
<stddev value="1.15443"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.00948"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 12:44:09"/>
<numplays>0</numplays>
</item>
<item objecttype="thing" objectid="104590" subtype="boardgameexpansion" collid="53430550">
<name sortindex="1">Castle Panic: The Wizard's Tower</name>
<yearpublished>2011</yearpublished>
<image>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__original/img/S5nr9djtA4cUDvi7dmtTiYhut1Y=/0x0/filters:format(jpeg)/pic6966120.jpg</image>
<thumbnail>https://cf.geekdo-images.com/ZI_7riAQtSAb9T3bodOVKA__small/img/VzbEp7j_wYr0KiXfvsYITTAhnSk=/fit-in/200x150/filters:strip_icc()/pic6966120.jpg</thumbnail>
<stats minplayers="1" maxplayers="6" minplaytime="90" maxplaytime="90" playingtime="90" numowned="10158">
<rating value="N/A">
<usersrated value="3587"/>
<average value="7.43829"/>
<bayesaverage value="6.63785"/>
<stddev value="1.18971"/>
<median value="0"/>
<ranks>
<rank type="subtype" id="1" name="boardgame" friendlyname="Board Game Rank" value="Not Ranked" bayesaverage="6.63785"/>
<rank type="family" id="5499" name="familygames" friendlyname="Family Game Rank" value="Not Ranked" bayesaverage="6.78135"/>
</ranks>
</rating>
</stats>
<status own="1" prevowned="0" fortrade="0" want="0" wanttoplay="0" wanttobuy="0" wishlist="0" preordered="0" lastmodified="2018-08-07 13:04:56"/>
<numplays>0</numplays>
</item>
</items>
+166
View File
@@ -0,0 +1,166 @@
"""Diff-stage tests: pure compute_diff cases plus the real 2018 collection
snapshots as parsing fixtures. No network anywhere."""
from __future__ import annotations
from pathlib import Path
from bggpipe.diff import compute_diff, load_snapshot_collection
from bggpipe.models import CollectionItem
FIXTURES = Path(__file__).parent / "fixtures"
def _item(object_id, coll_id, name="Game", version_id=None, own=True):
return CollectionItem(
object_id=object_id,
coll_id=coll_id,
name=name,
subtype="boardgame",
own=own,
year=None,
version_id=version_id,
)
def _match(
title, bgg_id="", status="auto", vstatus="version_unknown", vid="", vname=""
):
return {
"title_raw": title,
"bgg_id": bgg_id,
"bgg_name": title.title(),
"year": "2000",
"type": "boardgame",
"match_status": status,
"version_id": vid,
"version_name": vname,
"version_status": vstatus,
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": "x.jpg",
}
# -- snapshot loading ---------------------------------------------------
def test_load_real_snapshots_merges_and_dedupes():
collection = load_snapshot_collection(FIXTURES)
# 79 base + 3 expansions, but all 3 expansion collids also appear in
# the base file -> 79 unique physical copies
assert len(collection) == 79
assert len({c.coll_id for c in collection}) == 79
by_id = {c.object_id: c for c in collection}
assert by_id[207830].name == "5-Minute Dungeon"
assert by_id[177].name == "Advanced Civilization"
# hand-entered in 2018: every entry is version-less (parsed, not assumed)
assert all(c.version_id is None for c in collection)
# -- compute_diff -------------------------------------------------------
def test_new_game_goes_to_add_with_version():
result = compute_diff(
[
_match(
"Cat Crimes",
"235096",
vstatus="version_auto",
vid="360982",
vname="ThinkFun edition",
)
],
[_item(13, 1)],
)
(row,) = result.to_add
assert row["bgg_id"] == "235096"
assert row["version_id"] == "360982"
assert not result.to_update
def test_owned_versionless_plus_confident_version_goes_to_update():
result = compute_diff(
[
_match(
"Britannia",
"240",
vstatus="version_auto",
vid="55555",
vname="AH English edition",
)
],
[_item(240, 900001, name="Britannia")],
)
assert result.already_owned == ["Britannia"]
(row,) = result.to_update
assert row == {
"collid": "900001",
"bgg_id": "240",
"bgg_name": "Britannia",
"version_id": "55555",
"version_name": "AH English edition",
}
assert not result.to_add
def test_owned_with_matching_version_is_just_owned():
result = compute_diff(
[_match("Wingspan", "266192", vstatus="version_auto", vid="465063")],
[_item(266192, 5, version_id=465063)],
)
assert result.already_owned == ["Wingspan"]
assert not result.to_update and not result.to_add
def test_owned_with_different_version_reports_disagreement_untouched():
result = compute_diff(
[
_match(
"Wingspan",
"266192",
vstatus="version_auto",
vid="521212",
vname="fourth printing",
)
],
[_item(266192, 5, version_id=465063)],
)
assert result.already_owned == ["Wingspan"]
assert not result.to_update # additive only: never edit a set version
assert "fourth printing" in result.disagreements[0]
def test_version_unknown_owned_by_bare_id():
result = compute_diff(
[_match("Catan", "13")],
[_item(13, 1, name="Catan")],
)
assert result.already_owned == ["Catan"]
assert not result.to_add and not result.to_update
def test_two_photo_editions_consume_distinct_collids():
matches = [
_match("Cosmic A", "39", vstatus="version_auto", vid="111"),
_match("Cosmic B", "39", vstatus="version_auto", vid="222"),
]
collection = [_item(39, 701), _item(39, 702)]
result = compute_diff(matches, collection)
assert {r["collid"] for r in result.to_update} == {"701", "702"}
assert {r["version_id"] for r in result.to_update} == {"111", "222"}
def test_pending_rejected_and_unseen_are_reported():
matches = [
_match("Mystery Spine", status="ambiguous"),
_match("Junk", status="rejected"),
_match("Catan", "13"),
]
collection = [_item(13, 1, name="Catan"), _item(9209, 2, name="Ticket to Ride")]
result = compute_diff(matches, collection)
assert result.pending == ["Mystery Spine"]
assert result.rejected == 1
assert [c.object_id for c in result.unseen] == [9209] # informational
assert result.recognized == 1