"""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 = """
-
https://cf.example/thumb.jpg
https://cf.example/full.jpg
A bird-collection engine builder.
"""
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 = (
'- '
''
"
"
)
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("", "" + 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(
"""
-
-
-
-
"""
)
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 = """
-
-
-
-
-
-
-
-
-
-
-
-
"""
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(
"""
-
-
-
-
"""
)
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"