74fc4fe847
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>
408 lines
15 KiB
Python
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
|