Files
bggpipe/tests/test_resolve.py
T
Eric WagonerandClaude Fable 5 50a710ce99 "Local" stops being a one-way door, and the head search stops skipping
Answering Eric's question — no, a local game never looks itself up again
— by making it possible. A local row's Titles line gains "look it up",
reopening it through the SAME cascade resolve uses (board games,
truncation heads, then RPGGeek) rather than the partial re-implementation
reopen_match had; that cascade is now one shared find_candidates()
instead of two drifting copies. Review's manual (f) re-search falls back
to RPGGeek too.

That exposed a real matcher gap: truncation heads jumped from
"drop the last word" straight to "first two words", so a printed title
that buries the real name in the middle was unreachable — "ALICE IS
MISSING A SILENT ROLE PLAYING GAME" never tried "ALICE IS MISSING".
Heads now shrink from the right, longest first (bounded at six, since
each is a rate-limited request); only exact normalized matches count for
heads, so shorter heads cannot match loosely.

Both of Eric's Alice Is Missing rows now find their RPGGeek entries
(311654, and 380459 for Silent Falls) and await his picks in review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-05 23:51:54 -04:00

739 lines
26 KiB
Python

"""Resolve-stage tests, replayed from tests/fixtures/bgg_cache — never online.
The transport below raises on any network attempt, proving every response
comes from the committed fixture cache.
"""
from __future__ import annotations
import csv
import json
import shutil
from pathlib import Path
import httpx
import pytest
from bggpipe.bgg_client import BGGClient, cache_key
from bggpipe.config import Config
from bggpipe.models import GameVersion
from bggpipe.normalize import normalize_title
from bggpipe.resolve import (
Candidate,
MatchRow,
TitleEntry,
_dominant,
_score_version,
_truncation_heads,
dedupe_matches,
load_titles,
read_matches,
resolve_entry,
run_resolve,
write_matches,
)
FIXTURES = Path(__file__).parent / "fixtures" / "bgg_cache"
TITLES_JSON = Path(__file__).parent / "data" / "titles.json"
def _no_network(request: httpx.Request) -> httpx.Response:
raise AssertionError(f"test hit the network: {request.url}")
@pytest.fixture
def client() -> BGGClient:
return BGGClient(
cache_dir=FIXTURES,
transport=httpx.MockTransport(_no_network),
sleep=lambda s: pytest.fail("slept during fixture replay"),
)
@pytest.fixture
def rows_by_title(client) -> dict[str, object]:
entries = load_titles(TITLES_JSON)
return {e.title_raw: resolve_entry(client, e) for e in entries}
def test_catan_auto_matches_base_game(rows_by_title):
row = rows_by_title["Catan"]
assert (row.match_status, row.bgg_id, row.bgg_name) == ("auto", 13, "Catan")
assert row.version_status == "version_unknown" # no cues -> never guess
def test_wingspan_auto_with_version_from_cues(rows_by_title):
row = rows_by_title["Wingspan"]
assert (row.match_status, row.bgg_id) == ("auto", 266192)
# Stonemaier + 2019 + English narrows 46 real versions to a few
# English printings — several plausible, so review decides; the 2019
# first printing must be on the ballot
assert row.version_status == "version_ambiguous"
assert 433233 in {v["version_id"] for v in row.version_candidates}
def test_expansion_matches_expansion_not_base(rows_by_title):
row = rows_by_title["Wingspan: European Expansion"]
assert (row.match_status, row.bgg_id) == ("auto", 290448)
assert row.type == "boardgameexpansion"
def test_wingspan_europe_spine_never_matches_base(rows_by_title):
"""The spec's canonical trap: a truncated spine must not auto-match."""
row = rows_by_title["Wingspan Europe"]
assert row.match_status == "unmatched"
assert row.bgg_id is None
def test_accented_title_resolves(rows_by_title):
row = rows_by_title["Café International"]
assert (row.match_status, row.bgg_id) == ("auto", 214)
def test_same_name_editions_stay_ambiguous(rows_by_title):
row = rows_by_title["Citadels"]
assert row.match_status == "ambiguous"
assert {c.bgg_id for c in row.candidates} == {478, 205398}
# stats were fetched for the review UI
assert all(c.owned is not None for c in row.candidates)
def test_nonsense_lands_unmatched(rows_by_title):
row = rows_by_title["Blorvath: Quest of the Zzyzx"]
assert row.match_status == "unmatched"
def test_run_resolve_writes_csv_and_is_idempotent(client, tmp_path):
data_dir = tmp_path / "data"
data_dir.mkdir()
shutil.copy(TITLES_JSON, data_dir / "titles.json")
cfg = Config(data_dir=data_dir)
first = run_resolve(cfg, client=client)
assert len(first) == 15
with cfg.matches_path.open(newline="") as f:
rows = list(csv.DictReader(f))
assert len(rows) == 15
by_title = {r["title_raw"]: r for r in rows}
assert by_title["Catan"]["match_status"] == "auto"
assert by_title["Citadels"]["match_status"] == "ambiguous"
candidates = json.loads(by_title["Citadels"]["candidates_json"])
assert len(candidates) == 2
# ambiguous edition: no id recorded, the candidates carry the ballot
assert by_title["Wingspan"]["version_status"] == "version_ambiguous"
assert "433233" in by_title["Wingspan"]["version_candidates_json"]
# second run: everything already in matches.csv is skipped, file unchanged
before = cfg.matches_path.read_text()
second = run_resolve(cfg, client=client)
assert second == []
assert cfg.matches_path.read_text() == before
# -- pure-logic unit tests (no fixtures) --------------------------------
def _cand(bgg_id, exact=True, owned=None, type_="boardgame"):
return Candidate(
bgg_id=bgg_id,
name="X",
year=2000,
type=type_,
exact=exact,
fuzzy=100.0,
owned=owned,
)
def test_dominant_famous_game_beats_obscure_duplicate():
top = [_cand(1, owned=50000), _cand(2, owned=300)]
assert _dominant(top).bgg_id == 1
def test_dominant_refuses_mixed_types():
top = [_cand(1, owned=50000), _cand(2, owned=300, type_="boardgameexpansion")]
assert _dominant(top) is None
def test_dominant_refuses_close_call():
top = [_cand(1, owned=50000), _cand(2, owned=20000)]
assert _dominant(top) is None
def test_score_version_all_cues():
entry = TitleEntry(
title_raw="Wingspan",
title_normalized="wingspan",
publisher_hint="Stonemaier",
year_hint=2019,
language_hint="english",
)
version = GameVersion(
version_id=1,
name="English first edition",
year=2019,
publishers=("Stonemaier Games",),
languages=("English",),
)
assert _score_version(entry, version) == 5
def test_score_version_no_overlap():
entry = TitleEntry(
title_raw="Wingspan",
title_normalized="wingspan",
publisher_hint="Feuerland",
year_hint=2021,
language_hint="german",
)
version = GameVersion(
version_id=1,
name="English first edition",
year=2019,
publishers=("Stonemaier Games",),
languages=("English",),
)
assert _score_version(entry, version) == 0
# -- progressive title truncation (long transcribed box titles) ---------
CIV_TITLE = (
"CIVILIZATION Game of the Heroic Age - The Dawn of History 8000 BC to 250 BC"
)
AH_HINT = "The Avalon Hill Game Company, Baltimore, Maryland"
def _entry(title, **kw):
return TitleEntry(title_raw=title, title_normalized=normalize_title(title), **kw)
def test_truncation_heads_order_and_dedupe():
heads = _truncation_heads(CIV_TITLE)
assert heads[0] == "CIVILIZATION Game of the Heroic Age" # before separator
assert heads[1] == "CIVILIZATION" # before the "Game ..." descriptor
assert len(heads) <= 6 # bounded: each head is a rate-limited request
assert "CIVILIZATION Game" in heads # shrinks from the right, longest first
def test_truncation_heads_game_word_without_separator():
assert _truncation_heads("SORCERER The Game of Magical Conflict")[0] == "SORCERER"
def test_truncation_heads_two_word_fallback():
heads = _truncation_heads(
"STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century"
)
assert heads[-1] == "STARFORCE ALPHA"
def test_short_titles_get_no_heads():
assert _truncation_heads("Catan") == []
assert _truncation_heads("Herbaceous") == []
def test_civilization_resolves_via_truncation(client):
entry = _entry(
CIV_TITLE,
publisher_hint=AH_HINT,
edition_hint="Bookcase Game",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 71)
# the publisher cue keeps Avalon Hill printings on the ballot; the
# real version list is too crowded for a single confident pick
assert row.version_status == "version_ambiguous"
assert any(
any("Avalon Hill" in p for p in (v.get("publishers") or []))
for v in row.version_candidates
)
def test_advanced_civilization_resolves_as_expansion(client):
entry = _entry(
"ADVANCED CIVILIZATION Game Expansion of the Heroic Age - "
"Featuring New Civilization, Commodity, and Calamity Cards",
publisher_hint=AH_HINT,
edition_hint="Bookcase Game",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id, row.type) == (
"auto",
177,
"boardgameexpansion",
)
assert row.version_status == "version_auto"
def test_sorcerer_publisher_tiebreak_beats_owned_dominance(client):
"""Head search finds TWO games named 'Sorcerer' (SPI 1975 vs White
Wizard 2019, which has far more owners) — the SPI publisher cue must
pick the SPI game, and the edition cue the Designer's Edition."""
entry = _entry(
"SORCERER The Game of Magical Conflict",
publisher_hint="Simulations Publications Incorporated (SPI)",
edition_hint="Designer's Edition",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 3585)
assert row.version_status == "version_auto"
assert row.version_name == "SPI Designer's Edition"
def test_starforce_two_word_head_matches_full_title(client):
entry = _entry(
"STARFORCE ALPHA CENTAURI Interstellar Conflict in the 25th Century",
publisher_hint="Simulations Publications Incorporated (SPI)",
edition_hint="Designer's Edition",
language_hint="English",
)
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 2524)
assert row.version_status == "version_ambiguous"
def test_sibling_editions_surface_as_ambiguous(client):
"""BGG files new editions as SEPARATE games ("Wiz-War (Eighth
Edition)"): a lone exact match must not hide its siblings behind a
confident auto — the user can't know what they never see."""
entry = _entry("WIZ-WAR", confidence="high")
row = resolve_entry(client, entry)
assert row.match_status == "ambiguous"
names = {c.name for c in row.candidates}
assert "Wiz-War" in names
assert any("Eighth Edition" in n for n in names)
assert any("9th Edition" in n for n in names)
def test_lone_obscure_candidate_never_autos(client):
"""BGG's search visibly truncates generic queries — the game named
"Dungeon!" appears in NEITHER of its own searches — so the sole
surviving candidate may be an impostor. Ambiguous, never auto."""
entry = _entry("Dungeon!", confidence="high")
row = resolve_entry(client, entry)
assert row.match_status == "ambiguous"
assert row.bgg_id is None
def test_wrong_year_hint_never_drives_a_version(client):
"""Flat Top's box says 1942 (the theme, not the print year): version
scoring must not pick any version off the back of it."""
entry = _entry("FLAT TOP", year_hint=1942, language_hint="English")
row = resolve_entry(client, entry)
assert (row.match_status, row.bgg_id) == ("auto", 2529)
assert row.version_status == "version_unknown"
assert row.version_id is None
# -- graceful degradation without a BGG token ---------------------------
def test_run_resolve_saves_progress_when_token_missing(tmp_path):
"""Cached titles resolve; uncached ones wait for the token instead of
crashing the run and losing everything."""
partial_cache = tmp_path / "cache"
partial_cache.mkdir()
for pattern in ("search_query=Catan-*", "thing_id=13-*"):
# the real Catan search has enough hits that the popularity
# tiebreak fetches /thing stats — that request is cached too
for f in FIXTURES.glob(pattern):
shutil.copy(f, partial_cache / f.name)
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "titles.json").write_text(
json.dumps(
[
{"title_raw": "Catan", "source_photos": ["a.jpg"]},
{"title_raw": "Wingspan", "source_photos": ["a.jpg"]},
]
)
)
cfg = Config(data_dir=data_dir)
unauthorized = BGGClient(
cache_dir=partial_cache,
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
rows = run_resolve(cfg, client=unauthorized)
assert [r.title_raw for r in rows] == ["Catan"] # cached one made it
with cfg.matches_path.open(newline="") as f:
saved = list(csv.DictReader(f))
assert len(saved) == 1 # blocked title left for a future run
# future run (fixtures now "recorded"): picks up only the blocked title
full = BGGClient(
cache_dir=FIXTURES,
transport=httpx.MockTransport(
lambda req: (_ for _ in ()).throw(AssertionError("network"))
),
)
rows2 = run_resolve(cfg, client=full)
assert [r.title_raw for r in rows2] == ["Wingspan"]
# -- post-resolve dedupe ------------------------------------------------
def _mrow(
title,
bgg_id,
photos,
name="Joking Hazard",
vstatus="version_unknown",
vid="",
status="auto",
):
return {
"title_raw": title,
"bgg_id": bgg_id,
"bgg_name": name,
"year": "2016",
"type": "boardgame",
"match_status": status,
"version_id": vid,
"version_name": "",
"version_status": vstatus,
"candidates_json": "[]",
"version_candidates_json": "[]",
"source_photos": photos,
"merged_into": "",
}
def _tentry(title, photos, **cues):
return TitleEntry(
title_raw=title,
title_normalized=normalize_title(title),
publisher_hint=cues.get("publisher_hint", ""),
edition_hint=cues.get("edition_hint", ""),
year_hint=cues.get("year_hint"),
language_hint=cues.get("language_hint", ""),
source_photos=tuple(photos),
)
def test_dedupe_merges_typo_read_into_canonical():
rows = [
_mrow("Jokin Ha...", "193621", "a.jpg"),
_mrow("Joking Hazard", "193621", "b.jpg;c.jpg"),
_mrow("Catan", "13", "d.jpg", name="CATAN"),
]
events = dedupe_matches(rows, [])
(event,) = events
assert event.loser_title == "Jokin Ha..."
assert event.survivor_title == "Joking Hazard" # name-matching read survives
by_title = {r["title_raw"]: r for r in rows}
assert by_title["Jokin Ha..."]["match_status"] == "merged"
assert by_title["Jokin Ha..."]["merged_into"] == "Joking Hazard"
assert by_title["Joking Hazard"]["match_status"] == "auto" # untouched
assert by_title["Catan"]["match_status"] == "auto"
assert len(rows) == 3 # nothing disappears
def test_dedupe_respects_conflicting_edition_cues():
rows = [
_mrow("Cosmic Encounter", "40529", "a.jpg", name="Cosmic Encounter"),
_mrow("COSMIC ENCOUNTER", "40529", "b.jpg", name="Cosmic Encounter"),
]
titles = [
_tentry("Cosmic Encounter", ["a.jpg"], edition_hint="42nd Anniversary Edition"),
_tentry("COSMIC ENCOUNTER", ["b.jpg"], edition_hint="Eon 1977 edition"),
]
assert dedupe_matches(rows, titles) == []
assert all(r["match_status"] == "auto" for r in rows)
def test_dedupe_versions_must_agree():
# different confident versions: two physical editions, never merged
rows = [
_mrow("Wingspan", "266192", "a.jpg", vstatus="version_auto", vid="465063"),
_mrow("WINGSPAN", "266192", "b.jpg", vstatus="version_auto", vid="521212"),
]
assert dedupe_matches(rows, []) == []
# same confident version: same box seen twice
rows2 = [
_mrow("Wingspan", "266192", "a.jpg", vstatus="version_auto", vid="465063"),
_mrow("WINGSPAN", "266192", "b.jpg", vstatus="version_auto", vid="465063"),
]
assert len(dedupe_matches(rows2, [])) == 1
# confident version vs unknown: conservative, no merge
rows3 = [
_mrow("Wingspan", "266192", "a.jpg", vstatus="version_auto", vid="465063"),
_mrow("WINGSPAN", "266192", "b.jpg"),
]
assert dedupe_matches(rows3, []) == []
def test_dedupe_matches_skips_split_titles():
rows = [
_mrow("Wiz-War", "94", "a.jpg"),
_mrow("Wiz-War", "94", "b.jpg"),
]
assert dedupe_matches(rows, [], splits=[{"norm": "wiz war", "photos": None}]) == []
assert all(not r["merged_into"] for r in rows)
def test_dedupe_is_idempotent_and_skips_merged():
rows = [
_mrow("Jokin Ha...", "193621", "a.jpg"),
_mrow("Joking Hazard", "193621", "b.jpg"),
]
assert len(dedupe_matches(rows, [])) == 1
assert dedupe_matches(rows, []) == [] # second pass: nothing new
def test_run_resolve_dedupes_and_keeps_all_rows(tmp_path):
cache = tmp_path / "cache"
cache.mkdir()
wingspan_xml = (
'<items total="1"><item type="boardgame" id="266192">'
'<name type="primary" value="Wingspan"/><yearpublished value="2019"/>'
"</item></items>"
)
# "WINGSPAN" is the depunct retry of "WINGSPAN!"; the stats file is
# the lone-candidate trust check (owned must clear the floor)
for query in ("Wingspan", "WINGSPAN!", "WINGSPAN"):
key = cache_key(
"search", {"query": query, "type": "boardgame,boardgameexpansion"}
)
(cache / key).write_text(wingspan_xml)
(cache / cache_key("thing", {"id": "266192", "stats": "1"})).write_text(
'<items><item type="boardgame" id="266192">'
'<name type="primary" value="Wingspan"/><yearpublished value="2019"/>'
'<statistics><ratings><owned value="120000"/>'
'<ranks><rank type="subtype" id="1" name="boardgame" value="30"/></ranks>'
"</ratings></statistics></item></items>"
)
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "titles.json").write_text(
json.dumps(
[
{"title_raw": "Wingspan", "source_photos": ["a.jpg"]},
{"title_raw": "WINGSPAN!", "source_photos": ["b.jpg"]}, # variant read
]
)
)
cfg = Config(data_dir=data_dir)
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
run_resolve(cfg, client=client)
saved = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert len(saved) == 2 # no row disappeared
assert saved["Wingspan"]["match_status"] == "auto"
assert saved["WINGSPAN!"]["match_status"] == "merged"
assert saved["WINGSPAN!"]["merged_into"] == "Wingspan"
# -- re-run and --force behavior ----------------------------------------
def test_run_resolve_force_rebuilds_from_scratch(client, tmp_path):
data_dir = tmp_path / "data"
data_dir.mkdir()
shutil.copy(TITLES_JSON, data_dir / "titles.json")
cfg = Config(data_dir=data_dir)
run_resolve(cfg, client=client)
# poison one row: force must throw it away and re-resolve everything
rows = read_matches(cfg.matches_path)
rows[0]["match_status"] = "rejected"
write_matches(cfg.matches_path, rows)
forced = run_resolve(cfg, force=True, client=client)
assert len(forced) == 15 # every title re-resolved, none skipped
fresh = {r["title_raw"]: r for r in read_matches(cfg.matches_path)}
assert fresh["Catan"]["match_status"] == "auto"
def test_new_photo_of_resolved_game_updates_row_instead_of_duplicating(
client, tmp_path
):
data_dir = tmp_path / "data"
data_dir.mkdir()
shutil.copy(TITLES_JSON, data_dir / "titles.json")
cfg = Config(data_dir=data_dir)
run_resolve(cfg, client=client)
n_rows = len(read_matches(cfg.matches_path))
# extract sees Catan again on a reshoot photo: the entry's photo set
# grows, its (title, photos) key changes
titles = json.loads((data_dir / "titles.json").read_text())
for entry in titles:
if entry["title_raw"] == "Catan":
entry["source_photos"] = sorted([*entry["source_photos"], "reshoot.jpg"])
(data_dir / "titles.json").write_text(json.dumps(titles))
assert run_resolve(cfg, client=client) == [] # nothing re-resolved
rows = read_matches(cfg.matches_path)
assert len(rows) == n_rows # and no duplicate row appended
catan = next(r for r in rows if r["title_raw"] == "Catan")
assert "reshoot.jpg" in catan["source_photos"] # provenance followed
def test_dedupe_never_overturns_a_human_veto():
a = _mrow("CATAN", "13", "p1.jpg")
b = _mrow("Catan", "13", "p2.jpg", status="approved")
b["dedupe_veto"] = "1" # review said: genuinely two copies
events = dedupe_matches([a, b], [])
assert events == []
assert b["match_status"] == "approved"
def test_publisher_pick_refuses_multiple_same_publisher_candidates():
from bggpipe.resolve import _publisher_pick
entry = TitleEntry(
title_raw="Sorcerer", title_normalized="sorcerer", publisher_hint="SPI"
)
cands = [
_cand(1, exact=True),
_cand(2, exact=True),
]
for c in cands:
c.publishers = ["Simulations Publications, Inc. (SPI)"]
assert _publisher_pick(entry, cands) is None
def test_publisher_pick_refuses_mixed_base_and_expansion():
from bggpipe.resolve import _publisher_pick
entry = TitleEntry(
title_raw="Wingspan", title_normalized="wingspan", publisher_hint="Stonemaier"
)
base = _cand(1, exact=True, type_="boardgame")
expansion = _cand(2, exact=True, type_="boardgameexpansion")
for c in (base, expansion):
c.publishers = ["Stonemaier Games"]
assert _publisher_pick(entry, [base, expansion]) is None
def test_resolve_version_handles_unknown_id():
from bggpipe.resolve import resolve_version
class EmptyThings:
def things(self, ids, **kwargs):
return []
entry = TitleEntry(title_raw="X", title_normalized="x", publisher_hint="Someone")
row = MatchRow(title_raw="X", bgg_id=999999)
resolve_version(EmptyThings(), entry, row) # must not raise
assert row.version_status == "version_unknown"
def test_empty_normalized_title_never_matches(client):
# 风声 normalizes to "" — empty-vs-empty must not count as exact
from bggpipe.resolve import _plausible_candidates
entry = TitleEntry(title_raw="风声", title_normalized="")
# any cached query works; candidates must be rejected regardless of name
assert _plausible_candidates(client, entry, "Catan") == []
def test_blocked_same_title_entry_defers_the_whole_group(tmp_path):
# entry1 of a two-edition title is blocked (no token); entry2 must NOT
# resolve, or its row would occupy entry1's pairing slot next run
data_dir = tmp_path / "data"
data_dir.mkdir()
(data_dir / "titles.json").write_text(
json.dumps(
[
{
"title_raw": "Catan",
"edition_hint": "3rd edition",
"source_photos": ["a.jpg"],
},
{
"title_raw": "Catan",
"edition_hint": "5th edition",
"source_photos": ["b.jpg"],
},
]
)
)
cfg = Config(data_dir=data_dir)
blocked_client = BGGClient(
cache_dir=tmp_path / "empty_cache",
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
sleep=lambda s: None,
)
run_resolve(cfg, client=blocked_client)
assert read_matches(cfg.matches_path) == [] # both deferred, none misplaced
def test_truncation_separator_chosen_by_position():
heads = _truncation_heads("Blorvath: Quest of the Zzyzx - 2nd Edition")
assert heads[0] == "Blorvath"
assert (
"Blorvath: Quest" not in heads
) # two-word fallback uses the pre-subtitle head
def test_reordered_titles_json_cannot_mispair_editions(client, tmp_path):
# run 1 resolves "Catan" from b.jpg; run 2 prepends a NEW conflicting-cue
# "Catan" sighting from a.jpg (sorts earlier). Photo-overlap pairing must
# keep the b.jpg row glued to the b.jpg entry — not hand its resolution
# (and photos) to the newcomer positionally.
data_dir = tmp_path / "data"
data_dir.mkdir()
entry_b = {
"title_raw": "Catan",
"language_hint": "English", # conflicts with a.jpg's German; language
"source_photos": ["b.jpg"], # alone never triggers a versions fetch
}
(data_dir / "titles.json").write_text(json.dumps([entry_b]))
cfg = Config(data_dir=data_dir)
run_resolve(cfg, client=client)
(row,) = read_matches(cfg.matches_path)
assert row["source_photos"] == "b.jpg"
entry_a = {
"title_raw": "Catan",
"language_hint": "German",
"source_photos": ["a.jpg"],
}
(data_dir / "titles.json").write_text(json.dumps([entry_a, entry_b]))
run_resolve(cfg, client=client)
rows = read_matches(cfg.matches_path)
by_photos = {r["source_photos"]: r for r in rows}
assert by_photos["b.jpg"]["match_status"] == "auto" # kept its resolution
assert "a.jpg" in by_photos # newcomer resolved as its own row
assert len(rows) == 2
def test_merged_into_chains_resolve_to_terminal_survivor():
# X merged into Y in a prior run; this run merges Y into W — X must
# point at W, or diff's one-level photo hop loses X's provenance
x = _mrow("Wingspam", "266192", "x.jpg", status="merged")
x["merged_into"] = "Wingspan Typo"
y = _mrow("Wingspan Typo", "266192", "y.jpg", name="Wingspan")
w = _mrow("Wingspan", "266192", "w.jpg", name="Wingspan")
dedupe_matches([x, y, w], [])
assert y["match_status"] == "merged" and y["merged_into"] == "Wingspan"
assert x["merged_into"] == "Wingspan" # chain collapsed
def test_rpg_falls_back_to_rpgitem_search(client):
# Alice Is Missing: absent from the board-game search, present as an
# rpgitem — same geekdo API, resolved as a local library citizen
entry = TitleEntry(
title_raw="ALICE IS MISSING: A SILENT ROLE PLAYING GAME",
title_normalized="alice is missing silent role playing game",
)
row = resolve_entry(client, entry)
assert row.match_status == "auto"
assert row.type == "rpgitem"
assert row.bgg_id == 311654