Files
bggpipe/tests/test_shelves.py
T
Eric WagonerandClaude Fable 5 4f446f6f2a The Shelves layer: where every box physically lives
Eric's spec, all nine points. Two committed local-only stores follow
the local_games.json pattern — furniture.json (units of openings with
interior dims; a dimensionless opening is a virtual spot like a travel
case) and locations.json (game key -> opening + note). Shelf layouts
are nobody's data but the owner's; nothing touches upload.

The Shelves page builds furniture without hand-editing JSON — the
acceptance bar (two double-wides above three rows of four cubes, two
bookcases, a travel case) is a TEST, driven entirely through the
endpoints the UI calls. Presets for Kallax/Billy/custom/virtual,
grid creation with A1-style labels, openings editable/deletable/
reorderable. Units render as grids: zone, count, fill bar (stacked
thinnest-axis vs interior height), ⚠ on overfull or any resident that
can't fit. Openings open as a modal — a bottom sheet at phone widths,
search-first with thumb-sized targets for the moving-day loop.
Unshelved games list alongside with one-tap suggestions (only openings
they verifiably fit, with room).

Containment composes: a game stored inside another box inherits its
container's location, rides along in the opening's resident list
(marked), and refuses direct assignment naming its container. The
detail page's where-it-lives card gains the picker (openings grouped
by unit, each labeled fits / doesn't fit / can't verify) plus virtual
notes ("lent to Sarah, June"); the Library list shows a location line,
filters by unit or unshelved, and search matches location text and
zones.

bggpipe dims drops its hardcoded Kallax for the user's actual
furniture: per-opening capacity, overfull and misfit warnings,
unshelved count. bggpipe shelve --import loads a name,opening CSV
(ids or labels), rejecting — never guessing — unknown names, ambiguous
copies, unknown/ambiguous openings, misfits, and contained games.

Ten new tests incl. the acceptance flow, inheritance, CSV rejects,
and a phone-sheet smoke; 372 total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 13:45:36 -04:00

363 lines
12 KiB
Python

