"""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"