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
This commit is contained in:
co-authored by
Claude Fable 5
parent
4f446f6f2a
commit
6ab4a836b4
@@ -360,3 +360,260 @@ def test_shelves_page_serves_with_phone_sheet(tmp_path):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user