"""The location layer: stores, fit epistemics, inheritance, CSV import."""
from __future__ import annotations
import json
import pytest
import typer
from bggpipe.config import Config
from bggpipe.shelves import (
effective_location,
fits_opening,
import_assignments,
load_furniture,
new_opening_id,
opening_report,
save_furniture,
)
def _entry(name="Game", w=11.6, length=11.6, d=2.8, **extra):
dims = (
{"width_in": w, "length_in": length, "depth_in": d, "source": "version"}
if w
else {"width_in": None, "length_in": None, "depth_in": None, "source": "absent"}
)
return {"name": name, "dims": dims, **extra}
KALLAX = {
"id": "k1",
"label": "A1",
"zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
}
DOUBLE_WIDE = {
"id": "dw",
"label": "wide top",
"zone": "big boxes",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
}
TRAVEL = {"id": "travel", "label": "travel case", "zone": ""}
def test_fits_opening_speaks_the_house_language():
assert fits_opening(_entry(), KALLAX) is True
# 17" box: fails the cube, fits the double-wide — the acceptance case
big = _entry("Bugs in the Kitchen", 11.5, 17, 2.25)
assert fits_opening(big, KALLAX) is False
assert fits_opening(big, DOUBLE_WIDE) is True
# no game dims -> can't verify, never "fits"
assert fits_opening(_entry("Mystery", w=None), KALLAX) is None
# a virtual location imposes no limits
assert fits_opening(big, TRAVEL) is None
def test_opening_report_counts_stack_and_warns():
thin = _entry("Thin", 11, 11, 2)
thick = _entry("Thick", 11, 11, 4)
unmeasured = _entry("Mystery", w=None)
report = opening_report(KALLAX, [("a", thin), ("b", thick), ("c", unmeasured)])
assert report["games"] == 3
assert report["stacked_in"] == 6
assert report["unmeasured"] == 1
assert report["overfull"] is False
# seven thick boxes overflow a 13.25" interior
stack = [(str(i), thick) for i in range(7)]
assert opening_report(KALLAX, stack)["overfull"] is True
# a misfit is named even when the stack has room
report = opening_report(KALLAX, [("a", _entry("Too Long", 11.5, 17, 2.25))])
assert report["misfits"] == ["Too Long"]
def test_effective_location_inherits_from_container():
games = {
"173634": _entry("Trove", bgg_id=173634),
"999": _entry("Witchdoctor", bgg_id=999, stored_in="173634"),
"13": _entry("Catan", bgg_id=13),
}
locations = {"173634": {"opening_id": "k1", "note": "top shelf"}}
oid, note, via = effective_location("999", games["999"], games, locations)
assert (oid, note, via) == ("k1", "top shelf", "173634")
# the container itself reads its own record
assert effective_location("173634", games["173634"], games, locations)[2] is None
# unassigned, uncontained: unshelved
assert effective_location("13", games["13"], games, locations) == (None, "", None)
def test_opening_ids_are_stable_and_unique():
units = [
{
"name": "Den Kallax",
"openings": [{"id": "den-kallax-a1", "label": "A1", "zone": ""}],
}
]
assert new_opening_id(units, "Den Kallax", "A2") == "den-kallax-a2"
assert new_opening_id(units, "Den Kallax", "A1") == "den-kallax-a1-2"
def test_furniture_round_trip_and_corrupt_store(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
units = [{"name": "Den", "openings": [KALLAX, DOUBLE_WIDE]}]
save_furniture(cfg, units)
assert load_furniture(cfg) == units
cfg.furniture_path.write_text("{torn")
with pytest.raises(ValueError, match="furniture.json is corrupt"):
load_furniture(cfg)
def _import_cfg(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(
json.dumps(
{
"1": _entry("Catan", 11.6, 11.6, 3, bgg_id=1),
"2": _entry("Bugs in the Kitchen", 11.5, 17, 2.25, bgg_id=2),
"3": _entry("Witchdoctor", 8, 8, 2, bgg_id=3, stored_in="1"),
"4a": _entry("Twin", 8, 8, 2, bgg_id=40),
"4b": _entry("Twin", 8, 8, 2, bgg_id=41),
}
)
)
save_furniture(
cfg,
[
{"name": "Den Kallax", "openings": [KALLAX]},
{"name": "Loft", "openings": [DOUBLE_WIDE, TRAVEL]},
],
)
return cfg
def test_csv_import_assigns_and_rejects_honestly(tmp_path, capsys):
cfg = _import_cfg(tmp_path)
plan = tmp_path / "plan.csv"
plan.write_text(
"name,opening\n"
"Catan,A1\n" # by label
"Bugs in the Kitchen,dw\n" # by id, needs the double-wide
"Bugs in the Kitchen,A1\n" # doesn't fit the cube: reject
"Witchdoctor,A1\n" # contained: reject
"Twin,A1\n" # two copies: reject
"Ghost Game,A1\n" # unknown name: reject
"Catan,Z9\n" # unknown opening: reject
)
result = import_assignments(cfg, plan)
assert result["assigned"] == 2
assert len(result["rejects"]) == 5
saved = json.loads(cfg.locations_path.read_text())
assert saved["1"]["opening_id"] == "k1"
assert saved["2"]["opening_id"] == "dw"
out = capsys.readouterr().out
assert "doesn't fit" in out and "lives inside another box" in out
assert "matches 2 copies" in out and "no game by that name" in out
def test_import_without_furniture_exits_with_guidance(tmp_path):
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text("{}")
plan = tmp_path / "plan.csv"
plan.write_text("name,opening\n")
with pytest.raises(typer.Exit):
import_assignments(cfg, plan)
# -- web layer ----------------------------------------------------------
def _web(tmp_path, games):
import httpx
from fastapi.testclient import TestClient
from bggpipe.bgg_client import BGGClient
from bggpipe.resolve import write_matches
from bggpipe.webreview import create_app
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
cfg.photos_dir.mkdir(parents=True)
cfg.data_dir.mkdir(parents=True)
write_matches(cfg.matches_path, [])
cfg.games_path.write_text(json.dumps(games))
client = BGGClient(
cache_dir=tmp_path / "no_cache",
transport=httpx.MockTransport(
lambda req: httpx.Response(401, text="Unauthorized")
),
)
return TestClient(create_app(cfg, client=client)), cfg
def test_acceptance_full_furniture_flow_without_touching_json(tmp_path):
"""The spec's bar: two double-wide openings above three rows of four
cubes, plus two bookcases of shelves, plus a travel case — expressed
entirely through the endpoints the UI calls."""
web, cfg = _web(tmp_path, {})
assert (
web.post("/api/furniture/add-unit", json={"name": "Den wall"}).status_code
== 200
)
# two double-wides, one at a time
for label in ("wide left", "wide right"):
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den wall",
"label": label,
"zone": "big boxes",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
},
)
# three rows of four cubes as a grid
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den wall",
"rows": 3,
"cols": 4,
"zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
for name in ("Bookcase north", "Bookcase south"):
web.post("/api/furniture/add-unit", json={"name": name})
web.post(
"/api/furniture/add-openings",
json={
"unit": name,
"rows": 5,
"cols": 1,
"zone": "long games",
"width_in": 30.75,
"height_in": 13,
"depth_in": 11,
},
)
web.post("/api/furniture/add-unit", json={"name": "Travel"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Travel",
"label": "travel case",
},
)
state = web.get("/api/shelves").json()
counts = {u["name"]: len(u["openings"]) for u in state["units"]}
assert counts == {
"Den wall": 14,
"Bookcase north": 5,
"Bookcase south": 5,
"Travel": 1,
}
# the store round-trips as plain committed JSON
saved = json.loads(cfg.furniture_path.read_text())["units"]
assert saved[0]["openings"][0]["label"] == "wide left"
assert saved[-1]["openings"][0].get("width_in") is None # virtual
# editable, deletable, reorderable
first_cube = saved[0]["openings"][2]["id"]
web.post("/api/furniture/edit-opening", json={"id": first_cube, "zone": "kids"})
web.post("/api/furniture/move-opening", json={"id": first_cube, "direction": -1})
web.post(
"/api/furniture/delete-opening", json={"id": saved[0]["openings"][3]["id"]}
)
saved2 = json.loads(cfg.furniture_path.read_text())["units"]
assert len(saved2[0]["openings"]) == 13
assert saved2[0]["openings"][1]["id"] == first_cube # moved up one
def test_locate_flow_and_stored_in_refusal(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "Trove",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 4,
"source": "version",
},
},
"2": {"bgg_id": 2, "name": "Witchdoctor", "stored_in": "1"},
"3": {
"bgg_id": 3,
"name": "Longboi",
"dims": {
"width_in": 11.5,
"length_in": 17,
"depth_in": 2.25,
"source": "version",
},
},
}
web, cfg = _web(tmp_path, games)
web.post("/api/furniture/add-unit", json={"name": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"rows": 1,
"cols": 2,
"zone": "",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
# a contained game refuses direct assignment, naming its container
res = web.post("/api/locate", json={"key": "2", "opening_id": opening})
assert res.status_code == 409 and "Trove" in res.json()["detail"]
# the container shelves; its contents ride along into the opening
assert (
web.post("/api/locate", json={"key": "1", "opening_id": opening}).status_code
== 200
)
state = web.get("/api/shelves").json()
resident = state["units"][0]["openings"][0]["resident_games"]
assert {g["name"] for g in resident} == {"Trove", "Witchdoctor"}
assert next(g for g in resident if g["name"] == "Witchdoctor")["inherited"]
# suggestions never offer an opening the game can't fit
unshelved = state["unshelved"]
longboi = next(g for g in unshelved if g["name"] == "Longboi")
assert longboi["suggestions"] == [] # 17" beats every 13.25" cube
# the library list knows where everything lives
lib = {g["name"]: g for g in web.get("/api/library").json()}
assert lib["Trove"]["location"]["text"] == "Den · A1"
assert lib["Witchdoctor"]["location"]["via"] == "1"
assert lib["Longboi"]["location"]["text"] == ""
# a virtual note without an opening reads as the location
web.post("/api/locate", json={"key": "3", "note": "lent to Sarah, June"})
lib = {g["name"]: g for g in web.get("/api/library").json()}
assert lib["Longboi"]["location"]["text"] == "lent to Sarah, June"
def test_shelves_page_serves_with_phone_sheet(tmp_path):
"""Phone smoke: the page serves, and the template carries the
bottom-sheet structure the mobile styles dock to the thumb."""
web, _ = _web(tmp_path, {})
html = web.get("/shelves").text
assert 'id="opensheet"' in html and 'id="sheetsearch"' in html
assert 'href="/shelves" aria-current="page"' in html
css = web.get("/static/app.css").text
assert "max-width: 900px" in css and ".sheetcard" in css