Audit: 5-reviewer sweep — 19 fixes across every stage, +24 tests
Correctness: review vetoes persist via a dedupe_veto column (resolve re-runs no longer overturn humans); diff emits second copies whose confident version matches no owned copy (spec: pairs own only on both ids) and fetches the live collection with refresh; resolve pairs titles.json entries to rows by title so a reshoot photo updates provenance instead of duplicating rows; version lookups survive empty /thing results; publisher tie-break now honors the mixed base/expansion veto and refuses multi-candidate picks; empty-normalized (non-Latin) titles never count as exact. Upload: LoginError aborts a run instead of logging N bogus failures (and 3 identical consecutive failures abort as systemic); Cloudflare interstitials are detected; added-without-version gets its own logged status that verify understands; same-game updates run one per pass so the name-targeted row edit can't overwrite a fresh version; absent diff outputs fail loudly; pagination clicks are paced. Web review: a lock serializes freshen/decide (threadpool race dropped decisions); failed saves roll memory back and always alert the browser (non-JSON 500s included); session warnings reach the page instead of a StringIO; state-load failures and dead servers show banners instead of a blank page; duplicate (title, photos) rows are addressable by ordinal. Consistency: shared CONFIDENT_VERSION_STATUSES, client_for(), Config paths for every artifact, one review-port constant, named matching thresholds, strict collection-id parsing, error-doc responses never cached, unknown config keys warn, extract reports dropped vision entries, fixture generators share escaping + marker text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+120
-12
@@ -14,17 +14,23 @@ from pathlib import Path
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from bggpipe.bgg_client import BGGClient
|
||||
from bggpipe.bgg_client import BGGClient, cache_key
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.models import GameVersion
|
||||
from bggpipe.normalize import normalize_title
|
||||
from bggpipe.resolve import (
|
||||
Candidate,
|
||||
MatchRow,
|
||||
TitleEntry,
|
||||
_dominant,
|
||||
_score_version,
|
||||
_truncation_heads,
|
||||
dedupe_matches,
|
||||
load_titles,
|
||||
read_matches,
|
||||
resolve_entry,
|
||||
run_resolve,
|
||||
write_matches,
|
||||
)
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures" / "bgg_cache"
|
||||
@@ -191,9 +197,6 @@ def test_score_version_no_overlap():
|
||||
|
||||
# -- progressive title truncation (long transcribed box titles) ---------
|
||||
|
||||
from bggpipe.normalize import normalize_title # noqa: E402
|
||||
from bggpipe.resolve import _truncation_heads # noqa: E402
|
||||
|
||||
CIV_TITLE = (
|
||||
"CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC"
|
||||
)
|
||||
@@ -347,13 +350,15 @@ def test_run_resolve_saves_progress_when_token_missing(tmp_path):
|
||||
|
||||
# -- post-resolve dedupe ------------------------------------------------
|
||||
|
||||
from bggpipe.bgg_client import cache_key as _cache_key # noqa: E402
|
||||
from bggpipe.resolve import dedupe_matches # noqa: E402
|
||||
|
||||
|
||||
def _mrow(
|
||||
title, bgg_id, photos, name="Joking Hazard",
|
||||
vstatus="version_unknown", vid="", status="auto",
|
||||
title,
|
||||
bgg_id,
|
||||
photos,
|
||||
name="Joking Hazard",
|
||||
vstatus="version_unknown",
|
||||
vid="",
|
||||
status="auto",
|
||||
):
|
||||
return {
|
||||
"title_raw": title,
|
||||
@@ -446,8 +451,6 @@ def test_dedupe_is_idempotent_and_skips_merged():
|
||||
|
||||
|
||||
def test_run_resolve_dedupes_and_keeps_all_rows(tmp_path):
|
||||
from bggpipe.resolve import write_matches as _wm # noqa: F401
|
||||
|
||||
cache = tmp_path / "cache"
|
||||
cache.mkdir()
|
||||
wingspan_xml = (
|
||||
@@ -456,7 +459,7 @@ def test_run_resolve_dedupes_and_keeps_all_rows(tmp_path):
|
||||
"</item></items>"
|
||||
)
|
||||
for query in ("Wingspan", "WINGSPAN!"):
|
||||
key = _cache_key(
|
||||
key = cache_key(
|
||||
"search", {"query": query, "type": "boardgame,boardgameexpansion"}
|
||||
)
|
||||
(cache / key).write_text(wingspan_xml)
|
||||
@@ -482,3 +485,108 @@ def test_run_resolve_dedupes_and_keeps_all_rows(tmp_path):
|
||||
assert saved["Wingspan"]["match_status"] == "auto"
|
||||
assert saved["WINGSPAN!"]["match_status"] == "merged"
|
||||
assert saved["WINGSPAN!"]["merged_into"] == "Wingspan"
|
||||
|
||||
|
||||
# -- audit-fix regressions ----------------------------------------------
|
||||
|
||||
|
||||
def test_run_resolve_force_rebuilds_from_scratch(client, tmp_path):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
shutil.copy(TITLES_JSON, data_dir / "titles.json")
|
||||
cfg = Config(data_dir=data_dir)
|
||||
run_resolve(cfg, client=client)
|
||||
|
||||
# poison one row: force must throw it away and re-resolve everything
|
||||
rows = read_matches(cfg.matches_path)
|
||||
rows[0]["match_status"] = "rejected"
|
||||
write_matches(cfg.matches_path, rows)
|
||||
|
||||
forced = run_resolve(cfg, force=True, client=client)
|
||||
assert len(forced) == 7 # every title re-resolved, none skipped
|
||||
fresh = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
|
||||
assert fresh["Catan"]["match_status"] == "auto"
|
||||
|
||||
|
||||
def test_new_photo_of_resolved_game_updates_row_instead_of_duplicating(
|
||||
client, tmp_path
|
||||
):
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
shutil.copy(TITLES_JSON, data_dir / "titles.json")
|
||||
cfg = Config(data_dir=data_dir)
|
||||
run_resolve(cfg, client=client)
|
||||
n_rows = len(read_matches(cfg.matches_path))
|
||||
|
||||
# extract sees Catan again on a reshoot photo: the entry's photo set
|
||||
# grows, its (title, photos) key changes
|
||||
titles = json.loads((data_dir / "titles.json").read_text())
|
||||
for entry in titles:
|
||||
if entry["title_raw"] == "Catan":
|
||||
entry["source_photos"] = sorted([*entry["source_photos"], "reshoot.jpg"])
|
||||
(data_dir / "titles.json").write_text(json.dumps(titles))
|
||||
|
||||
assert run_resolve(cfg, client=client) == [] # nothing re-resolved
|
||||
rows = read_matches(cfg.matches_path)
|
||||
assert len(rows) == n_rows # and no duplicate row appended
|
||||
catan = next(r for r in rows if r["title_raw"] == "Catan")
|
||||
assert "reshoot.jpg" in catan["source_photos"] # provenance followed
|
||||
|
||||
|
||||
def test_dedupe_never_overturns_a_human_veto():
|
||||
a = _mrow("CATAN", "13", "p1.jpg")
|
||||
b = _mrow("Catan", "13", "p2.jpg", status="approved")
|
||||
b["dedupe_veto"] = "1" # review said: genuinely two copies
|
||||
events = dedupe_matches([a, b], [])
|
||||
assert events == []
|
||||
assert b["match_status"] == "approved"
|
||||
|
||||
|
||||
def test_publisher_pick_refuses_multiple_same_publisher_candidates():
|
||||
from bggpipe.resolve import _publisher_pick
|
||||
|
||||
entry = TitleEntry(
|
||||
title_raw="Sorcerer", title_normalized="sorcerer", publisher_hint="SPI"
|
||||
)
|
||||
cands = [
|
||||
_cand(1, exact=True),
|
||||
_cand(2, exact=True),
|
||||
]
|
||||
for c in cands:
|
||||
c.publishers = ["Simulations Publications, Inc. (SPI)"]
|
||||
assert _publisher_pick(entry, cands) is None
|
||||
|
||||
|
||||
def test_publisher_pick_refuses_mixed_base_and_expansion():
|
||||
from bggpipe.resolve import _publisher_pick
|
||||
|
||||
entry = TitleEntry(
|
||||
title_raw="Wingspan", title_normalized="wingspan", publisher_hint="Stonemaier"
|
||||
)
|
||||
base = _cand(1, exact=True, type_="boardgame")
|
||||
expansion = _cand(2, exact=True, type_="boardgameexpansion")
|
||||
for c in (base, expansion):
|
||||
c.publishers = ["Stonemaier Games"]
|
||||
assert _publisher_pick(entry, [base, expansion]) is None
|
||||
|
||||
|
||||
def test_resolve_version_handles_unknown_id():
|
||||
from bggpipe.resolve import resolve_version
|
||||
|
||||
class EmptyThings:
|
||||
def things(self, ids, **kwargs):
|
||||
return []
|
||||
|
||||
entry = TitleEntry(title_raw="X", title_normalized="x", publisher_hint="Someone")
|
||||
row = MatchRow(title_raw="X", bgg_id=999999)
|
||||
resolve_version(EmptyThings(), entry, row) # must not raise
|
||||
assert row.version_status == "version_unknown"
|
||||
|
||||
|
||||
def test_empty_normalized_title_never_matches(client):
|
||||
# 风声 normalizes to "" — empty-vs-empty must not count as exact
|
||||
from bggpipe.resolve import _plausible_candidates
|
||||
|
||||
entry = TitleEntry(title_raw="风声", title_normalized="")
|
||||
# any cached query works; candidates must be rejected regardless of name
|
||||
assert _plausible_candidates(client, entry, "Catan") == []
|
||||
|
||||
Reference in New Issue
Block a user