The automatic cascade only reaches RPGGeek when BGG's board-game search comes up empty — so every D&D box, which BGG does list as board games, can never find its RPGGeek entry no matter how many times it is reopened. Eric hit exactly that and settled for keeping them local. Match cards now carry an editable query with two buttons, "search BGG" and "search RPGGeek", which replace the row's ballot with whatever the chosen database returns (owned counts and ranks attached when available; if that stats call fails the results still stand and the degradation is reported). The TUI's (f) re-search falls back to RPGGeek automatically when the board-game search is empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
701 lines
23 KiB
Python
701 lines
23 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)
|
|
|
|
|
|
def test_research_can_target_rpggeek_explicitly(tmp_path):
|
|
"""BGG has board games called "Dungeons & Dragons", so the automatic
|
|
cascade never reaches RPGGeek for them — only an explicit search can."""
|
|
cfg = _setup(tmp_path, [_row(title_raw="Wingspan", match_status="unmatched")])
|
|
session = ReviewSession(
|
|
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
|
|
)
|
|
row = session.rows[0]
|
|
# cached search, but the stats batch for these results was never
|
|
# recorded: losing the decoration must not lose the search
|
|
session.client = BGGClient(
|
|
cache_dir=FIXTURES,
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(401, text="Unauthorized")
|
|
),
|
|
)
|
|
found = session.research(row, "Citadels")
|
|
assert found and row["match_status"] == "ambiguous"
|
|
ids = {c["bgg_id"] for c in json.loads(row["candidates_json"])}
|
|
assert {478, 205398} <= ids
|
|
assert session.warnings # the missing stats are reported, not swallowed
|
|
# a fresh ballot supersedes the previous verdict
|
|
assert row["bgg_id"] == ""
|
|
with pytest.raises(ValueError, match="needs some text"):
|
|
session.research(row, " ")
|