A box of expansions in one pass: search, tick, done — with containment

Eric's Character Trove problem, both halves. The Titles add panel gains
"search BGG and tick them off": one API search (rate-limited, cached)
returns the whole family as a checklist, already-cataloged ids greyed
out, and every ticked result lands as an APPROVED match row plus a
title addition — the human picked it off BGG's own list, so resolve
has nothing left to derive. A pick whose name matches an undecided
photo line decides THAT line (photos kept) instead of duplicating it;
BGG's true name twins (two games both called "Citadels") skip with an
honest message rather than fusing.

And the half Eric spotted mid-build: containment is real data, not a
convention. A stored_in column on the match row (the container's
bgg_id — human curation, riding the same durable CSV as dedupe_veto)
is set by the pick panel's "they all live inside" selector, flows
through enrich onto games.json, and surfaces both directions in the
Library — "where it lives" on the content, "in this box" on the
container. The dims report excludes contained games from the Kallax
unknowns and counts them separately: a game with no box of its own
has no shelf space to plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-09 12:05:43 -04:00
co-authored by Claude Fable 5
parent 102507b040
commit dbc1899759
11 changed files with 5192 additions and 3899 deletions
+97
View File
@@ -103,6 +103,7 @@ def make_cfg(tmp_path) -> Config:
[
{
"title_raw": "Citadels",
"confidence": "high",
"publisher_hint": "Fantasy Flight",
"edition_hint": "",
"source_photos": ["shelf.jpg"],
@@ -1443,3 +1444,99 @@ def test_review_summary_reports_token_presence_not_value(tmp_path, monkeypatch):
monkeypatch.delenv("BGG_API_TOKEN")
web2, _ = make_client(tmp_path / "tokenless")
assert web2.get("/api/state").json()["summary"]["token_present"] is False
def test_bgg_search_and_add_picked_flow(tmp_path):
"""A whole box of expansions in one pass: search once, tick, and each
pick lands already-matched with its container recorded."""
cfg = make_cfg(tmp_path)
web = TestClient(create_app(cfg, client=fixture_search_client()))
found = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
assert len(found) >= 2
ids = {r["bgg_id"] for r in found}
assert 478 in ids
assert all(r["already"] is False for r in found)
picks = [r for r in found if r["bgg_id"] in (478, 13291, 205398)]
res = web.post(
"/api/add-picked", json={"picks": picks, "stored_in": "173634"}
).json()
# 478 and The Dark City land; 205398 is BGG's OTHER game named
# "Citadels" — a true name twin, skipped honestly rather than fused
assert res["added"] == 2
assert res["skipped"] == ["Citadels — a same-named line already exists"]
rows = {r["bgg_id"]: r for r in read_matches(cfg.matches_path)}
# "Citadels" already existed as an undecided photo line: the pick
# DECIDED that line (photos kept) instead of duplicating it
assert rows["478"]["match_status"] == "approved"
assert rows["478"]["source_photos"] == "shelf.jpg"
assert rows["478"]["stored_in"] == "173634"
# the expansion had no line: appended photo-less, already matched
assert rows["13291"]["match_status"] == "approved"
assert rows["13291"]["source_photos"] == ""
# re-searching now flags them; re-adding skips instead of duplicating
found2 = web.get("/api/bgg-search", params={"q": "Citadels"}).json()["results"]
assert next(r for r in found2 if r["bgg_id"] == 478)["already"] is True
res2 = web.post("/api/add-picked", json={"picks": picks}).json()
assert res2["added"] == 0 and len(res2["skipped"]) == 3
# blank query is a 400, and BGG-down is a 502, not a crash
assert web.get("/api/bgg-search", params={"q": " "}).status_code == 400
down = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
assert down.get("/api/bgg-search", params={"q": "x"}).status_code == 502
def fixture_search_client() -> BGGClient:
from pathlib import Path as _P
fixtures = _P(__file__).parent / "fixtures" / "bgg_cache"
return BGGClient(
cache_dir=fixtures,
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
def test_library_detail_shows_containment_both_ways(tmp_path):
cfg = make_cfg(tmp_path)
rows = read_matches(cfg.matches_path)
rows.append(
_row(
title_raw="The Trove",
match_status="approved",
bgg_id="173634",
source_photos="shelf.jpg",
)
)
rows.append(
_row(
title_raw="Witchdoctor",
match_status="approved",
bgg_id="999",
source_photos="",
stored_in="173634",
)
)
write_matches(cfg.matches_path, rows)
cfg.games_path.write_text(
json.dumps(
{
"173634": {"bgg_id": 173634, "name": "The Trove", "type": "boardgame"},
"999": {
"bgg_id": 999,
"name": "Witchdoctor",
"type": "boardgameexpansion",
"stored_in": "173634",
},
}
)
)
web = TestClient(create_app(cfg, client=unauthorized_client(tmp_path)))
inside = web.get("/api/library/999").json()
assert inside["stored_in_game"] == {"key": "173634", "name": "The Trove"}
box = web.get("/api/library/173634").json()
assert box["contains"] == [{"key": "999", "name": "Witchdoctor"}]