aed969856b
A 401 on an uncached title no longer aborts the run: cached titles resolve and save, blocked titles are listed with registration/token instructions and left out of matches.csv so a future run picks them up untouched. Supports the take-photos-now, resolve-later workflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
346 lines
11 KiB
Python
346 lines
11 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
|
|
from bggpipe.config import Config
|
|
from bggpipe.models import GameVersion
|
|
from bggpipe.resolve import (
|
|
Candidate,
|
|
TitleEntry,
|
|
_dominant,
|
|
_score_version,
|
|
load_titles,
|
|
resolve_entry,
|
|
run_resolve,
|
|
)
|
|
|
|
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)
|
|
# cues: Stonemaier + 2019 + English -> the first edition, not the
|
|
# 2020 printing (score 5 vs 3) and not the German edition
|
|
assert row.version_status == "version_auto"
|
|
assert row.version_id == 465063
|
|
assert row.version_name == "English first edition"
|
|
|
|
|
|
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", 373)
|
|
|
|
|
|
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) == 7
|
|
|
|
with cfg.matches_path.open(newline="") as f:
|
|
rows = list(csv.DictReader(f))
|
|
assert len(rows) == 7
|
|
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
|
|
assert by_title["Wingspan"]["version_id"] == "465063"
|
|
|
|
# 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) ---------
|
|
|
|
from bggpipe.normalize import normalize_title # noqa: E402
|
|
from bggpipe.resolve import _truncation_heads # noqa: E402
|
|
|
|
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) <= 3
|
|
|
|
|
|
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)
|
|
# publisher cue picks the Avalon Hill version, not Hartland/Gibsons
|
|
assert row.version_status == "version_auto"
|
|
assert row.version_id == 71001
|
|
|
|
|
|
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_auto"
|
|
|
|
|
|
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."""
|
|
import shutil as _shutil
|
|
|
|
partial_cache = tmp_path / "cache"
|
|
partial_cache.mkdir()
|
|
for f in FIXTURES.glob("search_query=Catan-*"):
|
|
_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"]
|