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
739 lines
26 KiB
Python
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
|