Web review UI: bggpipe review --web (FastAPI, localhost, no build step)
One self-contained page (inline CSS/JS, system fonts, works offline): match cards show source photos, extracted cues, and candidates with cached-XML thumbnails (placeholder tiles until real fixtures exist); actions are pick / manual BGG id / reject, plus a skippable editions pass (pick or unknown). Keyboard-first: j/k navigate, 1-9 pick, r reject, m manual, u unknown, d dismiss. Every decision writes matches.csv through the same ReviewSession methods the TUI now shares — the TUI remains as the no-flag fallback. unidentified.json renders as visually distinct reshoot work-orders with dismissals persisted in data/unidentified_dismissed.json (survives extract rebuilds). Progress tally and a diff-ready done screen; photo serving is allowlisted to photos/ contents; server binds 127.0.0.1 only. Layout leaves room for a later games.json browse view. Provenance guard: fixture generators now write STUB_FIXTURES.marker into their cache dirs, and CLAUDE.md gains the hard rule that stub- resolved version_ids are placeholders — upload must refuse to run while data/bgg_cache/STUB_FIXTURES.marker exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""Web review UI tests via FastAPI's TestClient — no server, no network,
|
||||
no live BGG (the injected client 401s on any cache miss)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from bggpipe.bgg_client import BGGClient, cache_key
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.resolve import read_matches, write_matches
|
||||
from bggpipe.webreview import create_app, load_thumbnails
|
||||
|
||||
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,
|
||||
},
|
||||
]
|
||||
)
|
||||
VERSION_CANDIDATES = json.dumps(
|
||||
[
|
||||
{
|
||||
"version_id": 111,
|
||||
"name": "First edition",
|
||||
"year": 1975,
|
||||
"publishers": ["TSR"],
|
||||
"languages": ["English"],
|
||||
"score": 3,
|
||||
},
|
||||
{
|
||||
"version_id": 222,
|
||||
"name": "Second edition",
|
||||
"year": 1980,
|
||||
"publishers": ["TSR"],
|
||||
"languages": ["English"],
|
||||
"score": 3,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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": "shelf.jpg",
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
def make_cfg(tmp_path) -> Config:
|
||||
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
||||
cfg.photos_dir.mkdir(parents=True)
|
||||
(cfg.photos_dir / "shelf.jpg").write_bytes(b"\xff\xd8\xff\xdbfakejpeg")
|
||||
write_matches(
|
||||
cfg.matches_path,
|
||||
[
|
||||
_row(
|
||||
title_raw="Citadels",
|
||||
match_status="ambiguous",
|
||||
candidates_json=CITADELS_CANDIDATES,
|
||||
),
|
||||
_row(title_raw="Mystery", match_status="unmatched"),
|
||||
_row(
|
||||
title_raw="Dungeon!",
|
||||
match_status="auto",
|
||||
bgg_id="1339",
|
||||
bgg_name="Dungeon!",
|
||||
version_status="version_ambiguous",
|
||||
version_candidates_json=VERSION_CANDIDATES,
|
||||
),
|
||||
],
|
||||
)
|
||||
cfg.titles_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"title_raw": "Citadels",
|
||||
"publisher_hint": "Fantasy Flight",
|
||||
"edition_hint": "",
|
||||
"source_photos": ["shelf.jpg"],
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
cfg.unidentified_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"shelf.jpg": [
|
||||
{
|
||||
"location": "top shelf, far left",
|
||||
"partial_text": "EMP",
|
||||
"art_notes": "black box, gold letters",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
return cfg
|
||||
|
||||
|
||||
def unauthorized_client(tmp_path) -> BGGClient:
|
||||
return BGGClient(
|
||||
cache_dir=tmp_path / "no_cache",
|
||||
transport=httpx.MockTransport(
|
||||
lambda req: httpx.Response(401, text="Unauthorized")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def make_client(tmp_path) -> tuple[TestClient, Config]:
|
||||
cfg = make_cfg(tmp_path)
|
||||
app = create_app(cfg, client=unauthorized_client(tmp_path))
|
||||
return TestClient(app), cfg
|
||||
|
||||
|
||||
def test_index_serves_page(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
response = web.get("/")
|
||||
assert response.status_code == 200
|
||||
assert "bggpipe" in response.text
|
||||
|
||||
|
||||
def test_state_lists_pending_versions_and_tickets(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
state = web.get("/api/state").json()
|
||||
assert [r["title_raw"] for r in state["pending"]] == ["Citadels", "Mystery"]
|
||||
citadels = state["pending"][0]
|
||||
assert citadels["cues"]["publisher"] == "Fantasy Flight"
|
||||
assert citadels["photos"] == ["shelf.jpg"]
|
||||
assert citadels["candidates"][0]["thumbnail"] is None # stub cache: placeholder
|
||||
assert [v["title_raw"] for v in state["versions"]] == ["Dungeon!"]
|
||||
assert state["unidentified"][0]["location"] == "top shelf, far left"
|
||||
assert state["summary"]["total"] == 3
|
||||
|
||||
|
||||
def test_pick_candidate_persists(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
state = web.post(
|
||||
"/api/decision",
|
||||
json={
|
||||
"title_raw": "Citadels",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "pick",
|
||||
"bgg_id": 205398,
|
||||
},
|
||||
).json()
|
||||
assert [r["title_raw"] for r in state["pending"]] == ["Mystery"]
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Citadels"]["match_status"] == "approved"
|
||||
assert saved["Citadels"]["bgg_id"] == "205398"
|
||||
|
||||
|
||||
def test_manual_id_degrades_without_token(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
response = web.post(
|
||||
"/api/decision",
|
||||
json={
|
||||
"title_raw": "Mystery",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "manual",
|
||||
"bgg_id": 99999,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Mystery"]["match_status"] == "approved"
|
||||
assert saved["Mystery"]["bgg_id"] == "99999"
|
||||
assert saved["Mystery"]["bgg_name"] == "" # lookup blocked, id recorded
|
||||
|
||||
|
||||
def test_reject_and_bad_pick(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
web.post(
|
||||
"/api/decision",
|
||||
json={"title_raw": "Mystery", "source_photos": "shelf.jpg", "action": "reject"},
|
||||
)
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Mystery"]["match_status"] == "rejected"
|
||||
bad = web.post(
|
||||
"/api/decision",
|
||||
json={
|
||||
"title_raw": "Citadels",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "pick",
|
||||
"bgg_id": 42,
|
||||
},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
|
||||
def test_version_pick_and_unknown(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
state = web.post(
|
||||
"/api/version",
|
||||
json={
|
||||
"title_raw": "Dungeon!",
|
||||
"source_photos": "shelf.jpg",
|
||||
"action": "pick",
|
||||
"version_id": 222,
|
||||
},
|
||||
).json()
|
||||
assert state["versions"] == []
|
||||
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert saved["Dungeon!"]["version_status"] == "version_approved"
|
||||
assert saved["Dungeon!"]["version_id"] == "222"
|
||||
assert state["summary"]["version_updates"] == 1
|
||||
|
||||
|
||||
def test_dismiss_persists_across_restarts(tmp_path):
|
||||
web, cfg = make_client(tmp_path)
|
||||
state = web.post(
|
||||
"/api/dismiss",
|
||||
json={
|
||||
"photo": "shelf.jpg",
|
||||
"location": "top shelf, far left",
|
||||
"partial_text": "EMP",
|
||||
"art_notes": "black box, gold letters",
|
||||
},
|
||||
).json()
|
||||
assert state["unidentified"] == []
|
||||
# a brand-new app instance (fresh server) still honors the dismissal
|
||||
web2 = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
||||
assert web2.get("/api/state").json()["unidentified"] == []
|
||||
|
||||
|
||||
def test_photo_serving_is_locked_down(tmp_path):
|
||||
web, _ = make_client(tmp_path)
|
||||
assert web.get("/photos/shelf.jpg").status_code == 200
|
||||
assert web.get("/photos/nope.jpg").status_code == 404
|
||||
assert web.get("/photos/..%2Fdata%2Fmatches.csv").status_code == 404
|
||||
|
||||
|
||||
def test_thumbnails_come_from_cached_thing_xml(tmp_path):
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
key = cache_key("thing", {"id": "478", "stats": "1"})
|
||||
(cache / key).write_text(
|
||||
'<items><item type="boardgame" id="478">'
|
||||
"<thumbnail>https://cf.example/citadels.jpg</thumbnail>"
|
||||
'<name type="primary" value="Citadels"/></item></items>'
|
||||
)
|
||||
thumbs = load_thumbnails(cache)
|
||||
assert thumbs == {478: "https://cf.example/citadels.jpg"}
|
||||
Reference in New Issue
Block a user