f19b861251
Eric's observation: cleaning up raw reads happens BEFORE resolve, but the app ordered Review ahead of Catalog and never said proofreading was a step at all. The pipeline has two human checkpoints — proofread the reads (after extract), decide the matches (after resolve) — and now the app says so: the sidebar runs Pipeline, Photos, Titles, Review, Queue, Library, Help in true workflow order; the Titles page (né Catalog — the old name suggested a finished collection, which is the Library's job; /catalog redirects) gets a badge counting unresolved shaky reads; an edit marks its entry human-verified so the badge drains as you proofread; the extract stage card nudges toward the proofread before resolving; and the Help flow is rewritten as six stages + two checkpoints with the loop called out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
826 lines
26 KiB
Python
826 lines
26 KiB
Python
"""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"],
|
|
},
|
|
{
|
|
# extracted but never resolved (no BGG token yet)
|
|
"title_raw": "Fresh Off The Shelf",
|
|
"confidence": "high",
|
|
"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"}
|
|
|
|
|
|
def test_catalog_and_unresolved_backlog_are_visible(tmp_path):
|
|
"""Extracted-but-unresolved titles must not vanish from the UI, and the
|
|
done state must not claim diff-readiness while they exist."""
|
|
web, _ = make_client(tmp_path)
|
|
state = web.get("/api/state").json()
|
|
assert state["summary"]["extracted"] == 2
|
|
assert state["summary"]["unresolved"] == 1
|
|
by_title = {c["title_raw"]: c for c in state["catalog"]}
|
|
assert by_title["Fresh Off The Shelf"]["status"] == "awaiting_resolve"
|
|
assert by_title["Citadels"]["status"] == "ambiguous"
|
|
|
|
|
|
def test_merge_veto_roundtrip(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Jokin Ha...",
|
|
match_status="merged",
|
|
bgg_id="193621",
|
|
bgg_name="Joking Hazard",
|
|
source_photos="shelf.jpg",
|
|
)
|
|
)
|
|
rows[-1]["merged_into"] = "Joking Hazard"
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
|
|
state = web.get("/api/state").json()
|
|
(merge,) = state["merges"]
|
|
assert merge["title_raw"] == "Jokin Ha..."
|
|
assert merge["merged_into"] == "Joking Hazard"
|
|
|
|
state = web.post(
|
|
"/api/veto-merge",
|
|
json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
|
|
).json()
|
|
assert state["merges"] == []
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Jokin Ha..."]["match_status"] == "approved"
|
|
assert saved["Jokin Ha..."]["merged_into"] == ""
|
|
|
|
# veto on a non-merged row is refused
|
|
bad = web.post(
|
|
"/api/veto-merge",
|
|
json={"title_raw": "Jokin Ha...", "source_photos": "shelf.jpg"},
|
|
)
|
|
assert bad.status_code == 400
|
|
|
|
|
|
def test_static_assets_served_with_allowlist(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
logo = web.get("/static/logo.jpg")
|
|
assert logo.status_code == 200
|
|
assert logo.headers["content-type"] == "image/jpeg"
|
|
assert web.get("/static/favicon.png").status_code == 200
|
|
assert web.get("/static/nope.js").status_code == 404
|
|
assert web.get("/static/..%2Ftemplates%2Freview.html").status_code == 404
|
|
|
|
|
|
# -- live data reload ---------------------------------------------------
|
|
|
|
|
|
def test_state_picks_up_external_matches_rewrite(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
assert web.get("/api/state").json()["summary"]["total"] == 3
|
|
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
|
|
write_matches(cfg.matches_path, rows)
|
|
|
|
fresh = web.get("/api/state").json()
|
|
assert fresh["summary"]["total"] == 4
|
|
assert any(p["title_raw"] == "Newcomer" for p in fresh["pending"])
|
|
|
|
|
|
def test_decision_lands_on_externally_added_row(tmp_path):
|
|
# A row that did not exist at server start is still reviewable.
|
|
web, cfg = make_client(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(_row(title_raw="Newcomer", match_status="unmatched"))
|
|
write_matches(cfg.matches_path, rows)
|
|
|
|
res = web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": "Newcomer",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "reject",
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
|
assert saved["Newcomer"]["match_status"] == "rejected"
|
|
|
|
|
|
def test_own_saves_do_not_count_as_external_changes(tmp_path):
|
|
from bggpipe.review import ReviewSession
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
session = ReviewSession(cfg, client=unauthorized_client(tmp_path))
|
|
row = next(r for r in session.rows if r["title_raw"] == "Mystery")
|
|
session.decide_reject(row)
|
|
assert session.reload_if_changed() is False # my own write
|
|
write_matches(cfg.matches_path, session.rows + [_row(title_raw="X")])
|
|
assert session.reload_if_changed() is True # someone else's
|
|
assert any(r["title_raw"] == "X" for r in session.rows)
|
|
|
|
|
|
def test_session_warnings_surface_in_state(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
# a manual id triggers a lookup against the 401-ing client: the session
|
|
# degrades and the warning must reach the payload (the session console
|
|
# is a StringIO here — this is the only way the user ever sees it)
|
|
res = web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": "Mystery",
|
|
"source_photos": "shelf.jpg",
|
|
"action": "manual",
|
|
"bgg_id": 42,
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
warnings = res.json()["warnings"]
|
|
assert any("couldn't look up id 42" in w for w in warnings)
|
|
assert warnings == web.get("/api/state").json()["warnings"]
|
|
|
|
|
|
def test_duplicate_rows_are_individually_decidable_via_row_ix(tmp_path):
|
|
# two editions of one game in one photo: byte-identical rows. The
|
|
# ordinal must land each decision on its own row.
|
|
from bggpipe.resolve import read_matches as read_m
|
|
from bggpipe.resolve import write_matches as write_m
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
dup = _row(title_raw="Twins", match_status="unmatched")
|
|
write_m(cfg.matches_path, [dict(dup), dict(dup)])
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
|
|
pending = web.get("/api/state").json()["pending"]
|
|
assert [p["title_raw"] for p in pending] == ["Twins", "Twins"]
|
|
assert pending[0]["row_ix"] != pending[1]["row_ix"] # identity, not ==
|
|
|
|
second = pending[1]
|
|
web.post(
|
|
"/api/decision",
|
|
json={
|
|
"title_raw": second["title_raw"],
|
|
"source_photos": second["source_photos"],
|
|
"row_ix": second["row_ix"],
|
|
"action": "reject",
|
|
},
|
|
)
|
|
rows = read_m(cfg.matches_path)
|
|
assert [r["match_status"] for r in rows] == ["unmatched", "rejected"]
|
|
|
|
|
|
# -- catalog curation: rowless splits and title edits ---------------------
|
|
|
|
|
|
def _rowless_wizwar(cfg) -> None:
|
|
"""Add a multi-photo extracted title with NO matches row (still awaiting
|
|
resolve — the Wiz-War shape)."""
|
|
titles = json.loads(cfg.titles_path.read_text())
|
|
titles.append(
|
|
{
|
|
"title_raw": "Wiz-War",
|
|
"confidence": "high",
|
|
"publisher_hint": "Fantasy Flight Games",
|
|
"edition_hint": "",
|
|
"source_photos": ["shelf.jpg", "shelf2.jpg"],
|
|
}
|
|
)
|
|
cfg.titles_path.write_text(json.dumps(titles))
|
|
|
|
|
|
def test_rowless_multiphoto_title_is_splittable(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
_rowless_wizwar(cfg)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
line = next(
|
|
c
|
|
for c in web.get("/api/state").json()["catalog"]
|
|
if c["title_raw"] == "Wiz-War"
|
|
)
|
|
assert line["status"] == "awaiting_resolve"
|
|
assert line["can_split"] is True
|
|
|
|
res = web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"row_ix": None,
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
copies = [c for c in res.json()["catalog"] if c["title_raw"] == "Wiz-War"]
|
|
assert [c["photos"] for c in copies] == [["shelf.jpg"], ["shelf2.jpg"]]
|
|
assert all(c["can_split"] is False for c in copies)
|
|
# the decision persisted: the store holds it (photo-scoped) and
|
|
# titles.json is split
|
|
(record,) = json.loads(cfg.title_splits_path.read_text())
|
|
assert record == {"title": "Wiz-War", "photos": ["shelf.jpg", "shelf2.jpg"]}
|
|
per_photo = [
|
|
e["source_photos"]
|
|
for e in json.loads(cfg.titles_path.read_text())
|
|
if e["title_raw"] == "Wiz-War"
|
|
]
|
|
assert per_photo == [["shelf.jpg"], ["shelf2.jpg"]]
|
|
|
|
|
|
def test_split_still_requires_multiple_photos(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
res = web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Fresh Off The Shelf",
|
|
"source_photos": "shelf.jpg",
|
|
"row_ix": None,
|
|
},
|
|
)
|
|
assert res.status_code == 400
|
|
|
|
|
|
def test_edit_title_corrects_read_and_requeues_row(tmp_path):
|
|
web, cfg = make_client(tmp_path)
|
|
# Citadels has an ambiguous matches row; correcting its read must drop
|
|
# the stale row (it was searched with the old data) and store the fix
|
|
res = web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "Citadels: Dark City",
|
|
"edition": "2nd Edition",
|
|
"publisher": "",
|
|
"year": "2004",
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
state = res.json()
|
|
titles = [c["title_raw"] for c in state["catalog"]]
|
|
assert "Citadels: Dark City" in titles and "Citadels" not in titles
|
|
corrected = next(
|
|
e
|
|
for e in json.loads(cfg.titles_path.read_text())
|
|
if e["title_raw"] == "Citadels: Dark City"
|
|
)
|
|
assert corrected["year_hint"] == 2004
|
|
assert corrected["edition_hint"] == "2nd Edition"
|
|
assert corrected["publisher_hint"] == "" # empty string cleared the cue
|
|
assert not corrected.get("language_hint") # untouched (was unset)
|
|
assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
|
|
(record,) = json.loads(cfg.title_edits_path.read_text())
|
|
assert record["match"] == "Citadels"
|
|
assert record["publisher_hint"] == ""
|
|
|
|
|
|
def test_edit_title_rejects_empty_and_noop(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": " ",
|
|
},
|
|
).status_code
|
|
== 400
|
|
)
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
|
|
).status_code
|
|
== 400
|
|
)
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "No Such Game",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "X",
|
|
},
|
|
).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_edit_preserves_vetoed_rows_and_renames_them(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Citadels",
|
|
match_status="approved",
|
|
bgg_id="478",
|
|
source_photos="shelf2.jpg",
|
|
dedupe_veto="1",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
res = web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Citadels",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "Citadels (2016)",
|
|
},
|
|
)
|
|
assert res.status_code == 200
|
|
after = read_matches(cfg.matches_path)
|
|
# the human-vetoed row survives the cull, renamed to follow the entry;
|
|
# the unvetoed ambiguous row is requeued (dropped)
|
|
veto = [r for r in after if r.get("dedupe_veto")]
|
|
assert len(veto) == 1
|
|
assert veto[0]["title_raw"] == "Citadels (2016)"
|
|
assert veto[0]["match_status"] == "approved"
|
|
assert not any(
|
|
r["title_raw"] == "Citadels" and not r.get("dedupe_veto") for r in after
|
|
)
|
|
|
|
|
|
def test_drop_rows_photo_narrowing_spares_other_copy(tmp_path):
|
|
import io as _io
|
|
|
|
from rich.console import Console
|
|
|
|
from bggpipe.review import ReviewSession
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
write_matches(
|
|
cfg.matches_path,
|
|
[
|
|
_row(title_raw="Wiz-War", match_status="auto", source_photos="a.jpg"),
|
|
_row(title_raw="Wiz-War", match_status="auto", source_photos="b.jpg"),
|
|
],
|
|
)
|
|
session = ReviewSession(
|
|
cfg,
|
|
console=Console(file=_io.StringIO()),
|
|
input_fn=lambda prompt: "",
|
|
client=unauthorized_client(tmp_path),
|
|
)
|
|
assert session.drop_rows("Wiz-War", ["a.jpg"]) == 1
|
|
(survivor,) = read_matches(cfg.matches_path)
|
|
assert survivor["source_photos"] == "b.jpg"
|
|
|
|
|
|
def test_split_and_edit_refuse_while_pipeline_rewrites(tmp_path):
|
|
import threading
|
|
|
|
from bggpipe.jobs import JobRunner
|
|
|
|
cfg = make_cfg(tmp_path)
|
|
_rowless_wizwar(cfg)
|
|
release = threading.Event()
|
|
started = threading.Event()
|
|
|
|
def blocking_extract():
|
|
started.set()
|
|
release.wait(timeout=5)
|
|
|
|
jobs = JobRunner()
|
|
web = TestClient(
|
|
create_app(
|
|
cfg,
|
|
client=unauthorized_client(tmp_path),
|
|
stages={"extract": blocking_extract},
|
|
jobs=jobs,
|
|
)
|
|
)
|
|
assert web.post("/api/run/extract").status_code == 200
|
|
assert started.wait(timeout=5)
|
|
try:
|
|
split = web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"row_ix": None,
|
|
},
|
|
)
|
|
edit = web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"title_new": "Wiz-War!",
|
|
},
|
|
)
|
|
finally:
|
|
release.set()
|
|
assert split.status_code == 409
|
|
assert edit.status_code == 409
|
|
# neither curation store was written under the in-flight rewrite
|
|
assert not cfg.title_splits_path.exists()
|
|
assert not cfg.title_edits_path.exists()
|
|
|
|
|
|
def test_renaming_a_split_copy_keeps_split_protection(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
_rowless_wizwar(cfg)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
assert (
|
|
web.post(
|
|
"/api/split",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": "shelf.jpg;shelf2.jpg",
|
|
"row_ix": None,
|
|
},
|
|
).status_code
|
|
== 200
|
|
)
|
|
# rename BOTH copies to the same corrected title, one at a time — the
|
|
# store must keep protecting them or the rebuild re-merges the copies
|
|
for photo in ("shelf.jpg", "shelf2.jpg"):
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Wiz-War",
|
|
"source_photos": photo,
|
|
"title_new": "Wiz War 2000",
|
|
},
|
|
).status_code
|
|
== 200
|
|
)
|
|
per_photo = [
|
|
e["source_photos"]
|
|
for e in json.loads(cfg.titles_path.read_text())
|
|
if e["title_raw"] == "Wiz War 2000"
|
|
]
|
|
assert per_photo == [["shelf.jpg"], ["shelf2.jpg"]] # still two copies
|
|
|
|
|
|
def test_single_photo_rowless_entry_is_not_splittable(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
line = next(
|
|
c
|
|
for c in web.get("/api/state").json()["catalog"]
|
|
if c["title_raw"] == "Fresh Off The Shelf"
|
|
)
|
|
assert line["can_split"] is False
|
|
|
|
|
|
def test_remove_title_is_durable_and_drops_vetoed_rows(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
rows = read_matches(cfg.matches_path)
|
|
rows.append(
|
|
_row(
|
|
title_raw="Citadels",
|
|
match_status="approved",
|
|
bgg_id="478",
|
|
source_photos="shelf.jpg",
|
|
dedupe_veto="1",
|
|
)
|
|
)
|
|
write_matches(cfg.matches_path, rows)
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
res = web.post(
|
|
"/api/remove-title",
|
|
json={"title_raw": "Citadels", "source_photos": "shelf.jpg"},
|
|
)
|
|
assert res.status_code == 200
|
|
assert "Citadels" not in [c["title_raw"] for c in res.json()["catalog"]]
|
|
# removal is explicit: the veto'd row goes too (unlike edits)
|
|
assert not any(r["title_raw"] == "Citadels" for r in read_matches(cfg.matches_path))
|
|
(record,) = json.loads(cfg.title_removals_path.read_text())
|
|
assert record == {"title": "Citadels", "photos": ["shelf.jpg"]}
|
|
# durable: a fresh replay keeps it gone; the sibling title is untouched
|
|
from bggpipe.extract import replay_titles
|
|
|
|
replay_titles(cfg)
|
|
titles = {e["title_raw"] for e in json.loads(cfg.titles_path.read_text())}
|
|
assert titles == {"Fresh Off The Shelf"}
|
|
# unknown titles still 404
|
|
assert (
|
|
web.post(
|
|
"/api/remove-title",
|
|
json={"title_raw": "No Such Game", "source_photos": "x.jpg"},
|
|
).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_help_page_serves_and_is_in_nav(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
res = web.get("/help")
|
|
assert res.status_code == 200
|
|
assert "Fixing the titles" in res.text
|
|
# every page's nav includes the Help entry
|
|
assert 'href="/help"' in web.get("/").text
|
|
|
|
|
|
def test_shaky_reads_badge_counts_and_drains_on_edit(tmp_path):
|
|
cfg = make_cfg(tmp_path)
|
|
titles = json.loads(cfg.titles_path.read_text())
|
|
titles.append(
|
|
{
|
|
"title_raw": "Hebarceos",
|
|
"confidence": "low",
|
|
"source_photos": ["shelf.jpg"],
|
|
}
|
|
)
|
|
cfg.titles_path.write_text(json.dumps(titles))
|
|
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
|
|
assert web.get("/api/pipeline").json()["shaky_reads"] == 1
|
|
# an edit means a human read the line: it is verified, the badge drains
|
|
assert (
|
|
web.post(
|
|
"/api/edit-title",
|
|
json={
|
|
"title_raw": "Hebarceos",
|
|
"source_photos": "shelf.jpg",
|
|
"title_new": "Herbaceous",
|
|
},
|
|
).status_code
|
|
== 200
|
|
)
|
|
assert web.get("/api/pipeline").json()["shaky_reads"] == 0
|
|
|
|
|
|
def test_nav_order_matches_workflow(tmp_path):
|
|
web, _ = make_client(tmp_path)
|
|
html = web.get("/").text
|
|
order = [
|
|
html.index(f'href="{href}"')
|
|
for href in (
|
|
"/",
|
|
"/photos",
|
|
"/titles",
|
|
"/review",
|
|
"/queue",
|
|
"/library",
|
|
"/help",
|
|
)
|
|
]
|
|
assert order == sorted(order)
|
|
# the old address still lands on the page
|
|
assert web.get("/catalog", follow_redirects=False).headers["location"] == "/titles"
|