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
This commit is contained in:
co-authored by
Claude Fable 5
parent
e85f72c546
commit
4f446f6f2a
@@ -0,0 +1,362 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user