The real-data era: token live, stubs retired, editions on demand

BGG application approved. The migration the stub markers guarded for
weeks: both synthetic caches deleted; tests/fixtures/bgg_cache
re-recorded from the live API (recording list extended to every
scenario the suite exercises — Civilization truncation, the Sorcerer
SPI tiebreak, StarForce, Flat Top's thematic year, Alice Is Missing's
rpgitem fallback); resolve --force re-matched all 133 titles for real
(109 auto, 6 ambiguous, 18 unmatched, 30 edition ballots);
data/STUB_DATA.marker deleted with its exit condition met — the guard
mechanism stays armed should stubs ever regenerate.

Reality fixed one bug and taught one lesson. The bug: a multi-type
search lists an expansion twice (once per matched type) and the parser
kept the generic boardgame entry — parse_search now dedupes by id
preferring the specific type, which is what keeps expansion tagging
(the base-vs-expansion review guard) alive on real data. The lesson:
hand-built ambiguity is tidier than the real thing — Wingspan has 46
versions with three plausible English Stonemaier printings, so the
suite's synthetic version ids and version_auto expectations became
real ballots (assertions updated to recorded reality; the cue-plumbing
test keeps its crafted two-version scenario via an injected
transport).

New: pick edition. A cue-less matched row is version_unknown by design
(never guess) — but the owner knows which printing the box is.
open_version_ballot() fetches the game's complete version list,
cue-scores it when cues exist, and marks the row version_ambiguous so
the normal Review edition pass presents it; the Titles page grows the
button (Eric's three Wiz-Wars: two cue-less copies can now each claim
their edition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-05 18:50:17 -04:00
co-authored by Claude Fable 5
parent 15d3120029
commit 59f4b8c43c
135 changed files with 15851 additions and 266 deletions
+27 -18
View File
@@ -58,18 +58,18 @@ def rows_by_title(client) -> dict[str, object]:
def test_catan_auto_matches_base_game(rows_by_title):
row = rows_by_title["Catan"]
assert (row.match_status, row.bgg_id, row.bgg_name) == ("auto", 13, "CATAN")
assert (row.match_status, row.bgg_id, row.bgg_name) == ("auto", 13, "Catan")
assert row.version_status == "version_unknown" # no cues -> never guess
def test_wingspan_auto_with_version_from_cues(rows_by_title):
row = rows_by_title["Wingspan"]
assert (row.match_status, row.bgg_id) == ("auto", 266192)
# cues: Stonemaier + 2019 + English -> the first edition, not the
# 2020 printing (score 5 vs 3) and not the German edition
assert row.version_status == "version_auto"
assert row.version_id == 465063
assert row.version_name == "English first edition"
# Stonemaier + 2019 + English narrows 46 real versions to a few
# English printings — several plausible, so review decides; the 2019
# first printing must be on the ballot
assert row.version_status == "version_ambiguous"
assert 433233 in {v["version_id"] for v in row.version_candidates}
def test_expansion_matches_expansion_not_base(rows_by_title):
@@ -87,7 +87,7 @@ def test_wingspan_europe_spine_never_matches_base(rows_by_title):
def test_accented_title_resolves(rows_by_title):
row = rows_by_title["Café International"]
assert (row.match_status, row.bgg_id) == ("auto", 373)
assert (row.match_status, row.bgg_id) == ("auto", 214)
def test_same_name_editions_stay_ambiguous(rows_by_title):
@@ -110,17 +110,19 @@ def test_run_resolve_writes_csv_and_is_idempotent(client, tmp_path):
cfg = Config(data_dir=data_dir)
first = run_resolve(cfg, client=client)
assert len(first) == 7
assert len(first) == 13
with cfg.matches_path.open(newline="") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 7
assert len(rows) == 13
by_title = {r["title_raw"]: r for r in rows}
assert by_title["Catan"]["match_status"] == "auto"
assert by_title["Citadels"]["match_status"] == "ambiguous"
candidates = json.loads(by_title["Citadels"]["candidates_json"])
assert len(candidates) == 2
assert by_title["Wingspan"]["version_id"] == "465063"
# ambiguous edition: no id recorded, the candidates carry the ballot
assert by_title["Wingspan"]["version_status"] == "version_ambiguous"
assert "433233" in by_title["Wingspan"]["version_candidates_json"]
# second run: everything already in matches.csv is skipped, file unchanged
before = cfg.matches_path.read_text()
@@ -239,9 +241,13 @@ def test_civilization_resolves_via_truncation(client):
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 71)
# publisher cue picks the Avalon Hill version, not Hartland/Gibsons
assert row.version_status == "version_auto"
assert row.version_id == 71001
# the publisher cue keeps Avalon Hill printings on the ballot; the
# real version list is too crowded for a single confident pick
assert row.version_status == "version_ambiguous"
assert any(
any("Avalon Hill" in p for p in (v.get("publishers") or []))
for v in row.version_candidates
)
def test_advanced_civilization_resolves_as_expansion(client):
@@ -286,7 +292,7 @@ def test_starforce_two_word_head_matches_full_title(client):
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 2524)
assert row.version_status == "version_auto"
assert row.version_status == "version_ambiguous"
def test_wrong_year_hint_never_drives_a_version(client):
@@ -307,8 +313,11 @@ def test_run_resolve_saves_progress_when_token_missing(tmp_path):
crashing the run and losing everything."""
partial_cache = tmp_path / "cache"
partial_cache.mkdir()
for f in FIXTURES.glob("search_query=Catan-*"):
shutil.copy(f, partial_cache / f.name)
for pattern in ("search_query=Catan-*", "thing_id=13-*"):
# the real Catan search has enough hits that the popularity
# tiebreak fetches /thing stats — that request is cached too
for f in FIXTURES.glob(pattern):
shutil.copy(f, partial_cache / f.name)
data_dir = tmp_path / "data"
data_dir.mkdir()
@@ -508,7 +517,7 @@ def test_run_resolve_force_rebuilds_from_scratch(client, tmp_path):
write_matches(cfg.matches_path, rows)
forced = run_resolve(cfg, force=True, client=client)
assert len(forced) == 7 # every title re-resolved, none skipped
assert len(forced) == 13 # every title re-resolved, none skipped
fresh = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert fresh["Catan"]["match_status"] == "auto"
@@ -693,4 +702,4 @@ def test_rpg_falls_back_to_rpgitem_search(client):
row = resolve_entry(client, entry)
assert row.match_status == "auto"
assert row.type == "rpgitem"
assert row.bgg_id == 400001
assert row.bgg_id == 311654