Files
bggpipe/tests/test_shelves.py
T
Eric WagonerandClaude Fable 5 6ab4a836b4 Audit round 9 lands: the location layer keeps its promises
Five blind reviewers over the day-old shelves layer, ~28 verified
findings — plus Eric's screenshot catching the biggest one live: the
generic .card is flex (built for review's photo-beside-ballot layout),
so unit headers shared a row with their grids and unshelved rows
flowed horizontally off the page. Shelf cards are now .card.stack.

The model fixes. Containment chains resolve recursively with a cycle
guard — minis inside an insert inside a big box live where the big box
does, instead of vanishing from every list; the stored-in endpoint
walks the whole chain when refusing cycles, and clears the newly
contained game's own shelf spot (one box must never consume capacity
in two openings). Contained games are listed residents but occupy no
shelf space: only physical boxes are stacked and fit-checked — the web
report now agrees with the dims report about the same opening. A
location pointing at a vanished opening (hand-edited or reverted
store) SURFACES as unshelved with a "shelf gone" chip in the app and
counts as homeless in the CLI, instead of hiding the game from every
list while the page declares everything has a home.

Honest edges. Opening dimensions are all-or-none everywhere (a
half-sized opening silently became limitless; the CLI report crashed
formatting it); a second grid on a unit continues the row letters so
labels stay unique and label-addressed CSV imports keep working, and
row letters survive past Z; CSV re-imports preserve hand-entered
notes; the ambiguity reject names the fix that actually works;
corrupt furniture/locations stores speak a 500 instead of a raw
traceback; the dims help text stops saying Kallax; DIM_AXES gets one
home in models.py instead of three drifting copies; the new stores
join CLAUDE.md's commit registry.

