Box dimensions: enrich learns shelf math, dims reports the Kallax truth

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
This commit is contained in:
Eric Wagoner
2026-08-09 11:18:48 -04:00
co-authored by Claude Fable 5
parent 9d752c4109
commit 102507b040
10 changed files with 1767 additions and 131 deletions
+84
View File
@@ -0,0 +1,84 @@
"""The shelf-space report: fit math, coverage buckets, honest unknowns."""
from __future__ import annotations
import json
import pytest
import typer
from bggpipe.config import Config
from bggpipe.dims import fits_kallax, run_dims_report
def test_fits_kallax_tries_every_orientation():
assert fits_kallax(11.6, 11.6, 2.8) # ordinary big-box
# too tall to stand, but slides in lying down: depth axis takes 15.0
assert fits_kallax(15.0, 12.0, 3.0)
# two axes over the opening: no orientation works
assert not fits_kallax(16.2, 16.2, 4.0)
# fits the opening but too deep to close the wall behind it
assert not fits_kallax(12.0, 12.0, 15.5)
# exact boundary counts as fitting
assert fits_kallax(13.2, 13.2, 15.4)
def test_report_buckets_misfits_and_unknowns(tmp_path, capsys):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(
json.dumps(
{
"1": {
"name": "Fits Fine",
"dims": {
"width_in": 11.6,
"length_in": 11.6,
"depth_in": 2.8,
"weight_lb": 4,
"source": "version",
},
},
"2": {
"name": "Monster Box",
"dims": {
"width_in": 16.2,
"length_in": 16.2,
"depth_in": 4.0,
"weight_lb": 7,
"source": "unanimous",
},
},
"3": {
"name": "Argued About",
"dims": {
"width_in": None,
"length_in": None,
"depth_in": None,
"weight_lb": None,
"source": "conflicting",
},
},
"4": {"name": "Never Measured"}, # pre-dims entry: absent
}
)
)
summary = run_dims_report(cfg)
assert summary["by_source"] == {
"version": 1,
"unanimous": 1,
"conflicting": 1,
"absent": 1,
}
assert summary["misfits"] == ["Monster Box"]
# can't verify is NOT the same as fits: both unknowns are named
assert summary["unknown"] == ["Argued About", "Never Measured"]
out = capsys.readouterr().out
assert "do NOT fit" in out and "Monster Box" in out
assert "can't be verified" in out and "Never Measured" in out
def test_report_without_games_json_exits_with_guidance(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
with pytest.raises(typer.Exit):
run_dims_report(cfg)
+124 -2
View File
@@ -151,11 +151,25 @@ def _cfg_with_matches(tmp_path):
def _seed_batch_fixture(cache_dir):
"""The batch cache entry run_enrich will ask for: sorted ids 13,266192."""
"""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):
@@ -209,7 +223,9 @@ def test_refresh_bypasses_cache_read(tmp_path):
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler))
games = run_enrich(cfg, refresh=True, client=client)
assert len(requests) == 1 # cache read skipped, live fetch happened
# 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
@@ -338,3 +354,109 @@ def test_corrupt_local_games_store_fails_loud(tmp_path):
)
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"