Eric's brief, implemented to the letter. BGG keeps physical dimensions on VERSIONS, not games, so enrich runs a second cached pass over thing?versions=1 (same batching, token, rate limit, and cache as every call). A game with a chosen version takes that exact version's numbers (source "version", mirrored onto its version dict); a versionless game gets numbers only when every printing with data agrees within 0.5" per axis (source "unanimous", keeping the MAX per axis — the planning question is "will it fit"); disagreement stores nulls as "conflicting" — never a guess — and BGG's 0 parses as "never entered", not a real dimension. rpgitems and local games are "absent". Read-only: upload untouched. The new offline `bggpipe dims` reports coverage by source, the ten biggest footprints, and a Kallax fit check (13.2" square opening, 15.4" deep; a box fits if SOME orientation puts two axes through the opening within the depth) — naming every misfit and every game whose dimensions can't be verified, because can't-verify ≠ fits. Trusted numbers surface on the Library detail page as a "box" row. First real run: 54 version-exact, 16 unanimous, 39 conflicting, 27 absent; three genuine misfits (Bugs in the Kitchen's 17" box, History of the World and Risk LotR both over the 15.4" depth). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
463 lines
16 KiB
Python
463 lines
16 KiB
Python
"""Enrich-stage tests: full-metadata parsing plus run_enrich orchestration
|
|
(batched, cache-keyed, idempotent, token-degrading). No network."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from bggpipe.bgg_client import BGGClient, cache_key
|
|
from bggpipe.config import Config
|
|
from bggpipe.models import parse_things_full
|
|
from bggpipe.resolve import write_matches
|
|
|
|
FULL_THING_XML = """<items>
|
|
<item type="boardgame" id="266192">
|
|
<thumbnail>https://cf.example/thumb.jpg</thumbnail>
|
|
<image>https://cf.example/full.jpg</image>
|
|
<name type="primary" sortindex="1" value="Wingspan"/>
|
|
<name type="alternate" value="Flügelschlag"/>
|
|
<description>A bird-collection engine builder.</description>
|
|
<yearpublished value="2019"/>
|
|
<minplayers value="1"/>
|
|
<maxplayers value="5"/>
|
|
<poll name="suggested_numplayers">
|
|
<results numplayers="1">
|
|
<result value="Best" numvotes="10"/>
|
|
<result value="Recommended" numvotes="50"/>
|
|
<result value="Not Recommended" numvotes="20"/>
|
|
</results>
|
|
<results numplayers="3">
|
|
<result value="Best" numvotes="120"/>
|
|
<result value="Recommended" numvotes="40"/>
|
|
<result value="Not Recommended" numvotes="2"/>
|
|
</results>
|
|
<results numplayers="4">
|
|
<result value="Best" numvotes="90"/>
|
|
<result value="Recommended" numvotes="80"/>
|
|
<result value="Not Recommended" numvotes="5"/>
|
|
</results>
|
|
</poll>
|
|
<playingtime value="70"/>
|
|
<minplaytime value="40"/>
|
|
<maxplaytime value="70"/>
|
|
<minage value="10"/>
|
|
<link type="boardgamecategory" id="1" value="Animals"/>
|
|
<link type="boardgamecategory" id="2" value="Card Game"/>
|
|
<link type="boardgamemechanic" id="3" value="Engine Building"/>
|
|
<link type="boardgamedesigner" id="4" value="Elizabeth Hargrave"/>
|
|
<link type="boardgameartist" id="5" value="Natalia Rojas"/>
|
|
<link type="boardgamepublisher" id="6" value="Stonemaier Games"/>
|
|
<statistics page="1"><ratings>
|
|
<average value="8.05"/>
|
|
<averageweight value="2.45"/>
|
|
<owned value="123456"/>
|
|
<ranks><rank type="subtype" id="1" name="boardgame" value="30"/></ranks>
|
|
</ratings></statistics>
|
|
</item>
|
|
</items>"""
|
|
|
|
|
|
def test_parse_things_full_extracts_everything():
|
|
(game,) = parse_things_full(FULL_THING_XML)
|
|
assert game["name"] == "Wingspan"
|
|
assert game["year"] == 2019
|
|
assert game["description"] == "A bird-collection engine builder."
|
|
assert (game["min_players"], game["max_players"]) == (1, 5)
|
|
# 3 is Best-majority; 4 loses to Recommended+NotRec? no — Best(90) >= Rec(80)
|
|
# and > NotRec(5), so 4 qualifies too; 1 does not (Best < Recommended)
|
|
assert game["best_player_counts"] == ["3", "4"]
|
|
assert game["designers"] == ["Elizabeth Hargrave"]
|
|
assert game["artists"] == ["Natalia Rojas"]
|
|
assert game["publishers"] == ["Stonemaier Games"]
|
|
assert game["categories"] == ["Animals", "Card Game"]
|
|
assert game["mechanics"] == ["Engine Building"]
|
|
assert game["rating"] == 8.05
|
|
assert game["weight"] == 2.45
|
|
assert game["rank"] == 30
|
|
assert game["playtime"] == 70
|
|
assert game["min_age"] == 10
|
|
assert game["image"].endswith("full.jpg")
|
|
|
|
|
|
CATAN_MINIMAL = (
|
|
'<items><item type="boardgame" id="13">'
|
|
'<name type="primary" value="CATAN"/><yearpublished value="1995"/>'
|
|
"</item></items>"
|
|
)
|
|
|
|
|
|
def _matches_rows():
|
|
base = {
|
|
"year": "",
|
|
"type": "boardgame",
|
|
"candidates_json": "[]",
|
|
"source_photos": "x.jpg",
|
|
"version_id": "",
|
|
"version_name": "",
|
|
"version_status": "version_unknown",
|
|
"version_candidates_json": "[]",
|
|
}
|
|
return [
|
|
{
|
|
**base,
|
|
"title_raw": "Catan",
|
|
"bgg_id": "13",
|
|
"bgg_name": "CATAN",
|
|
"match_status": "auto",
|
|
},
|
|
{
|
|
**base,
|
|
"title_raw": "Wingspan",
|
|
"bgg_id": "266192",
|
|
"bgg_name": "Wingspan",
|
|
"match_status": "auto",
|
|
"version_id": "465063",
|
|
"version_name": "English first edition",
|
|
"version_status": "version_auto",
|
|
"version_candidates_json": json.dumps(
|
|
[
|
|
{
|
|
"version_id": 465063,
|
|
"name": "English first edition",
|
|
"year": 2019,
|
|
"publishers": ["Stonemaier Games"],
|
|
"languages": ["English"],
|
|
"score": 5,
|
|
}
|
|
]
|
|
),
|
|
},
|
|
{
|
|
**base,
|
|
"title_raw": "Junk",
|
|
"bgg_id": "",
|
|
"bgg_name": "",
|
|
"match_status": "rejected",
|
|
},
|
|
]
|
|
|
|
|
|
def _no_network(request: httpx.Request) -> httpx.Response:
|
|
raise AssertionError(f"test hit the network: {request.url}")
|
|
|
|
|
|
def _cfg_with_matches(tmp_path):
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
write_matches(cfg.matches_path, _matches_rows())
|
|
return cfg
|
|
|
|
|
|
def _seed_batch_fixture(cache_dir):
|
|
"""The cache entries run_enrich will ask for: the stats batch and the
|
|
dims pass's versions batch (sorted ids 13,266192)."""
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1)
|
|
key = cache_key("thing", {"id": "13,266192", "stats": "1"})
|
|
(cache_dir / key).write_text(combined)
|
|
versions_key = cache_key("thing", {"id": "13,266192", "versions": "1"})
|
|
(cache_dir / versions_key).write_text(
|
|
"""<items>
|
|
<item type="boardgame" id="13"><versions>
|
|
<item type="boardgameversion" id="7"><width value="9.5" />
|
|
<length value="11.5" /><depth value="3" /><weight value="3" /></item>
|
|
</versions></item>
|
|
<item type="boardgame" id="266192"><versions>
|
|
<item type="boardgameversion" id="465063"><width value="11.7" />
|
|
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
|
|
</versions></item>
|
|
</items>"""
|
|
)
|
|
|
|
|
|
def test_run_enrich_writes_games_json_with_versions(tmp_path):
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = _cfg_with_matches(tmp_path)
|
|
cache = tmp_path / "cache"
|
|
_seed_batch_fixture(cache)
|
|
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
|
|
|
|
games = run_enrich(cfg, client=client)
|
|
assert set(games) == {"13", "266192:465063"}
|
|
wingspan = games["266192:465063"]
|
|
assert wingspan["designers"] == ["Elizabeth Hargrave"]
|
|
assert wingspan["version"]["name"] == "English first edition"
|
|
assert wingspan["version"]["publishers"] == ["Stonemaier Games"]
|
|
assert games["13"]["version"] is None
|
|
saved = json.loads((cfg.data_dir / "games.json").read_text())
|
|
assert saved == games
|
|
|
|
|
|
def test_run_enrich_skips_already_enriched(tmp_path):
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = _cfg_with_matches(tmp_path)
|
|
cache = tmp_path / "cache"
|
|
_seed_batch_fixture(cache)
|
|
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
|
|
run_enrich(cfg, client=client)
|
|
|
|
# empty cache + network-refusing transport: passes only if enrich
|
|
# doesn't need to fetch anything at all
|
|
bare = BGGClient(
|
|
cache_dir=tmp_path / "empty", transport=httpx.MockTransport(_no_network)
|
|
)
|
|
games = run_enrich(cfg, client=bare)
|
|
assert set(games) == {"13", "266192:465063"}
|
|
|
|
|
|
def test_refresh_bypasses_cache_read(tmp_path):
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = _cfg_with_matches(tmp_path)
|
|
cache = tmp_path / "cache"
|
|
_seed_batch_fixture(cache)
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(str(request.url))
|
|
return httpx.Response(200, text=FULL_THING_XML.replace("8.05", "7.5"))
|
|
|
|
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler))
|
|
games = run_enrich(cfg, refresh=True, client=client)
|
|
# cache reads skipped, live fetches happened: stats batch + dims pass
|
|
assert any("stats=1" in u for u in requests)
|
|
assert any("versions=1" in u for u in requests)
|
|
assert games["266192:465063"]["rating"] == 7.5
|
|
|
|
|
|
def test_enrich_degrades_without_token(tmp_path, capsys):
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = _cfg_with_matches(tmp_path)
|
|
client = BGGClient(
|
|
cache_dir=tmp_path / "empty",
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(401, text="Unauthorized")
|
|
),
|
|
)
|
|
games = run_enrich(cfg, client=client)
|
|
assert games == {}
|
|
assert "waiting on the BGG API" in capsys.readouterr().out
|
|
assert json.loads((cfg.data_dir / "games.json").read_text()) == {}
|
|
|
|
|
|
def test_local_rows_enrich_from_their_own_reads(tmp_path):
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
cfg.data_dir.mkdir(parents=True)
|
|
write_matches(
|
|
cfg.matches_path,
|
|
[
|
|
{
|
|
"title_raw": "Obscure Homebrew",
|
|
"bgg_id": "",
|
|
"bgg_name": "",
|
|
"year": "",
|
|
"type": "",
|
|
"match_status": "local",
|
|
"version_id": "",
|
|
"version_name": "",
|
|
"version_status": "",
|
|
"candidates_json": "[]",
|
|
"version_candidates_json": "[]",
|
|
"source_photos": "shelf.jpg",
|
|
}
|
|
],
|
|
)
|
|
cfg.titles_path.write_text(
|
|
json.dumps(
|
|
[
|
|
{
|
|
"title_raw": "Obscure Homebrew",
|
|
"publisher_hint": "Basement Press",
|
|
"year_hint": 1998,
|
|
"source_photos": ["shelf.jpg"],
|
|
}
|
|
]
|
|
)
|
|
)
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
client = BGGClient(
|
|
cache_dir=cfg.data_dir / "cache",
|
|
transport=httpx.MockTransport(_no_network),
|
|
)
|
|
games = run_enrich(cfg, client=client)
|
|
(key,) = [k for k in games if k.startswith("local:")]
|
|
entry = games[key]
|
|
assert entry["name"] == "Obscure Homebrew"
|
|
assert entry["year"] == 1998
|
|
assert entry["publishers"] == ["Basement Press"]
|
|
assert entry["type"] == "localgame"
|
|
# idempotent: a second run keeps the entry (no prune, no dupe)
|
|
games2 = run_enrich(cfg, client=client)
|
|
assert key in games2
|
|
|
|
|
|
def test_summary_counts_local_and_api_entries_separately(tmp_path, capsys):
|
|
"""Local entries have no API target, so folding them into the fetched
|
|
tally made "already present or waiting" report a NEGATIVE count."""
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
cfg.data_dir.mkdir(parents=True)
|
|
write_matches(
|
|
cfg.matches_path,
|
|
[
|
|
{
|
|
"title_raw": "Homebrew",
|
|
"bgg_id": "",
|
|
"bgg_name": "",
|
|
"year": "",
|
|
"type": "",
|
|
"match_status": "local",
|
|
"version_id": "",
|
|
"version_name": "",
|
|
"version_status": "",
|
|
"candidates_json": "[]",
|
|
"version_candidates_json": "[]",
|
|
"source_photos": "shelf.jpg",
|
|
}
|
|
],
|
|
)
|
|
cfg.titles_path.write_text("[]")
|
|
client = BGGClient(
|
|
cache_dir=cfg.data_dir / "cache",
|
|
transport=httpx.MockTransport(_no_network),
|
|
)
|
|
games = run_enrich(cfg, client=client)
|
|
out = capsys.readouterr().out
|
|
assert len(games) == 1
|
|
assert "1 local-only" in out
|
|
import re as _re
|
|
|
|
assert not _re.search(r"-\d", out) # no negative tallies
|
|
|
|
|
|
def test_corrupt_local_games_store_fails_loud(tmp_path):
|
|
"""local_games.json is the ONLY source for off-BGG games — a tolerant
|
|
reader that skipped it would silently drop hand-written metadata."""
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
rows = _matches_rows()
|
|
for row in rows: # all local: the store must be read before any fetch
|
|
row["match_status"] = "local"
|
|
row["bgg_id"] = ""
|
|
write_matches(cfg.matches_path, rows)
|
|
cfg.local_games_path.write_text("{torn")
|
|
bare = BGGClient(
|
|
cache_dir=tmp_path / "empty", transport=httpx.MockTransport(_no_network)
|
|
)
|
|
with pytest.raises(ValueError, match="local_games.json is corrupt"):
|
|
run_enrich(cfg, client=bare)
|
|
|
|
|
|
DIMS_XML = """<items>
|
|
<item type="boardgame" id="240">
|
|
<versions>
|
|
<item type="boardgameversion" id="24621">
|
|
<width value="8.4" /><length value="11.5" /><depth value="2.1" />
|
|
<weight value="2" />
|
|
</item>
|
|
<item type="boardgameversion" id="99999">
|
|
<width value="0" /><length value="0" /><depth value="0" />
|
|
<weight value="0" />
|
|
</item>
|
|
</versions>
|
|
</item>
|
|
<item type="boardgame" id="500">
|
|
<versions>
|
|
<item type="boardgameversion" id="1"><width value="11.6" />
|
|
<length value="11.6" /><depth value="2.8" /><weight value="4.1" /></item>
|
|
<item type="boardgameversion" id="2"><width value="11.8" />
|
|
<length value="11.4" /><depth value="3.0" /><weight value="4.3" /></item>
|
|
<item type="boardgameversion" id="3"><width value="0" />
|
|
<length value="0" /><depth value="0" /><weight value="0" /></item>
|
|
</versions>
|
|
</item>
|
|
<item type="boardgame" id="600">
|
|
<versions>
|
|
<item type="boardgameversion" id="4"><width value="8.0" />
|
|
<length value="8.0" /><depth value="2.0" /><weight value="1" /></item>
|
|
<item type="boardgameversion" id="5"><width value="12.0" />
|
|
<length value="12.0" /><depth value="3.0" /><weight value="5" /></item>
|
|
</versions>
|
|
</item>
|
|
<item type="boardgame" id="700">
|
|
<versions>
|
|
<item type="boardgameversion" id="6"><width value="16.2" />
|
|
<length value="16.2" /><depth value="4.0" /><weight value="7" /></item>
|
|
</versions>
|
|
</item>
|
|
</items>"""
|
|
|
|
|
|
def test_parse_version_dims_treats_zero_as_absent():
|
|
from bggpipe.models import parse_version_dims
|
|
|
|
by_game = parse_version_dims(DIMS_XML)
|
|
exact = next(r for r in by_game[240] if r["version_id"] == 24621)
|
|
assert exact["width_in"] == 8.4 and exact["weight_lb"] == 2
|
|
zeroed = next(r for r in by_game[240] if r["version_id"] == 99999)
|
|
assert all(
|
|
zeroed[f] is None for f in ("width_in", "length_in", "depth_in", "weight_lb")
|
|
)
|
|
|
|
|
|
def test_entry_dims_verdicts():
|
|
"""The four sources: exact version, unanimous chorus (within 0.5"),
|
|
conflicting chorus (nulls, never a guess), and absent."""
|
|
from bggpipe.enrich import _entry_dims
|
|
from bggpipe.models import parse_version_dims
|
|
|
|
by_game = parse_version_dims(DIMS_XML)
|
|
|
|
versioned = _entry_dims({"version": {"version_id": 24621}}, by_game[240])
|
|
assert versioned["source"] == "version" and versioned["width_in"] == 8.4
|
|
|
|
# chosen version exists but carries only zeros: absent, not borrowed
|
|
zeroed = _entry_dims({"version": {"version_id": 99999}}, by_game[240])
|
|
assert zeroed["source"] == "absent" and zeroed["width_in"] is None
|
|
|
|
unanimous = _entry_dims({"version": None}, by_game[500])
|
|
assert unanimous["source"] == "unanimous"
|
|
assert unanimous["width_in"] == 11.8 # max per axis: the fit question
|
|
assert unanimous["depth_in"] == 3.0
|
|
|
|
conflicting = _entry_dims({"version": None}, by_game[600])
|
|
assert conflicting["source"] == "conflicting"
|
|
assert conflicting["width_in"] is None
|
|
|
|
assert _entry_dims({"version": None}, [])["source"] == "absent"
|
|
|
|
|
|
def test_enrich_fills_dims_from_cached_versions(tmp_path):
|
|
from bggpipe.enrich import run_enrich
|
|
|
|
cfg = _cfg_with_matches(tmp_path)
|
|
cache = tmp_path / "cache"
|
|
_seed_batch_fixture(cache)
|
|
(cache / cache_key("thing", {"id": "13,266192", "versions": "1"})).write_text(
|
|
"""<items>
|
|
<item type="boardgame" id="13"><versions>
|
|
<item type="boardgameversion" id="7"><width value="11.6" />
|
|
<length value="11.6" /><depth value="3.0" /><weight value="4" /></item>
|
|
</versions></item>
|
|
<item type="boardgame" id="266192"><versions>
|
|
<item type="boardgameversion" id="465063"><width value="11.7" />
|
|
<length value="11.7" /><depth value="2.8" /><weight value="4.4" /></item>
|
|
</versions></item>
|
|
</items>"""
|
|
)
|
|
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(_no_network))
|
|
games = run_enrich(cfg, client=client)
|
|
wingspan = games["266192:465063"]
|
|
assert wingspan["dims"]["source"] == "version"
|
|
assert wingspan["dims"]["depth_in"] == 2.8
|
|
assert wingspan["version"]["width_in"] == 11.7 # mirrored onto the version
|
|
assert games["13"]["dims"]["source"] == "unanimous"
|