Re-audit round 2: 5 blind reviewers, 17 fixes, +12 tests

The re-run confirmed round 1 held and then caught second-order bugs in
its own fixes plus two long-standing ones everyone missed. TUI decisions
after a mid-session reload were counted but never written (rows are now
re-adopted into the fresh list on every save, preferring undecided slots
on duplicate keys); row_ix was computed by equality so duplicate rows
shared an ordinal (identity now, merges included, veto sends it); upload
job keys collided for two same-version copies (completions are counted
per key, so --limit or an interrupt can no longer strand the second
copy); diff consumes collids on exact-version matches (a vetoed
same-version second copy was silently swallowed) and splits mismatches:
report-only disagreement while an unclaimed copy exists, second-copy add
only when every copy is claimed.

Also: XML responses are validated and written atomically before caching
(a torn or truncated 200 body can never poison a re-run), JSON artifacts
write atomically, thing/search parsers refuse missing ids like the
collection parser, empty game names are refused by the upload queue, a
never-rendering version picker fails retryably instead of terminally,
the systemic-failure abort compares exception types, blocked same-title
entries defer as a group so positional pairing can't misalign,
truncation heads pick the earliest separator, diff messages tell the
truth when a token exists without a username, and the shared-constant
sweep now actually covers every module (statuses, search types, marker
names, client_for, ports). pydantic declared as a direct dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-02 14:34:44 -04:00
parent 38e20f2c30
commit 65d4cdd5ec
23 changed files with 624 additions and 170 deletions
+68 -8
View File
@@ -10,6 +10,10 @@ from bggpipe.diff import 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):
@@ -115,10 +119,10 @@ def test_owned_with_matching_version_is_just_owned():
assert not result.to_update and not result.to_add
def test_confident_version_matching_no_copy_is_a_second_copy_to_add():
# Spec: a (bgg_id, version_id) pair is owned only if a collection item
# matches BOTH. All copies carry different versions -> this is an
# additional physical copy; existing entries are never edited.
def test_version_mismatch_with_unclaimed_copy_is_report_only():
# ONE row, ONE copy with a different version: most likely the same
# physical box mis-scored. Spec: report the disagreement, touch nothing,
# and never risk uploading a duplicate entry.
result = compute_diff(
[
_match(
@@ -131,10 +135,47 @@ def test_confident_version_matching_no_copy_is_a_second_copy_to_add():
],
[_item(266192, 5, version_id=465063)],
)
assert not result.to_update # additive only: never edit a set version
assert [r["version_id"] for r in result.to_add] == ["521212"]
assert "fourth printing" in result.second_copies[0]
assert result.already_owned == []
assert not result.to_update and not result.to_add
assert result.already_owned == ["Wingspan"]
assert "fourth printing" in result.disagreements[0]
def test_vetoed_duplicate_of_same_version_is_a_real_second_copy():
# Two rows, same confident version, ONE owned copy with that version:
# a human vetoed the merge ("these ARE two boxes"), so the exact-version
# match must consume the copy and the second row must become an add.
rows = [
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd ed."),
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd ed."),
]
result = compute_diff(rows, [_item(13, 900, version_id=123)])
assert result.already_owned == ["Catan"]
assert [r["version_id"] for r in result.to_add] == ["123"]
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)])
assert result.already_owned == ["Catan"]
(added,) = result.to_add
assert added["version_id"] == ""
assert len(result.second_copies) == 1
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
rows = [
_match("Catan", "13"),
_match("Catan", "13", vstatus="version_auto", vid="55", vname="5th ed."),
]
result = compute_diff(rows, [_item(13, 900), _item(13, 901)])
assert [u["version_id"] for u in result.to_update] == ["55"]
assert result.to_add == []
assert result.already_owned.count("Catan") == 2
def test_version_unknown_owned_by_bare_id():
@@ -237,3 +278,22 @@ def test_run_diff_outputs_feed_upload_unchanged(tmp_path, monkeypatch):
assert [(j.action, j.bgg_id, j.name) for j in fake.calls] == [
("add", "266192", "Wingspan")
]
def test_token_without_username_says_so(tmp_path, monkeypatch, capsys):
import shutil
from bggpipe.diff import run_diff
from bggpipe.resolve import write_matches
monkeypatch.setenv("BGG_API_TOKEN", "tok")
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:
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