Files
bggpipe/tests/test_diff.py
T
Eric Wagoner 74fc4fe847 RPGs become local library citizens: identified, enriched, never uploaded
When the board-game search (and truncation heads) runs dry, resolve
falls back to type=rpgitem — the geekdo database is shared, so the same
API, token, cache, and classification machinery apply. Matched rpgitems
flow through review and enrich normally but diff routes them to a
local_only bucket, structurally outside to_add/to_update: their
collections live on RPGGeek, beyond this pipeline's write scope. The
library page gains an All/Board games/RPGs filter and an "RPG · local
only" badge; the catalog tags them too. Fixture generators write blanket
empty rpgitem stubs for every known query (the fallback fires for every
unmatched title) with real synthetic entries for Alice Is Missing.

Data: both Alice rows re-resolved from unmatched to auto rpgitem
matches. First diff since the audit reworks also lands their real-data
consequences: Dungeon! gains its TSR edition update on a versionless
copy the old claim ordering missed, to_add rows carry unioned reshoot
provenance, and the Herbaceous typo row's survivor is now the
correctly-spelled title.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 19:01:09 -04:00

408 lines
15 KiB
Python

"""Diff-stage tests: pure compute_diff cases plus the real 2018 collection
snapshots as parsing fixtures. No network anywhere."""
from __future__ import annotations
import shutil
from pathlib import Path
from bggpipe.config import Config
from bggpipe.diff import (
SNAPSHOT_FILES,
compute_diff,
load_snapshot_collection,
run_diff,
)
from bggpipe.models import CollectionItem
from bggpipe.resolve import write_matches
from bggpipe.upload import run_upload
FIXTURES = Path(__file__).parent / "fixtures"
def _item(object_id, coll_id, name="Game", version_id=None, own=True):
return CollectionItem(
object_id=object_id,
coll_id=coll_id,
name=name,
subtype="boardgame",
own=own,
year=None,
version_id=version_id,
)
def _match(
title, bgg_id="", status="auto", vstatus="version_unknown", vid="", vname=""
):
return {
"title_raw": title,
"bgg_id": bgg_id,
"bgg_name": title.title(),
"year": "2000",
"type": "boardgame",
"match_status": status,
"version_id": vid,
"version_name": vname,
"version_status": vstatus,
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": "x.jpg",
}
# -- snapshot loading ---------------------------------------------------
def test_load_real_snapshots_merges_and_dedupes():
collection = load_snapshot_collection(FIXTURES)
# 79 base + 3 expansions, but all 3 expansion collids also appear in
# the base file -> 79 unique physical copies
assert len(collection) == 79
assert len({c.coll_id for c in collection}) == 79
by_id = {c.object_id: c for c in collection}
assert by_id[207830].name == "5-Minute Dungeon"
assert by_id[177].name == "Advanced Civilization"
# hand-entered in 2018: every entry is version-less (parsed, not assumed)
assert all(c.version_id is None for c in collection)
# -- compute_diff -------------------------------------------------------
def test_new_game_goes_to_add_with_version():
result = compute_diff(
[
_match(
"Cat Crimes",
"235096",
vstatus="version_auto",
vid="360982",
vname="ThinkFun edition",
)
],
[_item(13, 1)],
)
(row,) = result.to_add
assert row["bgg_id"] == "235096"
assert row["version_id"] == "360982"
assert not result.to_update
def test_owned_versionless_plus_confident_version_goes_to_update():
result = compute_diff(
[
_match(
"Britannia",
"240",
vstatus="version_auto",
vid="55555",
vname="AH English edition",
)
],
[_item(240, 900001, name="Britannia")],
)
assert result.already_owned == ["Britannia"]
(row,) = result.to_update
assert row == {
"collid": "900001",
"bgg_id": "240",
"bgg_name": "Britannia",
"version_id": "55555",
"version_name": "AH English edition",
}
assert not result.to_add
def test_owned_with_matching_version_is_just_owned():
result = compute_diff(
[_match("Wingspan", "266192", vstatus="version_auto", vid="465063")],
[_item(266192, 5, version_id=465063)],
)
assert result.already_owned == ["Wingspan"]
assert not result.to_update and not result.to_add
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(
"Wingspan",
"266192",
vstatus="version_auto",
vid="521212",
vname="fourth printing",
)
],
[_item(266192, 5, version_id=465063)],
)
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_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():
# row A (v3, no match) must not consume the v2 copy that row B exactly
# matches — exact-version claims settle before disagreements
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 matches no collection entry: a genuine new copy
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
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():
result = compute_diff(
[_match("Catan", "13")],
[_item(13, 1, name="Catan")],
)
assert result.already_owned == ["Catan"]
assert not result.to_add and not result.to_update
def test_two_photo_editions_consume_distinct_collids():
matches = [
_match("Cosmic A", "39", vstatus="version_auto", vid="111"),
_match("Cosmic B", "39", vstatus="version_auto", vid="222"),
]
collection = [_item(39, 701), _item(39, 702)]
result = compute_diff(matches, collection)
assert {r["collid"] for r in result.to_update} == {"701", "702"}
assert {r["version_id"] for r in result.to_update} == {"111", "222"}
def test_pending_rejected_and_unseen_are_reported():
matches = [
_match("Mystery Spine", status="ambiguous"),
_match("Junk", status="rejected"),
_match("Catan", "13"),
]
collection = [_item(13, 1, name="Catan"), _item(9209, 2, name="Ticket to Ride")]
result = compute_diff(matches, collection)
assert result.pending == ["Mystery Spine"]
assert result.rejected == 1
assert [c.object_id for c in result.unseen] == [9209] # informational
assert result.recognized == 1
def test_merged_rows_are_skipped_but_photos_carry_to_survivor():
matches = [
_match("Joking Hazard", "193621"),
{
**_match("Jokin Ha...", "193621", status="merged"),
"merged_into": "Joking Hazard",
"source_photos": "other.jpg",
},
]
result = compute_diff(matches, []) # empty collection -> to_add
assert result.merged == 1
assert result.pending == [] # merged is not "needs review"
(row,) = result.to_add
assert row["title_raw"] == "Joking Hazard"
assert row["source_photos"] == "other.jpg;x.jpg" # combined
def test_versionless_copies_exhaust_then_second_copy_becomes_add():
# two confident-version matches, ONE versionless copy: the first consumes
# it (to_update), the second is an additional physical copy (to_add)
result = compute_diff(
[
_match("Sorcerer", "39", vstatus="version_auto", vid="111", vname="1st"),
_match("Sorcerer", "39", vstatus="version_auto", vid="222", vname="2nd"),
],
[_item(39, 701)],
)
assert [u["version_id"] for u in result.to_update] == ["111"]
assert [a["version_id"] for a in result.to_add] == ["222"]
assert len(result.second_copies) == 1
def test_run_diff_outputs_feed_upload_unchanged(tmp_path, monkeypatch):
# the cross-stage contract: whatever run_diff writes, run_upload must
# read — a column rename on either side has to fail HERE
monkeypatch.delenv("BGG_API_TOKEN", raising=False)
cfg = Config(data_dir=tmp_path)
fixtures = Path(__file__).parent / "fixtures"
for name in SNAPSHOT_FILES:
shutil.copy(fixtures / name, tmp_path / name)
write_matches(
cfg.matches_path,
[
_match("Wingspan", "266192", status="auto"), # not in the snapshots
_match("5 MINUTE DUNGEON", "207830", status="auto"), # owned
],
)
result = run_diff(cfg)
assert [r["bgg_id"] for r in result.to_add] == ["266192"]
from test_upload import FakeUploader
fake = FakeUploader()
run_upload(cfg, uploader=fake, sleep=lambda s: None, now=lambda: "t")
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):
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_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 token IS set; blame the username
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
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):
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
def test_bare_sibling_of_confident_add_is_not_a_duplicate_upload():
# photo A reads "Wingspan" (confident version), photo B misreads
# "Wingspam" (bare) resolving to the same absent game: ONE add, not two
rows = [
_match("Wingspan", "266192", vstatus="version_auto", vid="1", vname="1st"),
_match("Wingspam", "266192"),
]
result = compute_diff(rows, [])
assert len(result.to_add) == 1
assert result.already_owned == ["Wingspam"]
def test_vetoed_bare_sibling_still_adds_for_absent_game():
vetoed = {**_match("Wingspan", "266192"), "dedupe_veto": "1"}
rows = [
_match("Wingspan", "266192", vstatus="version_auto", vid="1", vname="1st"),
vetoed,
]
result = compute_diff(rows, [])
assert len(result.to_add) == 2
assert result.to_add[1]["second_copy"] == "1"
def test_second_copy_flag_travels_on_exhausted_adds():
rows = [
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd"),
_match("Catan", "13", vstatus="version_auto", vid="123", vname="3rd"),
]
result = compute_diff(rows, [_item(13, 900, version_id=123)])
(added,) = result.to_add
assert added["second_copy"] == "1"
def test_rpgitem_rows_are_local_only_never_queued():
rpg = {**_match("ALICE IS MISSING", "400001"), "type": "rpgitem"}
result = compute_diff([rpg, _match("Catan", "13")], [])
assert result.local_only == ["ALICE IS MISSING"]
assert [r["bgg_id"] for r in result.to_add] == ["13"] # RPG never queued
assert result.recognized == 1 # RPGs aren't counted against the queue