Files
bggpipe/tests/test_review.py
T
Eric WagonerandClaude Fable 5 50a710ce99 "Local" stops being a one-way door, and the head search stops skipping
Answering Eric's question — no, a local game never looks itself up again
— by making it possible. A local row's Titles line gains "look it up",
reopening it through the SAME cascade resolve uses (board games,
truncation heads, then RPGGeek) rather than the partial re-implementation
reopen_match had; that cascade is now one shared find_candidates()
instead of two drifting copies. Review's manual (f) re-search falls back
to RPGGeek too.

That exposed a real matcher gap: truncation heads jumped from
"drop the last word" straight to "first two words", so a printed title
that buries the real name in the middle was unreachable — "ALICE IS
MISSING A SILENT ROLE PLAYING GAME" never tried "ALICE IS MISSING".
Heads now shrink from the right, longest first (bounded at six, since
each is a rate-limited request); only exact normalized matches count for
heads, so shorter heads cannot match loosely.

Both of Eric's Alice Is Missing rows now find their RPGGeek entries
(311654, and 380459 for Silent Falls) and await his picks in review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:51:54 -04:00

674 lines
22 KiB
Python

"""Review-TUI tests against a synthetic matches.csv, driven by scripted
input. BGG responses replay from the fixture cache; nothing hits the
network (the transport raises if it would)."""
from __future__ import annotations
import io
import json
from pathlib import Path
import httpx
import pytest
from rich.console import Console
from bggpipe.bgg_client import BGGClient
from bggpipe.config import Config
from bggpipe.resolve import read_matches, write_matches
from bggpipe.review import ReviewSession, run_review
from bggpipe.webreview import DismissStore
FIXTURES = Path(__file__).parent / "fixtures" / "bgg_cache"
def _no_network(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"test hit the network: {request.url}")
def fixture_client() -> BGGClient:
return BGGClient(cache_dir=FIXTURES, transport=httpx.MockTransport(_no_network))
def unauthorized_client(tmp_path) -> BGGClient:
"""Client whose every request 401s — the no-token-yet world."""
transport = httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
)
return BGGClient(cache_dir=tmp_path / "empty_cache", transport=transport)
def scripted(*answers):
it = iter(answers)
def input_fn(prompt: str) -> str:
try:
return next(it)
except StopIteration:
raise EOFError from None
return input_fn
def quiet_console() -> Console:
return Console(file=io.StringIO(), width=100)
def _row(**overrides) -> dict:
row = {
"title_raw": "",
"bgg_id": "",
"bgg_name": "",
"year": "",
"type": "",
"match_status": "auto",
"version_id": "",
"version_name": "",
"version_status": "version_unknown",
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": "hand-typed-test-list",
}
row.update(overrides)
return row
CITADELS_CANDIDATES = json.dumps(
[
{
"bgg_id": 478,
"name": "Citadels",
"year": 2000,
"type": "boardgame",
"owned": 85000,
"rank": 250,
},
{
"bgg_id": 205398,
"name": "Citadels",
"year": 2016,
"type": "boardgame",
"owned": 24000,
"rank": 400,
},
]
)
WINGSPAN_VERSION_CANDIDATES = json.dumps(
[
{
"version_id": 465063,
"name": "English first edition",
"year": 2019,
"publishers": ["Stonemaier Games"],
"languages": ["English"],
"score": 5,
},
{
"version_id": 521212,
"name": "English fourth printing",
"year": 2020,
"publishers": ["Stonemaier Games"],
"languages": ["English"],
"score": 5,
},
]
)
def _cfg(tmp_path) -> Config:
return Config(data_dir=tmp_path / "data")
def _setup(tmp_path, rows) -> Config:
cfg = _cfg(tmp_path)
write_matches(cfg.matches_path, rows)
return cfg
def test_ambiguous_pick_approves_candidate(tmp_path):
cfg = _setup(
tmp_path,
[
_row(
title_raw="Citadels",
match_status="ambiguous",
candidates_json=CITADELS_CANDIDATES,
)
],
)
run_review(
cfg, console=quiet_console(), input_fn=scripted("2"), client=fixture_client()
)
(row,) = read_matches(cfg.matches_path)
assert row["match_status"] == "approved"
assert row["bgg_id"] == "205398"
assert row["year"] == "2016"
def test_reject_and_skip(tmp_path):
cfg = _setup(
tmp_path,
[
_row(title_raw="Blorvath", match_status="unmatched"),
_row(
title_raw="Citadels",
match_status="ambiguous",
candidates_json=CITADELS_CANDIDATES,
),
],
)
run_review(
cfg,
console=quiet_console(),
input_fn=scripted("r", "s"),
client=fixture_client(),
)
rows = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert rows["Blorvath"]["match_status"] == "rejected"
assert rows["Citadels"]["match_status"] == "ambiguous" # skipped, still pending
def test_unmatched_manual_id_degrades_without_token(tmp_path):
cfg = _setup(tmp_path, [_row(title_raw="Some Rare Game", match_status="unmatched")])
run_review(
cfg,
console=quiet_console(),
input_fn=scripted("m 99999"),
client=unauthorized_client(tmp_path),
)
(row,) = read_matches(cfg.matches_path)
assert row["match_status"] == "approved"
assert row["bgg_id"] == "99999"
assert row["bgg_name"] == "" # lookup blocked; id recorded anyway
def test_unmatched_research_from_fixture_cache_with_version(tmp_path):
cfg = _setup(tmp_path, [_row(title_raw="Wingspan", match_status="unmatched")])
cfg.titles_path.parent.mkdir(parents=True, exist_ok=True)
cfg.titles_path.write_text(
json.dumps(
[
{
"title_raw": "Wingspan",
"publisher_hint": "Stonemaier Games",
"year_hint": 2019,
"language_hint": "English",
"source_photos": ["x.jpg"],
}
]
)
)
run_review(
cfg,
console=quiet_console(),
# the real search lists a fan pack first; Wingspan proper is #2
input_fn=scripted("f Wingspan", "2"),
client=fixture_client(),
)
(row,) = read_matches(cfg.matches_path)
assert row["match_status"] == "approved"
assert row["bgg_id"] == "266192"
# cues + the real version list -> a handful of English Stonemaier
# printings stay plausible; the edition pass gets the ballot
assert row["version_status"] == "version_ambiguous"
assert "433233" in row["version_candidates_json"]
def test_resumable_quit_midway_then_continue(tmp_path):
rows = [
_row(
title_raw="Citadels",
match_status="ambiguous",
candidates_json=CITADELS_CANDIDATES,
),
_row(title_raw="Blorvath", match_status="unmatched"),
]
cfg = _setup(tmp_path, rows)
# first sitting: one decision, then input runs out (EOF == quit)
run_review(
cfg, console=quiet_console(), input_fn=scripted("1"), client=fixture_client()
)
by_title = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert by_title["Citadels"]["match_status"] == "approved"
assert by_title["Blorvath"]["match_status"] == "unmatched" # untouched
# second sitting resumes exactly where we left off
run_review(
cfg, console=quiet_console(), input_fn=scripted("r"), client=fixture_client()
)
by_title = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert by_title["Blorvath"]["match_status"] == "rejected"
def test_version_pass_pick_and_unknown(tmp_path):
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wingspan",
match_status="auto",
bgg_id="266192",
version_status="version_ambiguous",
version_candidates_json=WINGSPAN_VERSION_CANDIDATES,
),
_row(
title_raw="Catan",
match_status="auto",
bgg_id="13",
version_status="version_ambiguous",
version_candidates_json=WINGSPAN_VERSION_CANDIDATES,
),
],
)
run_review(
cfg,
console=quiet_console(),
input_fn=scripted("y", "1", "u"),
client=fixture_client(),
)
by_title = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert by_title["Wingspan"]["version_status"] == "version_approved"
assert by_title["Wingspan"]["version_id"] == "465063"
assert by_title["Catan"]["version_status"] == "version_unknown"
assert by_title["Catan"]["version_id"] == ""
def test_version_pass_is_skippable(tmp_path):
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wingspan",
match_status="auto",
bgg_id="266192",
version_status="version_ambiguous",
version_candidates_json=WINGSPAN_VERSION_CANDIDATES,
)
],
)
run_review(
cfg, console=quiet_console(), input_fn=scripted("n"), client=fixture_client()
)
(row,) = read_matches(cfg.matches_path)
assert row["version_status"] == "version_ambiguous" # untouched, review later
# -- concurrent-rewrite and degradation safety --------------------------
def test_veto_merge_persists_against_future_dedupe(tmp_path):
cfg = _setup(
tmp_path,
[
_row(title_raw="CATAN", match_status="merged", merged_into="Catan"),
_row(title_raw="Catan", match_status="auto", bgg_id="13"),
],
)
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
merged = next(r for r in session.rows if r["match_status"] == "merged")
session.veto_merge(merged)
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["CATAN"]["match_status"] == "approved"
assert saved["CATAN"]["dedupe_veto"] == "1" # survives resolve re-runs
def test_failed_save_never_leaves_memory_ahead_of_disk(tmp_path, monkeypatch):
cfg = _setup(tmp_path, [_row(title_raw="Mystery", match_status="unmatched")])
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
row = session.rows[0]
def exploding_write(path, rows):
raise OSError("disk full")
monkeypatch.setattr("bggpipe.review.write_matches", exploding_write)
with pytest.raises(OSError):
session.decide_reject(row)
# memory was rolled back to what disk actually holds
assert session.rows[0]["match_status"] == "unmatched"
assert session.decisions == 0
def test_save_merges_own_decision_over_concurrent_external_rewrite(tmp_path):
cfg = _setup(tmp_path, [_row(title_raw="Mystery", match_status="unmatched")])
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
row = session.rows[0]
# resolve appends a new row in another terminal AFTER our session loaded
external = read_matches(cfg.matches_path)
external.append(_row(title_raw="Newcomer", match_status="auto", bgg_id="7"))
write_matches(cfg.matches_path, external)
session.decide_reject(row)
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert saved["Mystery"]["match_status"] == "rejected" # our decision
assert "Newcomer" in saved # their row survived too
def test_manual_id_unknown_to_bgg_warns_instead_of_crashing(tmp_path):
empty_things = BGGClient(
cache_dir=tmp_path / "cache",
transport=httpx.MockTransport(
lambda req: httpx.Response(200, text='<items total="0"></items>')
),
sleep=lambda s: None,
)
cfg = _setup(tmp_path, [_row(title_raw="Mystery", match_status="unmatched")])
session = ReviewSession(
cfg, console=quiet_console(), input_fn=scripted(), client=empty_things
)
session.decide_manual(session.rows[0], 999999)
assert session.rows[0]["match_status"] == "approved"
assert session.rows[0]["bgg_id"] == "999999"
assert any("no game with id 999999" in w for w in session.warnings)
def test_every_tui_decision_after_external_rewrite_is_saved(tmp_path):
# run() iterates row references snapshotted before any reload; every
# decision must be re-adopted into the current list or it would be
# counted but never written.
cfg = _setup(
tmp_path,
[
_row(title_raw="Alpha", match_status="unmatched"),
_row(title_raw="Beta", match_status="unmatched"),
_row(title_raw="Gamma", match_status="unmatched"),
],
)
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
stale_refs = list(session.pending_rows()) # what run() iterates
external = read_matches(cfg.matches_path)
external.append(_row(title_raw="Newcomer", match_status="auto", bgg_id="7"))
write_matches(cfg.matches_path, external)
for ref in stale_refs: # decision 1 reloads; 2 and 3 are orphaned refs
session.decide_reject(ref)
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert [saved[t]["match_status"] for t in ("Alpha", "Beta", "Gamma")] == (
["rejected"] * 3
)
assert "Newcomer" in saved # the external row survived too
assert session.decisions == 3
def test_fill_version_uses_the_approved_rows_own_cues(tmp_path):
# two same-title entries are two editions: the version lookup must use
# the row's own photo-keyed cues, never a title-keyed last-wins dict
entry_good = {
"title_raw": "Wingspan",
"publisher_hint": "Stonemaier", # matches the fixture's version
"year_hint": 2019,
"source_photos": ["good.jpg"],
}
entry_bad = {
"title_raw": "Wingspan",
"publisher_hint": "Nobody Media", # matches nothing
"edition_hint": "Imaginary edition",
"source_photos": ["bad.jpg"],
}
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wingspan",
match_status="ambiguous",
source_photos="good.jpg",
candidates_json=json.dumps(
[{"bgg_id": 266192, "name": "Wingspan", "year": 2019}]
),
)
],
)
(cfg.data_dir / "titles.json").write_text(json.dumps([entry_good, entry_bad]))
two_versions = """<items><item type="boardgame" id="266192">
<name type="primary" value="Wingspan"/><yearpublished value="2019"/>
<versions>
<item type="boardgameversion" id="111">
<name type="primary" value="English first edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
<link type="language" id="2184" value="English"/>
</item>
<item type="boardgameversion" id="222">
<name type="primary" value="German edition"/>
<yearpublished value="2019"/>
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
<link type="language" id="2188" value="German"/>
</item>
</versions></item></items>"""
crafted = BGGClient(
cache_dir=tmp_path / "crafted_cache",
transport=httpx.MockTransport(
lambda req: httpx.Response(200, text=two_versions)
),
)
session = ReviewSession(
cfg, console=quiet_console(), input_fn=scripted(), client=crafted
)
row = session.rows[0]
session.decide_pick(row, {"bgg_id": 266192, "name": "Wingspan", "year": 2019})
# the row's own photo (good.jpg) must select entry_good's cues
assert row["version_status"] == "version_auto"
assert row["version_id"] == "111"
def test_dismiss_failure_keeps_ticket_visible(tmp_path, monkeypatch):
store = DismissStore(tmp_path / "dismissed.json")
def exploding(path, text):
raise OSError("disk full")
monkeypatch.setattr("bggpipe.webreview.atomic_write_text", exploding)
with pytest.raises(OSError):
store.add("photo|loc|txt|art")
assert store.keys == set() # memory never claims what disk doesn't hold
def test_corrupt_dismiss_file_is_quarantined_not_fatal(tmp_path):
path = tmp_path / "dismissed.json"
path.write_text('["torn')
with pytest.warns(UserWarning, match="unreadable"):
store = DismissStore(path)
assert store.keys == set()
assert (tmp_path / "dismissed.json.corrupt").exists()
def test_split_row_makes_per_photo_vetoed_copies(tmp_path):
from bggpipe.resolve import read_matches
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wiz-War",
match_status="approved",
bgg_id="104710",
bgg_name="Wiz-War",
source_photos="a.jpg;b.jpg;c.jpg",
version_candidates_json='[{"version_id": 1, "name": "8th"}]',
)
],
)
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
copies = session.split_row(session.rows[0])
saved = read_matches(cfg.matches_path)
assert [r["source_photos"] for r in saved] == ["a.jpg", "b.jpg", "c.jpg"]
assert all(r["dedupe_veto"] == "1" for r in saved)
assert all(r["bgg_id"] == "104710" for r in saved) # match survives
# each copy picks its own edition: candidates kept, status reopened
assert all(r["version_status"] == "version_ambiguous" for r in saved)
assert all(r["version_id"] == "" for r in saved)
assert len(copies) == 3
def test_split_copies_survive_resolve_rerun(tmp_path):
from bggpipe.resolve import read_matches, run_resolve
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wiz-War",
match_status="approved",
bgg_id="104710",
source_photos="a.jpg;b.jpg;c.jpg",
)
],
)
(cfg.data_dir / "titles.json").write_text(
json.dumps(
[{"title_raw": "Wiz-War", "source_photos": ["a.jpg", "b.jpg", "c.jpg"]}]
)
)
session = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=unauthorized_client(tmp_path),
)
session.split_row(session.rows[0])
run_resolve(cfg, client=fixture_client())
saved = read_matches(cfg.matches_path)
assert len(saved) == 3 # not re-merged, not re-resolved
assert [r["source_photos"] for r in saved] == ["a.jpg", "b.jpg", "c.jpg"]
def test_open_version_ballot_puts_every_edition_up_for_review(tmp_path):
# a cue-less row is version_unknown by design — but the human knows
# which printing the box is; the ballot fetches the FULL version list
cfg = _setup(
tmp_path,
[
_row(
title_raw="Wingspan",
match_status="auto",
bgg_id="266192",
bgg_name="Wingspan",
version_status="version_unknown",
)
],
)
session = ReviewSession(
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
)
row = session.rows[0]
count = session.open_version_ballot(row)
assert count == 46 # the real recorded version list, complete
assert row["version_status"] == "version_ambiguous"
candidates = json.loads(row["version_candidates_json"])
assert {c["version_id"] for c in candidates} >= {433233}
saved = read_matches(cfg.matches_path)[0]
assert saved["version_status"] == "version_ambiguous" # persisted
unmatched = _setup(tmp_path / "second", [_row(title_raw="Mystery")])
session2 = ReviewSession(
unmatched, console=quiet_console(), input_fn=scripted(), client=fixture_client()
)
with pytest.raises(ValueError, match="no BGG match"):
session2.open_version_ballot(session2.rows[0])
def test_local_decision_keeps_a_real_game_off_bgg(tmp_path):
cfg = _setup(
tmp_path, [_row(title_raw="Obscure Homebrew", match_status="unmatched")]
)
run_review(
cfg, console=quiet_console(), input_fn=scripted("l"), client=fixture_client()
)
(row,) = read_matches(cfg.matches_path)
assert row["match_status"] == "local"
assert row["bgg_id"] == "" # a local citizen carries no BGG identity
def test_reopen_match_returns_a_fresh_ballot(tmp_path):
""" "Wrong game" must not strand the row: it re-searches immediately
and comes back ambiguous with the candidates on the card — resolve
(which never touches unmatched rows) is not part of this loop."""
cfg = _setup(
tmp_path,
[
_row(
title_raw="WIZ-WAR",
match_status="auto",
bgg_id="589",
bgg_name="Wiz-War",
)
],
)
session = ReviewSession(
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
)
row = session.rows[0]
session.reopen_match(row)
assert row["match_status"] == "ambiguous"
names = {c["name"] for c in json.loads(row["candidates_json"])}
assert "Wiz-War" in names
assert any("Eighth Edition" in n for n in names) # the lineages surface
# BGG unreachable: the row still reopens, bare but actionable
dead = ReviewSession(
cfg,
console=quiet_console(),
input_fn=scripted(),
client=BGGClient(
cache_dir=tmp_path / "empty",
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
),
)
row2 = dead.rows[0]
dead.reopen_match(row2)
assert row2["match_status"] == "unmatched"
assert dead.warnings # the degradation is visible
def test_local_rows_can_be_looked_up_again_on_rpggeek(tmp_path):
""" "Local" means BGG has no board game by that name — but RPGGeek,
the same database under type=rpgitem, often does. A local row must not
be a one-way door."""
cfg = _setup(
tmp_path,
[
_row(
title_raw="ALICE IS MISSING: A SILENT ROLE PLAYING GAME",
match_status="local",
)
],
)
session = ReviewSession(
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
)
row = session.rows[0]
session.reopen_match(row)
assert row["match_status"] == "ambiguous"
candidates = json.loads(row["candidates_json"])
assert any(c["type"] == "rpgitem" for c in candidates)
assert any("Alice is Missing" in c["name"] for c in candidates)