Files
bggpipe/tests/test_review.py
T
Eric Wagoner 65d4cdd5ec Re-audit round 2: 5 blind reviewers, 17 fixes, +12 tests
The re-run confirmed round 1 held and then caught second-order bugs in
its own fixes plus two long-standing ones everyone missed. TUI decisions
after a mid-session reload were counted but never written (rows are now
re-adopted into the fresh list on every save, preferring undecided slots
on duplicate keys); row_ix was computed by equality so duplicate rows
shared an ordinal (identity now, merges included, veto sends it); upload
job keys collided for two same-version copies (completions are counted
per key, so --limit or an interrupt can no longer strand the second
copy); diff consumes collids on exact-version matches (a vetoed
same-version second copy was silently swallowed) and splits mismatches:
report-only disagreement while an unclaimed copy exists, second-copy add
only when every copy is claimed.

Also: XML responses are validated and written atomically before caching
(a torn or truncated 200 body can never poison a re-run), JSON artifacts
write atomically, thing/search parsers refuse missing ids like the
collection parser, empty game names are refused by the upload queue, a
never-rendering version picker fails retryably instead of terminally,
the systemic-failure abort compares exception types, blocked same-title
entries defer as a group so positional pairing can't misalign,
truncation heads pick the earliest separator, diff messages tell the
truth when a token exists without a username, and the shared-constant
sweep now actually covers every module (statuses, search types, marker
names, client_for, ports). pydantic declared as a direct dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:34:44 -04:00

422 lines
13 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
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(),
input_fn=scripted("f Wingspan", "1"),
client=fixture_client(),
)
(row,) = read_matches(cfg.matches_path)
assert row["match_status"] == "approved"
assert row["bgg_id"] == "266192"
# cues + fixture versions -> resolved to the 2019 Stonemaier English edition
assert row["version_status"] == "version_auto"
assert row["version_id"] == "465063"
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
# -- audit-fix regressions ----------------------------------------------
def test_veto_merge_persists_against_future_dedupe(tmp_path):
from bggpipe.resolve import read_matches
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]
import bggpipe.review as review_mod
def exploding_write(path, rows):
raise OSError("disk full")
monkeypatch.setattr(review_mod, "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):
from bggpipe.resolve import read_matches, write_matches
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):
# an empty /thing result (mistyped id) used to crash the whole session
import httpx as _httpx
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):
# THE round-2 catch: the TUI iterates row references snapshotted before
# any reload; after decision 1 triggers a reload, decisions 2..N used
# to be counted but never written.
from bggpipe.resolve import read_matches, write_matches
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