"""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='') ), 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)