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) |
|
| 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 |
|
| 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 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`.
|
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`
|
||||||
- `https://boardgamegeek.com/xmlapi2/collection?username=YOU&own=1&version=1&subtype=boardgameexpansion`
|
- `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
|
## Publishing your library on your own site
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ def enrich(
|
|||||||
run_enrich(cfg, refresh=refresh)
|
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()
|
@app.command()
|
||||||
def export(
|
def export(
|
||||||
out: Annotated[
|
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.config import Config
|
||||||
from bggpipe.fsio import atomic_write_text
|
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.normalize import normalize_title
|
||||||
from bggpipe.resolve import load_titles, read_matches
|
from bggpipe.resolve import load_titles, read_matches
|
||||||
|
|
||||||
BATCH_SIZE = 20
|
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:
|
def _version_info(row: dict) -> dict | None:
|
||||||
if not is_confident_version(row):
|
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'}"
|
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)
|
games_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
atomic_write_text(
|
atomic_write_text(
|
||||||
games_path, json.dumps(games, indent=2, ensure_ascii=False) + "\n"
|
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
|
# tally would drive "already present or waiting" negative
|
||||||
waiting = len(targets) - updated
|
waiting = len(targets) - updated
|
||||||
parts = [f"{updated} fetched from BGG"]
|
parts = [f"{updated} fetched from BGG"]
|
||||||
|
if dims_filled:
|
||||||
|
parts.append(f"box dims checked for {dims_filled}")
|
||||||
if local_keys:
|
if local_keys:
|
||||||
parts.append(f"{len(local_keys)} local-only")
|
parts.append(f"{len(local_keys)} local-only")
|
||||||
if waiting:
|
if waiting:
|
||||||
|
|||||||
@@ -268,6 +268,28 @@ def parse_things_full(xml_text: str) -> list[dict]:
|
|||||||
return games
|
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:
|
def _required_attr(item: ET.Element, name: str) -> str:
|
||||||
value = item.get(name)
|
value = item.get(name)
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
@@ -126,9 +126,15 @@ function render(g) {
|
|||||||
aria-label="cover photo for ${esc(g.name)}">`
|
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 = [
|
const facts = [
|
||||||
fact("players", players(g)),
|
fact("players", players(g)),
|
||||||
fact("playing time", playtime(g)),
|
fact("playing time", playtime(g)),
|
||||||
|
fact("box", dims),
|
||||||
fact("ages", g.min_age ? `${g.min_age}+` : ""),
|
fact("ages", g.min_age ? `${g.min_age}+` : ""),
|
||||||
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
|
fact("weight", g.weight ? `${g.weight.toFixed(2)} / 5` : ""),
|
||||||
fact("BGG rank", g.rank || ""),
|
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):
|
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)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1)
|
combined = FULL_THING_XML.replace("<items>", "<items>" + CATAN_MINIMAL[7:-8], 1)
|
||||||
key = cache_key("thing", {"id": "13,266192", "stats": "1"})
|
key = cache_key("thing", {"id": "13,266192", "stats": "1"})
|
||||||
(cache_dir / key).write_text(combined)
|
(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):
|
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))
|
client = BGGClient(cache_dir=cache, transport=httpx.MockTransport(handler))
|
||||||
games = run_enrich(cfg, refresh=True, client=client)
|
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
|
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"):
|
with pytest.raises(ValueError, match="local_games.json is corrupt"):
|
||||||
run_enrich(cfg, client=bare)
|
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