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:
co-authored by
Claude Fable 5
parent
9d752c4109
commit
102507b040
@@ -15,6 +15,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
| 5 | `bggpipe upload` | add games via a logged-in Playwright session | working — all browser flows verified live 2026-08-06 (62 adds + 36 version updates landed) |
|
||||
| 6 | `bggpipe enrich` | fetch full game/version metadata into `games.json` | working |
|
||||
| + | `bggpipe export` | render the library as self-contained static pages (covers downloaded, never hotlinked; shelf photos never included; Powered-by-BGG badge slot) | working |
|
||||
| + | `bggpipe dims` | offline shelf-space report over box dimensions enrich collects from BGG *versions* (dims live per-version, not per-game; 0 = never entered; versionless games need a unanimous chorus within 0.5"/axis else `conflicting` — never guessed) | working |
|
||||
|
||||
Full design lives in `docs/spec.md` (read it before changing pipeline semantics); the upload-stage walkthrough is in `docs/bgg-upload-flow.md`.
|
||||
|
||||
|
||||
+1296
-128
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,24 @@ BGG application approval can take a week or more. Until then: `extract` works im
|
||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1`
|
||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
|
||||
|
||||
## Shelf-space planning
|
||||
|
||||
Physical box dimensions live on BGG *versions*, not games, so enrich collects
|
||||
them in a second cached pass: your exact version's numbers where you chose
|
||||
one, otherwise a value only when every listed printing agrees (within half an
|
||||
inch per axis — disagreement is stored as `conflicting`, never guessed, and
|
||||
BGG's `0` means "never entered", not zero inches). Then:
|
||||
|
||||
```sh
|
||||
bggpipe dims
|
||||
```
|
||||
|
||||
reports coverage, the ten biggest footprints, and a fit check against an IKEA
|
||||
Kallax cube (13.2" × 13.2" opening, 15.4" deep) — listing every box that fits
|
||||
in **no** orientation, and every game whose dimensions can't be verified,
|
||||
because can't-verify is not the same as fits. Trusted dimensions also show on
|
||||
each game's Library detail page.
|
||||
|
||||
## Publishing your library on your own site
|
||||
|
||||
```sh
|
||||
|
||||
@@ -193,6 +193,15 @@ def enrich(
|
||||
run_enrich(cfg, refresh=refresh)
|
||||
|
||||
|
||||
@app.command()
|
||||
def dims(config: ConfigOpt = None) -> None:
|
||||
"""Shelf-space report: box sizes, biggest footprints, Kallax fit."""
|
||||
from bggpipe.dims import run_dims_report
|
||||
|
||||
cfg = load_config(config)
|
||||
run_dims_report(cfg)
|
||||
|
||||
|
||||
@app.command()
|
||||
def export(
|
||||
out: Annotated[
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Shelf-space report over the box dimensions enrich collected.
|
||||
|
||||
Reads games.json only — fully offline. The fit target is an IKEA Kallax
|
||||
cube: a box fits if SOME orientation puts two axes through the ~13.2"
|
||||
square opening with the third axis within the ~15.4" depth. "Can't
|
||||
verify" is reported as exactly that, never as a fit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from itertools import permutations
|
||||
|
||||
import typer
|
||||
|
||||
from bggpipe.config import Config
|
||||
|
||||
KALLAX_OPENING_IN = 13.2
|
||||
KALLAX_DEPTH_IN = 15.4
|
||||
|
||||
_AXES = ("width_in", "length_in", "depth_in")
|
||||
|
||||
|
||||
def fits_kallax(width: float, length: float, depth: float) -> bool:
|
||||
return any(
|
||||
a <= KALLAX_OPENING_IN and b <= KALLAX_OPENING_IN and c <= KALLAX_DEPTH_IN
|
||||
for a, b, c in permutations((width, length, depth))
|
||||
)
|
||||
|
||||
|
||||
def _footprint(dims: dict) -> float:
|
||||
"""The two largest axes multiplied — the shelf area a box claims
|
||||
lying in its flattest orientation."""
|
||||
axes = sorted((dims[a] for a in _AXES), reverse=True)
|
||||
return axes[0] * axes[1]
|
||||
|
||||
|
||||
def run_dims_report(cfg: Config) -> dict:
|
||||
if not cfg.games_path.exists():
|
||||
typer.echo(f"{cfg.games_path} not found — run `bggpipe enrich` first.")
|
||||
raise typer.Exit(code=1)
|
||||
games: dict[str, dict] = json.loads(cfg.games_path.read_text())
|
||||
|
||||
by_source: dict[str, list[tuple[str, dict]]] = {}
|
||||
for entry in games.values():
|
||||
dims = entry.get("dims") or dict.fromkeys(_AXES, None) | {"source": "absent"}
|
||||
by_source.setdefault(dims["source"], []).append(
|
||||
(entry.get("name") or "?", dims)
|
||||
)
|
||||
|
||||
typer.echo(f"Box dimensions across {len(games)} game(s):")
|
||||
for source, label in (
|
||||
("version", "from the exact owned version"),
|
||||
("unanimous", "every listed version agrees"),
|
||||
("conflicting", "versions disagree — not guessed"),
|
||||
("absent", "no data on BGG (or no BGG entry)"),
|
||||
):
|
||||
if by_source.get(source):
|
||||
typer.echo(f" {len(by_source[source]):>4} {source} — {label}")
|
||||
|
||||
measured = sorted(
|
||||
(
|
||||
(name, dims)
|
||||
for source in ("version", "unanimous")
|
||||
for name, dims in by_source.get(source, [])
|
||||
),
|
||||
key=lambda item: _footprint(item[1]),
|
||||
reverse=True,
|
||||
)
|
||||
if measured:
|
||||
typer.echo("\nBiggest footprints (two largest axes, flattest lie):")
|
||||
for name, dims in measured[:10]:
|
||||
w, length, d = (dims[a] for a in _AXES)
|
||||
typer.echo(
|
||||
f" {_footprint(dims):>6.1f} sq in {name}"
|
||||
f" ({w:g} × {length:g} × {d:g} in)"
|
||||
)
|
||||
|
||||
misfits = [
|
||||
(name, dims)
|
||||
for name, dims in measured
|
||||
if not fits_kallax(*(dims[a] for a in _AXES))
|
||||
]
|
||||
unknown = [
|
||||
name
|
||||
for source in ("conflicting", "absent")
|
||||
for name, _ in by_source.get(source, [])
|
||||
]
|
||||
typer.echo(
|
||||
f'\nKallax check ({KALLAX_OPENING_IN:g}" × {KALLAX_OPENING_IN:g}" opening, '
|
||||
f'{KALLAX_DEPTH_IN:g}" deep):'
|
||||
)
|
||||
if misfits:
|
||||
typer.echo(f" {len(misfits)} box(es) do NOT fit in any orientation:")
|
||||
for name, dims in misfits:
|
||||
w, length, d = (dims[a] for a in _AXES)
|
||||
typer.echo(f" {name} ({w:g} × {length:g} × {d:g} in)")
|
||||
else:
|
||||
typer.echo(" every measured box fits.")
|
||||
if unknown:
|
||||
typer.echo(
|
||||
f" {len(unknown)} game(s) can't be verified (no trusted "
|
||||
"dimensions — not the same as fitting):"
|
||||
)
|
||||
for name in sorted(unknown):
|
||||
typer.echo(f" {name}")
|
||||
|
||||
return {
|
||||
"by_source": {s: len(v) for s, v in by_source.items()},
|
||||
"misfits": [name for name, _ in misfits],
|
||||
"unknown": sorted(unknown),
|
||||
}
|
||||
+95
-1
@@ -27,12 +27,99 @@ from bggpipe.bgg_client import (
|
||||
)
|
||||
from bggpipe.config import Config
|
||||
from bggpipe.fsio import atomic_write_text
|
||||
from bggpipe.models import is_confident_version, is_recognized
|
||||
from bggpipe.models import is_confident_version, is_recognized, parse_version_dims
|
||||
from bggpipe.normalize import normalize_title
|
||||
from bggpipe.resolve import load_titles, read_matches
|
||||
|
||||
BATCH_SIZE = 20
|
||||
|
||||
_AXES = ("width_in", "length_in", "depth_in")
|
||||
_AGREE_IN = 0.5 # per-axis tolerance for calling versionless dims unanimous
|
||||
|
||||
|
||||
def _entry_dims(entry: dict, records: list[dict]) -> dict:
|
||||
"""The box-dimensions verdict for one game, from its versions' data.
|
||||
|
||||
Known version: that exact version's numbers or nothing. No chosen
|
||||
version: only a chorus that AGREES (within 0.5" per axis, all axes
|
||||
present) counts — the maximum per axis is kept, since the planning
|
||||
question is "will it fit", and disagreement beyond tolerance stores
|
||||
nulls rather than a guess."""
|
||||
version_id = (entry.get("version") or {}).get("version_id")
|
||||
if version_id:
|
||||
rec = next((r for r in records if r["version_id"] == version_id), None)
|
||||
if rec and all(rec[a] for a in _AXES):
|
||||
return {
|
||||
**{a: rec[a] for a in _AXES},
|
||||
"weight_lb": rec["weight_lb"],
|
||||
"source": "version",
|
||||
}
|
||||
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
|
||||
complete = [r for r in records if all(r[a] for a in _AXES)]
|
||||
if not complete:
|
||||
return dict.fromkeys((*_AXES, "weight_lb"), None) | {"source": "absent"}
|
||||
dims: dict = {}
|
||||
for axis in _AXES:
|
||||
values = [r[axis] for r in complete]
|
||||
if max(values) - min(values) > _AGREE_IN:
|
||||
return dict.fromkeys((*_AXES, "weight_lb"), None) | {
|
||||
"source": "conflicting"
|
||||
}
|
||||
dims[axis] = max(values)
|
||||
weights = [r["weight_lb"] for r in complete if r["weight_lb"]]
|
||||
dims["weight_lb"] = (
|
||||
max(weights) if weights and max(weights) - min(weights) <= 0.5 else None
|
||||
)
|
||||
return dims | {"source": "unanimous"}
|
||||
|
||||
|
||||
def _dims_pass(games: dict, client: BGGClient, *, refresh: bool) -> tuple[int, bool]:
|
||||
"""Fill entry["dims"] for every BGG-backed game from versions=1 data
|
||||
(physical dimensions live on VERSIONS, not games). Cached XML makes
|
||||
re-runs free; rpgitems and local games get source "absent"."""
|
||||
need_ids = sorted(
|
||||
{
|
||||
e["bgg_id"]
|
||||
for e in games.values()
|
||||
if e.get("type") in ("boardgame", "boardgameexpansion")
|
||||
and e.get("bgg_id")
|
||||
and (refresh or "dims" not in e)
|
||||
}
|
||||
)
|
||||
by_game: dict[int, list[dict]] = {}
|
||||
blocked = False
|
||||
for start in range(0, len(need_ids), BATCH_SIZE):
|
||||
batch = need_ids[start : start + BATCH_SIZE]
|
||||
try:
|
||||
xml = client.get_xml(
|
||||
"thing",
|
||||
{"id": ",".join(str(i) for i in batch), "versions": "1"},
|
||||
refresh=refresh,
|
||||
)
|
||||
except (BGGAuthError, BGGQueueTimeout):
|
||||
blocked = True
|
||||
break
|
||||
by_game.update(parse_version_dims(xml))
|
||||
filled = 0
|
||||
for entry in games.values():
|
||||
if not (refresh or "dims" not in entry):
|
||||
continue
|
||||
if entry.get("bgg_id") in by_game:
|
||||
entry["dims"] = _entry_dims(entry, by_game[entry["bgg_id"]])
|
||||
if entry["dims"]["source"] == "version" and entry.get("version"):
|
||||
entry["version"].update(
|
||||
{k: entry["dims"][k] for k in (*_AXES, "weight_lb")}
|
||||
)
|
||||
filled += 1
|
||||
elif entry.get("bgg_id") is None or entry.get("type") not in (
|
||||
"boardgame",
|
||||
"boardgameexpansion",
|
||||
):
|
||||
entry["dims"] = dict.fromkeys((*_AXES, "weight_lb"), None) | {
|
||||
"source": "absent"
|
||||
}
|
||||
return filled, blocked
|
||||
|
||||
|
||||
def _version_info(row: dict) -> dict | None:
|
||||
if not is_confident_version(row):
|
||||
@@ -184,6 +271,11 @@ def run_enrich(
|
||||
f" pruned {len(stale)} stale entr{'y' if len(stale) == 1 else 'ies'}"
|
||||
)
|
||||
|
||||
# box dimensions live on VERSIONS, not games: a second cached pass
|
||||
# (read-only; upload is untouched) fills entry["dims"] for shelf math
|
||||
dims_filled, dims_blocked = _dims_pass(games, client, refresh=refresh)
|
||||
blocked = blocked or dims_blocked
|
||||
|
||||
games_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_text(
|
||||
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
|
||||
@@ -194,6 +286,8 @@ def run_enrich(
|
||||
# tally would drive "already present or waiting" negative
|
||||
waiting = len(targets) - updated
|
||||
parts = [f"{updated} fetched from BGG"]
|
||||
if dims_filled:
|
||||
parts.append(f"box dims checked for {dims_filled}")
|
||||
if local_keys:
|
||||
parts.append(f"{len(local_keys)} local-only")
|
||||
if waiting:
|
||||
|
||||
@@ -268,6 +268,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
|
||||
return games
|
||||
|
||||
|
||||
def parse_version_dims(xml_text: str) -> dict[int, list[dict]]:
|
||||
"""Physical box data per game id from a versions=1 response. BGG uses
|
||||
0 for "never entered" — a zero axis or weight parses as None, never
|
||||
as a real dimension. Returns plain dicts (artifact data)."""
|
||||
out: dict[int, list[dict]] = {}
|
||||
for item in _root(xml_text).findall("item"):
|
||||
game_id = int(_required_attr(item, "id"))
|
||||
records = out.setdefault(game_id, [])
|
||||
for v_item in item.findall("versions/item"):
|
||||
record: dict = {"version_id": int(_required_attr(v_item, "id"))}
|
||||
for xml_name, field_name in (
|
||||
("width", "width_in"),
|
||||
("length", "length_in"),
|
||||
("depth", "depth_in"),
|
||||
("weight", "weight_lb"),
|
||||
):
|
||||
value = _attr_float(v_item.find(xml_name))
|
||||
record[field_name] = value if value else None
|
||||
records.append(record)
|
||||
return out
|
||||
|
||||
|
||||
def _required_attr(item: ET.Element, name: str) -> str:
|
||||
value = item.get(name)
|
||||
if not value:
|
||||
|
||||
@@ -126,9 +126,15 @@ function render(g) {
|
||||
aria-label="cover photo for ${esc(g.name)}">`
|
||||
: "";
|
||||
|
||||
const dims = g.dims && g.dims.width_in
|
||||
? `${g.dims.width_in} × ${g.dims.length_in} × ${g.dims.depth_in} in`
|
||||
+ (g.dims.weight_lb ? ` · ${g.dims.weight_lb} lb` : "")
|
||||
+ (g.dims.source === "unanimous" ? " (all printings agree)" : "")
|
||||
: "";
|
||||
const facts = [
|
||||
fact("players", players(g)),
|
||||
fact("playing time", playtime(g)),
|
||||
fact("box", dims),
|
||||
fact("ages", g.min_age ? `${g.min_age}+` : ""),
|
||||
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
|
||||
fact("BGG rank", g.rank || ""),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user