Re-audit round 3: 5 blind reviewers, 15 fixes, +18 tests — converging

Round 3's two HIGHs: _fill_version resolved versions with the LAST
same-title entry's cues (photo-aware lookup existed since round 1 but
this caller never used it), and the round-2 diff rework let an earlier
row's disagreement consume the exact-version copy a later row matched.
Diff claims now settle strongest-first across all rows (exact matches,
then versionless upgrades, then disagreement/second-copy), unvetoed
bare duplicates stay owned per spec, and updates are withheld with a
manual-fix note whenever any copy of the game already carries a version
(the row edit targets by name and could hit the wrong copy).

Also: entry-to-row pairing matches by photo overlap before position
(titles.json order churn from reshoot filenames could swap editions);
BGGQueueTimeout defers a title like a missing token; DismissStore
writes atomically, mutates memory only after the write, and
quarantines a torn file instead of bricking the server; version-picker
page-limit exhaustion stays retryable; verify's copy-count shortfall
reports once per game (the old guard was dead code); the upload log
header is created atomically; transient version-lookup failures record
a retryable version_error, not terminal version_unknown; extract
isolates per-photo failures and salvages JSON followed by prose; a
state revision counter stops stale poll responses reverting decisions;
plus the shared-predicate/fsio/docstring consolidation and CLI wiring,
live-diff, verify-wiring, and search-guard tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 14:55:04 -04:00
parent 65d4cdd5ec
commit 92aaa91a49
24 changed files with 719 additions and 187 deletions
+91 -15
View File
@@ -6,14 +6,10 @@ from __future__ import annotations
from pathlib import Path
from bggpipe.config import Config
from bggpipe.diff import compute_diff, load_snapshot_collection
from bggpipe.diff import SNAPSHOT_FILES, compute_diff, load_snapshot_collection
from bggpipe.models import CollectionItem
FIXTURES = Path(__file__).parent / "fixtures"
SNAPSHOT_NAMES = (
"collection_snapshot_base.xml",
"collection_snapshot_expansions.xml",
)
def _item(object_id, coll_id, name="Game", version_id=None, own=True):
@@ -154,17 +150,50 @@ def test_vetoed_duplicate_of_same_version_is_a_real_second_copy():
assert len(result.second_copies) == 1
def test_bare_duplicate_beyond_owned_count_is_added_versionless():
# Two vetoed version-unknown rows, one owned copy: the extra bare row
# is a version-less second copy, not silently "already owned".
rows = [_match("Catan", "13"), _match("Catan", "13")]
result = compute_diff(rows, [_item(13, 900)])
def test_vetoed_bare_duplicate_beyond_owned_count_is_added_versionless():
# Two version-unknown rows, one owned copy: only a HUMAN VETO makes the
# extra bare row a genuine second copy (spec: a bare id is owned if any
# copy exists — an unvetoed typo-read sibling must not upload).
vetoed = {**_match("Catan", "13"), "dedupe_veto": "1"}
result = compute_diff([_match("Catan", "13"), vetoed], [_item(13, 900)])
assert result.already_owned == ["Catan"]
(added,) = result.to_add
assert added["version_id"] == ""
assert len(result.second_copies) == 1
def test_unvetoed_bare_duplicate_stays_owned():
# same shape WITHOUT the veto: both rows owned, nothing uploaded
rows = [_match("Catan", "13"), _match("Catan", "13")]
result = compute_diff(rows, [_item(13, 900)])
assert result.already_owned == ["Catan", "Catan"]
assert result.to_add == []
def test_earlier_disagreement_cannot_steal_a_later_rows_exact_match():
# round-3 ordering bug: row A (v3, no match) must not consume the v2
# copy that row B exactly matches — exact matches settle first
rows = [
_match("Catan", "13", vstatus="version_auto", vid="3", vname="v3"),
_match("Catan", "13", vstatus="version_auto", vid="2", vname="v2"),
]
result = compute_diff(rows, [_item(13, 900, version_id=2)])
assert result.already_owned == ["Catan"] # B's exact match claims the copy
# A's v3 box exists on the shelf and matches no collection entry: a
# genuine new copy — NOT a spurious v2 duplicate, NOT a false disagreement
assert [r["version_id"] for r in result.to_add] == ["3"]
assert result.disagreements == []
def test_update_withheld_when_another_copy_is_versioned():
# upload's row edit targets by NAME: an update is only safe when every
# copy is versionless, else it could overwrite the versioned copy
rows = [_match("Catan", "13", vstatus="version_auto", vid="5", vname="5th")]
result = compute_diff(rows, [_item(13, 900, version_id=7), _item(13, 901)])
assert result.to_update == []
assert "set it by hand" in result.disagreements[0]
def test_bare_row_does_not_steal_versionless_copy_from_confident_update():
# ordering independence: the confident row upgrades the versionless
# copy even when a bare row of the same game appears first in the file
@@ -256,10 +285,7 @@ def test_run_diff_outputs_feed_upload_unchanged(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
cfg = Config(data_dir=tmp_path)
fixtures = Path(__file__).parent / "fixtures"
for name in (
"collection_snapshot_base.xml",
"collection_snapshot_expansions.xml",
):
for name in SNAPSHOT_FILES:
shutil.copy(fixtures / name, tmp_path / name)
write_matches(
cfg.matches_path,
@@ -290,10 +316,60 @@ def test_token_without_username_says_so(tmp_path, monkeypatch, capsys):
monkeypatch.delenv("BGG_USERNAME", raising=False)
cfg = Config(data_dir=tmp_path) # bgg_username defaults to ""
fixtures = Path(__file__).parent / "fixtures"
for name in SNAPSHOT_NAMES:
for name in SNAPSHOT_FILES:
shutil.copy(fixtures / name, tmp_path / name)
write_matches(cfg.matches_path, [_match("Catan", "13")])
run_diff(cfg)
out = capsys.readouterr().out
assert "BGG_API_TOKEN is set but BGG_USERNAME is not" in out
assert "No BGG_API_TOKEN" not in out # the old message was a lie here
class _LiveClient:
"""Fake client recording collection_full calls for the live branch."""
def __init__(self, collection, fail_auth=False):
self.collection = collection
self.fail_auth = fail_auth
self.calls: list[dict] = []
def collection_full(self, username, *, refresh=False):
from bggpipe.bgg_client import BGGAuthError
self.calls.append({"username": username, "refresh": refresh})
if self.fail_auth:
raise BGGAuthError("token rejected")
return self.collection
def test_live_diff_fetches_fresh_collection(tmp_path, monkeypatch):
# the branch that runs the day the token arrives: must call
# collection_full with refresh=True, not serve resolve-era cache
from bggpipe.diff import run_diff
from bggpipe.resolve import write_matches
monkeypatch.setenv("BGG_API_TOKEN", "tok")
monkeypatch.setenv("BGG_USERNAME", "eric")
cfg = Config(bgg_username="eric", data_dir=tmp_path)
write_matches(cfg.matches_path, [_match("Catan", "13")])
client = _LiveClient([_item(13, 1, name="Catan")])
result = run_diff(cfg, client=client)
assert client.calls == [{"username": "eric", "refresh": True}]
assert result.already_owned == ["Catan"]
def test_live_diff_falls_back_to_snapshots_on_auth_failure(tmp_path, monkeypatch):
import shutil
from bggpipe.diff import run_diff
from bggpipe.resolve import write_matches
monkeypatch.setenv("BGG_API_TOKEN", "bad")
monkeypatch.setenv("BGG_USERNAME", "eric")
cfg = Config(bgg_username="eric", data_dir=tmp_path)
for name in SNAPSHOT_FILES:
shutil.copy(FIXTURES / name, tmp_path / name)
write_matches(cfg.matches_path, [_match("5 MINUTE DUNGEON", "207830")])
result = run_diff(cfg, client=_LiveClient([], fail_auth=True))
assert result.already_owned == ["5 MINUTE DUNGEON"] # snapshots served