The page behaves. Custom-dims fields hide unless the custom preset is
chosen (typed values were silently discarded); the sheet is a real
dialog (role, aria-modal, Escape, focus return, one layer at a time);
backdrop close requires press AND release on the backdrop (a text-
selection drag out of the search box no longer dismisses); refresh
goes through changeGate and stops wiping the search mid-interaction;
the prompt() chain is an inline per-unit form with client-side
all-or-none validation; unit-create only toasts success after the
openings actually land, recovers from its own half-failures, and
guards against double-submit (a click retried against the re-rendering
DOM built a second grid — caught live in a Playwright run); warnings
speak (aria-labels on ⚠ and overfull in the fill bar's label);
unmeasured boxes are visible in cells and sheet rows; the library's
unshelved filter matches the Shelves page's definition; reorder
buttons have names; the detail locform wraps at phone width.

Eight new regression tests from the seats' sketches; 380 total.

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

620 lines
21 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
def test_stored_in_chain_resolves_to_the_outermost_box(tmp_path):
"""Minis inside an insert inside a big box live wherever the big box
does — a two-level chain must not vanish from the shelves page."""
games = {
"1": {
"bgg_id": 1,
"name": "Trove",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 4,
"source": "version",
},
},
"2": {"bgg_id": 2, "name": "Insert", "stored_in": "1"},
"3": {"bgg_id": 3, "name": "Minis", "stored_in": "2"},
}
web, cfg = _web(tmp_path, games)
web.post("/api/furniture/add-unit", json={"name": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"label": "A1",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
web.post("/api/locate", json={"key": "1", "opening_id": opening})
state = web.get("/api/shelves").json()
resident = state["units"][0]["openings"][0]["resident_games"]
assert {g["name"] for g in resident} == {"Trove", "Insert", "Minis"}
# only the physical box is stacked and counted
assert state["units"][0]["openings"][0]["games"] == 1
assert state["units"][0]["openings"][0]["stacked_in"] == 4
assert state["unshelved"] == []
# deep cycles refuse: Trove into Minis would close the loop
res = web.post("/api/stored-in", json={"key": "1", "container": "3"})
assert res.status_code == 400
def test_stored_in_clears_the_games_own_shelf_spot(tmp_path):
"""Shelve X, then declare X lives inside B: X's own location record
must go — one box must never consume capacity in two openings."""
games = {
"1": {
"bgg_id": 1,
"name": "Big Box",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 4,
"source": "version",
},
},
"2": {
"bgg_id": 2,
"name": "Little Box",
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
},
}
web, cfg = _web(tmp_path, games)
from bggpipe.resolve import MATCH_COLUMNS, write_matches
write_matches(
cfg.matches_path,
[
{
**dict.fromkeys(MATCH_COLUMNS, ""),
"title_raw": name,
"bgg_id": str(i),
"bgg_name": name,
"match_status": "approved",
}
for i, name in ((1, "Big Box"), (2, "Little Box"))
],
)
web.post("/api/furniture/add-unit", json={"name": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"rows": 1,
"cols": 2,
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
openings = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
web.post("/api/locate", json={"key": "1", "opening_id": openings[0]})
web.post("/api/locate", json={"key": "2", "opening_id": openings[1]})
assert (
web.post("/api/stored-in", json={"key": "2", "container": "1"}).status_code
== 200
)
locations = json.loads(cfg.locations_path.read_text())
assert "2" not in locations # its own spot is gone; it rides with Big Box
state = web.get("/api/shelves").json()
assert state["units"][0]["openings"][1]["games"] == 0
def test_delete_unit_clears_only_its_own_locations(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "A",
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
},
"2": {
"bgg_id": 2,
"name": "B",
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
},
}
web, cfg = _web(tmp_path, games)
for name in ("Den", "Loft"):
web.post("/api/furniture/add-unit", json={"name": name})
web.post(
"/api/furniture/add-openings",
json={
"unit": name,
"label": "A1",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
state = web.get("/api/shelves").json()
den = state["units"][0]["openings"][0]["id"]
loft = state["units"][1]["openings"][0]["id"]
web.post("/api/locate", json={"key": "1", "opening_id": den})
web.post("/api/locate", json={"key": "2", "opening_id": loft})
web.post("/api/furniture/delete-unit", json={"name": "Den"})
locations = json.loads(cfg.locations_path.read_text())
assert "1" not in locations and locations["2"]["opening_id"] == loft
state = web.get("/api/shelves").json()
assert [g["name"] for g in state["unshelved"]] == ["A"]
def test_locate_clearing_both_fields_deletes_the_record(tmp_path):
games = {"1": {"bgg_id": 1, "name": "A"}}
web, cfg = _web(tmp_path, games)
web.post("/api/locate", json={"key": "1", "note": "lent out"})
assert json.loads(cfg.locations_path.read_text())["1"]["note"] == "lent out"
web.post("/api/locate", json={"key": "1", "opening_id": "", "note": ""})
assert "1" not in json.loads(cfg.locations_path.read_text())
def test_ghost_opening_assignment_surfaces_as_unshelved(tmp_path):
"""A location pointing at a vanished opening (hand-edited or reverted
store) must SHOW — silently disappearing from every list is how a
game gets lost for real."""
from bggpipe.shelves import save_locations
games = {"1": {"bgg_id": 1, "name": "Ghosted"}}
web, cfg = _web(tmp_path, games)
save_locations(cfg, {"1": {"opening_id": "gone-a9", "note": ""}})
state = web.get("/api/shelves").json()
(row,) = state["unshelved"]
assert row["name"] == "Ghosted" and row["lost_home"] is True
def test_partial_dims_refuse_everywhere(tmp_path):
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Den"})
res = web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"label": "half",
"width_in": 13.0,
},
)
assert res.status_code == 400
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"label": "whole",
"width_in": 13.0,
"height_in": 13.0,
"depth_in": 15.0,
},
)
oid = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
assert (
web.post(
"/api/furniture/edit-opening",
json={
"id": oid,
"height_in": 14.0,
},
).status_code
== 400
) # partial edit refused
# all three blanks makes it virtual, deliberately
web.post("/api/furniture/edit-opening", json={"id": oid})
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]
assert opening["width_in"] is None
def test_second_grid_continues_row_letters(tmp_path):
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Wall"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 2,
"cols": 2,
"width_in": 13.0,
"height_in": 13.0,
"depth_in": 15.0,
},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 1,
"cols": 2,
"width_in": 26.5,
"height_in": 13.0,
"depth_in": 15.0,
},
)
labels = [
o["label"] for o in web.get("/api/shelves").json()["units"][0]["openings"]
]
assert labels == ["A1", "A2", "B1", "B2", "C1", "C2"]
assert len(set(labels)) == 6 # unique: label-addressed CSVs stay usable
def test_move_opening_boundaries_no_op(tmp_path):
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Den"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Den",
"rows": 1,
"cols": 2,
"width_in": 13.0,
"height_in": 13.0,
"depth_in": 15.0,
},
)
ids = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
web.post("/api/furniture/move-opening", json={"id": ids[0], "direction": -1})
after = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
assert after == ids # first can't move earlier: quiet no-op