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:
@@ -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.
|
||||
- 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.
|
||||
- Idempotency: skip IDs already logged `added`; `--verify` re-fetches the collection to confirm.
|
||||
|
||||
|
||||
@@ -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).
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
- Idempotent: skip IDs already logged as `added`; re-verify against a fresh collection fetch on `--verify`.
|
||||
- Deliberately slow: 2–4 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)
|
||||
- `bgg_cache/` — cached XML API responses
|
||||
- `matches.csv` — the master matching table (stages 2–3)
|
||||
- `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)
|
||||
- `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.
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -67,7 +67,10 @@ def review(config: ConfigOpt = None) -> None:
|
||||
@app.command()
|
||||
def diff(config: ConfigOpt = None) -> None:
|
||||
"""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()
|
||||
|
||||
@@ -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
|
||||
+1038
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user