Resolve stage: matching, version resolution, fixtures; BGG API auth
bggpipe resolve works end to end: search -> exact/fuzzy candidate scoring -> auto/ambiguous/unmatched classification with owned-count tie-breaks (mixed base/expansion candidates never auto-match), version scoring from edition cues (never guessed; no cues -> version_unknown), idempotent matches.csv appends. Discovered mid-build: BGG now requires registered-application Bearer tokens on the XML API (2025 policy change) and returns 401 otherwise. Client sends Authorization from BGG_API_TOKEN and raises an actionable BGGAuthError; CLAUDE.md and the bgg-api skill are updated to match. Live fixture recording is blocked until registration is approved, so tests replay hand-crafted stub fixtures via a network-refusing transport; scripts/record_fixtures.py re-records real XML under the same cache keys once a token exists. One live read-only smoke test is skipped unless --run-live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
"""Record the BGG XML fixtures the test suite replays.
|
||||
|
||||
Runs the real resolve logic with the cache pointed at tests/fixtures/
|
||||
bgg_cache/, so exactly the responses resolve needs get recorded (live,
|
||||
rate-limited). Run once; commit the fixtures. Re-running only fetches
|
||||
whatever is not already recorded.
|
||||
|
||||
Usage: uv run python scripts/record_fixtures.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from bggpipe.bgg_client import BGGClient
|
||||
from bggpipe.resolve import load_titles, resolve_entry
|
||||
|
||||
FIXTURE_CACHE = Path("tests/fixtures/bgg_cache")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not os.environ.get("BGG_API_TOKEN"):
|
||||
raise SystemExit(
|
||||
"BGG_API_TOKEN is not set. The XML API requires a registered "
|
||||
"application token (https://boardgamegeek.com/applications). "
|
||||
"Until one exists, the committed fixtures are the hand-crafted "
|
||||
"stubs from scripts/write_stub_fixtures.py; delete "
|
||||
f"{FIXTURE_CACHE} and re-run this script to replace them with "
|
||||
"real recordings."
|
||||
)
|
||||
client = BGGClient(cache_dir=FIXTURE_CACHE)
|
||||
for entry in load_titles(Path("data/titles.json")):
|
||||
row = resolve_entry(client, entry)
|
||||
print(
|
||||
f"{entry.title_raw!r}: {row.match_status} "
|
||||
f"{row.bgg_name or '-'} ({row.bgg_id or '-'}) {row.version_status}"
|
||||
)
|
||||
print(f"\nFixtures recorded in {FIXTURE_CACHE}:")
|
||||
for f in sorted(FIXTURE_CACHE.iterdir()):
|
||||
print(f" {f.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Write hand-crafted stub XML fixtures into tests/fixtures/bgg_cache/.
|
||||
|
||||
BGG's XML API now requires a registered application token, which we don't
|
||||
have yet, so these fixtures are realistic approximations of real responses
|
||||
(shapes verified against the API docs) rather than recordings. Once a
|
||||
BGG_API_TOKEN exists: delete tests/fixtures/bgg_cache/ and run
|
||||
scripts/record_fixtures.py — it writes real recordings under the SAME
|
||||
filenames, and the test suite must still pass.
|
||||
|
||||
Usage: uv run python scripts/write_stub_fixtures.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from bggpipe.bgg_client import cache_key
|
||||
|
||||
FIXTURE_CACHE = Path("tests/fixtures/bgg_cache")
|
||||
SEARCH_TYPES = "boardgame,boardgameexpansion"
|
||||
|
||||
|
||||
def search_item(bgg_id: int, name: str, year: int | None, type_: str) -> str:
|
||||
year_xml = f'<yearpublished value="{year}"/>' if year else ""
|
||||
return (
|
||||
f'<item type="{type_}" id="{bgg_id}">'
|
||||
f'<name type="primary" value="{name}"/>{year_xml}</item>'
|
||||
)
|
||||
|
||||
|
||||
WINGSPAN_FAMILY = (
|
||||
search_item(266192, "Wingspan", 2019, "boardgame")
|
||||
+ search_item(290448, "Wingspan: European Expansion", 2019, "boardgameexpansion")
|
||||
+ search_item(300580, "Wingspan: Oceania Expansion", 2020, "boardgameexpansion")
|
||||
+ search_item(366161, "Wingspan Asia", 2022, "boardgame")
|
||||
)
|
||||
|
||||
SEARCHES = {
|
||||
"Catan": (
|
||||
search_item(13, "CATAN", 1995, "boardgame")
|
||||
+ search_item(926, "CATAN: Seafarers", 1997, "boardgameexpansion")
|
||||
+ search_item(27760, "Catan Dice Game", 2007, "boardgame")
|
||||
+ search_item(133038, "CATAN 3D Collector's Edition", 2005, "boardgame")
|
||||
),
|
||||
"Wingspan": WINGSPAN_FAMILY,
|
||||
"Wingspan: European Expansion": WINGSPAN_FAMILY,
|
||||
"Wingspan Europe": WINGSPAN_FAMILY,
|
||||
"Café International": (
|
||||
search_item(373, "Café International", 1989, "boardgame")
|
||||
+ search_item(8103, "Café International: Das Kartenspiel", 2001, "boardgame")
|
||||
),
|
||||
"Citadels": (
|
||||
search_item(478, "Citadels", 2000, "boardgame")
|
||||
+ search_item(205398, "Citadels", 2016, "boardgame")
|
||||
),
|
||||
"Blorvath: Quest of the Zzyzx": "",
|
||||
}
|
||||
|
||||
THINGS = {
|
||||
# Citadels tie-break: neither edition dominates 10x -> stays ambiguous.
|
||||
("478,205398", "stats"): """<items>
|
||||
<item type="boardgame" id="478">
|
||||
<name type="primary" value="Citadels"/><yearpublished value="2000"/>
|
||||
<statistics><ratings><owned value="85000"/>
|
||||
<ranks><rank type="subtype" id="1" name="boardgame" value="250"/></ranks>
|
||||
</ratings></statistics>
|
||||
</item>
|
||||
<item type="boardgame" id="205398">
|
||||
<name type="primary" value="Citadels"/><yearpublished value="2016"/>
|
||||
<statistics><ratings><owned value="24000"/>
|
||||
<ranks><rank type="subtype" id="1" name="boardgame" value="400"/></ranks>
|
||||
</ratings></statistics>
|
||||
</item>
|
||||
</items>""",
|
||||
# Wingspan versions: 2019 Stonemaier English must beat the 2020 printing.
|
||||
("266192", "versions"): """<items>
|
||||
<item type="boardgame" id="266192">
|
||||
<name type="primary" value="Wingspan"/><yearpublished value="2019"/>
|
||||
<versions>
|
||||
<item type="boardgameversion" id="465063">
|
||||
<name type="primary" value="English first edition"/>
|
||||
<yearpublished value="2019"/>
|
||||
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
|
||||
<link type="language" id="2184" value="English"/>
|
||||
</item>
|
||||
<item type="boardgameversion" id="521212">
|
||||
<name type="primary" value="English fourth printing"/>
|
||||
<yearpublished value="2020"/>
|
||||
<link type="boardgamepublisher" id="23202" value="Stonemaier Games"/>
|
||||
<link type="language" id="2184" value="English"/>
|
||||
</item>
|
||||
<item type="boardgameversion" id="472430">
|
||||
<name type="primary" value="German edition"/>
|
||||
<yearpublished value="2019"/>
|
||||
<link type="boardgamepublisher" id="22160" value="Feuerland Spiele"/>
|
||||
<link type="language" id="2188" value="German"/>
|
||||
</item>
|
||||
</versions>
|
||||
</item>
|
||||
</items>""",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
FIXTURE_CACHE.mkdir(parents=True, exist_ok=True)
|
||||
for query, items in SEARCHES.items():
|
||||
key = cache_key("search", {"query": query, "type": SEARCH_TYPES})
|
||||
total = items.count("<item ")
|
||||
(FIXTURE_CACHE / key).write_text(f'<items total="{total}">{items}</items>')
|
||||
for (ids, flavor), xml in THINGS.items():
|
||||
key = cache_key("thing", {"id": ids, flavor: "1"})
|
||||
(FIXTURE_CACHE / key).write_text(xml)
|
||||
for f in sorted(FIXTURE_CACHE.iterdir()):
|
||||
print(f" {f.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user