The web layer's serialization story had three gaps: /api/run started a stage without the lock, so a decision mid-save could pass the rewrite guard and still be clobbered by the stage's full rewrite (now the start itself serializes); /api/photos accepted a replacement photo while extract was running, permanently pairing the new bytes with the old photo's reads (now refuses like every other mutation); and /api/queue read session rows lock-free and stale (now freshens under the lock). The localhost Host allowlist applied only to writes — a DNS-rebound page could read pipeline state and shelf photos with plain GETs; it now covers all methods (foreign-Origin reads still pass: without CORS headers a cross-origin page can't read the response anyway). Data-loss finds: the off-BGG edit form re-rendered from games.json, which only sees hand data after enrich — so a second save resubmitted pre-save blanks and cleared the first (the detail endpoint now overlays local_games.json live). The local key embeds the photo list, so a new sighting orphaned hand-written facts silently; enrich now migrates them when the title still matches exactly one line, and warns instead of ever dropping. research() left the previous game's version verdicts on the row, riding a stale version_id onto the next pick; it clears all four fields as reopen does. find_row now prefers the version-open sibling on duplicate keys, mirroring _adopt. Re-adding a removed hand-added title silently no-opped behind a 200 — it now rescinds the removal (an explicit undo), and a true duplicate add answers 409. Smaller: parse_search's dedupe collapsed same-id rows under DIFFERENT names, discarding the alternate-name row whose exact match downstream scoring needed (now collapses same-name only; research merges its ballot per game preferring exact evidence); rpgitems rank in their own family so their rank parsed null; the pipeline badge counted review-retired queue rows as pending; the catalog pairing cascade ran per-entry so a tier-3 claim could steal a sibling's exact row (now tier-by-tier across all entries, as resolve does); library cards render a lone player bound without "undefined" and the seats filter tolerates it; added_no_version reads "done · no version" instead of a bare green done. Every finding verified against the code before fixing; each fix carries a regression test. 337 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
764 lines
25 KiB
Python
764 lines
25 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, " ")
|
|
|
|
|
|
def test_find_row_prefers_the_version_open_sibling(tmp_path):
|
|
"""Two decided duplicate rows: a version pick with a stale row_ix must
|
|
land on the sibling whose VERSION is still open, not overwrite the
|
|
other's earlier human decision (mirrors _adopt)."""
|
|
cfg = _setup(
|
|
tmp_path,
|
|
[
|
|
_row(
|
|
title_raw="Wiz-War",
|
|
match_status="approved",
|
|
bgg_id="589",
|
|
version_status="version_approved",
|
|
version_id="1",
|
|
),
|
|
_row(
|
|
title_raw="Wiz-War",
|
|
match_status="approved",
|
|
bgg_id="589",
|
|
version_status="version_ambiguous",
|
|
),
|
|
],
|
|
)
|
|
session = ReviewSession(
|
|
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
|
|
)
|
|
row = session.find_row("Wiz-War", "hand-typed-test-list", row_ix=None)
|
|
assert row["version_status"] == "version_ambiguous"
|
|
|
|
|
|
def test_research_clears_the_previous_games_version(tmp_path):
|
|
"""A fresh ballot supersedes EVERYTHING about the old match: a
|
|
surviving version_id would put the old game's edition on whatever the
|
|
human picks next."""
|
|
cfg = _setup(
|
|
tmp_path,
|
|
[
|
|
_row(
|
|
title_raw="Citadels",
|
|
match_status="approved",
|
|
bgg_id="478",
|
|
version_status="version_approved",
|
|
version_id="99999",
|
|
version_name="Old Game's Edition",
|
|
)
|
|
],
|
|
)
|
|
session = ReviewSession(
|
|
cfg, console=quiet_console(), input_fn=scripted(), client=fixture_client()
|
|
)
|
|
# stats batch for these results isn't recorded; decoration may fail
|
|
session.client = BGGClient(
|
|
cache_dir=FIXTURES,
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(401, text="Unauthorized")
|
|
),
|
|
)
|
|
row = session.rows[0]
|
|
session.research(row, "Citadels")
|
|
assert row["version_status"] == ""
|
|
assert row["version_id"] == "" and row["version_name"] == ""
|
|
assert row["version_candidates_json"] == "[]"
|