Review stage: resumable rich TUI for matches and versions
Prompt loop over ambiguous/unmatched rows: pick a candidate (table with owned/rank), skip, reject, enter a manual BGG id, or free-text re-search via the cached client. Approvals attempt version resolution from the title's edition cues, degrading to version_unknown when the API is unreachable (no token yet). Optional, skippable version pass for version_ambiguous rows. Every decision rewrites matches.csv atomically, so q/Ctrl-C/EOF mid-session loses nothing. Tests drive the loop with scripted input against a synthetic matches.csv and the fixture cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
"""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
|
||||
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 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
|
||||
Reference in New Issue
Block a user