diff --git a/data/furniture.json b/data/furniture.json
index 19c2710..b509381 100644
--- a/data/furniture.json
+++ b/data/furniture.json
@@ -22,7 +22,7 @@
{
"id": "library-kallax-b1",
"label": "B1",
- "zone": "cubes",
+ "zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -30,7 +30,7 @@
{
"id": "library-kallax-b2",
"label": "B2",
- "zone": "cubes",
+ "zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -38,7 +38,7 @@
{
"id": "library-kallax-b3",
"label": "B3",
- "zone": "cubes",
+ "zone": "party",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -46,7 +46,7 @@
{
"id": "library-kallax-b4",
"label": "B4",
- "zone": "cubes",
+ "zone": "two-player",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -54,7 +54,7 @@
{
"id": "library-kallax-c1",
"label": "C1",
- "zone": "cubes",
+ "zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -62,7 +62,7 @@
{
"id": "library-kallax-c2",
"label": "C2",
- "zone": "cubes",
+ "zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -70,7 +70,7 @@
{
"id": "library-kallax-c3",
"label": "C3",
- "zone": "cubes",
+ "zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -78,7 +78,7 @@
{
"id": "library-kallax-c4",
"label": "C4",
- "zone": "cubes",
+ "zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -86,7 +86,7 @@
{
"id": "library-kallax-d1",
"label": "D1",
- "zone": "cubes",
+ "zone": "general",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -94,7 +94,7 @@
{
"id": "library-kallax-d2",
"label": "D2",
- "zone": "cubes",
+ "zone": "vintage wargames",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
@@ -102,7 +102,7 @@
{
"id": "library-kallax-d3",
"label": "D3",
- "zone": "cubes",
+ "zone": "heavy",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4
diff --git a/src/bggpipe/templates/pages/shelves.html b/src/bggpipe/templates/pages/shelves.html
index 8ecd073..869ecd6 100644
--- a/src/bggpipe/templates/pages/shelves.html
+++ b/src/bggpipe/templates/pages/shelves.html
@@ -48,8 +48,10 @@
-
-
+
+
diff --git a/src/bggpipe/webreview.py b/src/bggpipe/webreview.py
index 241c035..694dc46 100644
--- a/src/bggpipe/webreview.py
+++ b/src/bggpipe/webreview.py
@@ -20,6 +20,7 @@ import hashlib
import io
import json
import os
+import re
import secrets
import socket
import threading
@@ -1329,14 +1330,25 @@ def create_app(
@app.post("/api/furniture/move-opening")
def api_move_opening(body: OpeningMoveBody) -> dict:
+ """Move an opening within its ROW. The rendered layout groups
+ rows by label letter, so a move across a letter boundary would
+ fragment the wall diagram (A1, B1, A2 renders as three rows) —
+ the move clamps there instead."""
+
+ def row_of(label: str) -> str:
+ m = re.match(r"^([A-Za-z]+)\d+$", label or "")
+ return m.group(1).upper() if m else f"~{label}"
+
def move(units):
for unit in units:
ids = [o["id"] for o in unit["openings"]]
if body.id in ids:
+ ops = unit["openings"]
i = ids.index(body.id)
j = i + (1 if body.direction > 0 else -1)
- if 0 <= j < len(unit["openings"]):
- ops = unit["openings"]
+ if 0 <= j < len(ops) and row_of(ops[i]["label"]) == row_of(
+ ops[j]["label"]
+ ):
ops[i], ops[j] = ops[j], ops[i]
return
raise HTTPException(404, "no such opening")
diff --git a/tests/test_shelves.py b/tests/test_shelves.py
index 69b1ce8..44038d7 100644
--- a/tests/test_shelves.py
+++ b/tests/test_shelves.py
@@ -268,16 +268,19 @@ def test_acceptance_full_furniture_flow_without_touching_json(tmp_path):
assert saved[0]["openings"][0]["label"] == "wide left"
assert saved[-1]["openings"][0].get("width_in") is None # virtual
- # editable, deletable, reorderable
+ # editable, deletable, reorderable (within a row — moves clamp at
+ # row-letter boundaries so the wall diagram can't fragment)
first_cube = saved[0]["openings"][2]["id"]
+ second_cube = saved[0]["openings"][3]["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"]}
- )
+ saved_mid = json.loads(cfg.furniture_path.read_text())["units"]
+ assert saved_mid[0]["openings"][2]["id"] == first_cube # clamped: row edge
+ web.post("/api/furniture/move-opening", json={"id": second_cube, "direction": -1})
+ web.post("/api/furniture/delete-opening", json={"id": first_cube})
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
+ assert saved2[0]["openings"][2]["id"] == second_cube # swapped within row
def test_locate_flow_and_stored_in_refusal(tmp_path):
@@ -599,6 +602,40 @@ def test_second_grid_continues_row_letters(tmp_path):
assert len(set(labels)) == 6 # unique: label-addressed CSVs stay usable
+def test_move_clamps_at_row_boundaries(tmp_path):
+ """A move must never fragment a row: B1 cannot cross above A2 (the
+ renderer would draw A, B, A as three rows), but B2 and B1 can swap."""
+ 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,
+ },
+ )
+
+ def labels():
+ return [
+ o["label"] for o in web.get("/api/shelves").json()["units"][0]["openings"]
+ ]
+
+ ids = {
+ o["label"]: o["id"]
+ for o in web.get("/api/shelves").json()["units"][0]["openings"]
+ }
+ # cross-row move clamps: B1 stays put
+ web.post("/api/furniture/move-opening", json={"id": ids["B1"], "direction": -1})
+ assert labels() == ["A1", "A2", "B1", "B2"]
+ # within-row move works: B2 left of B1
+ web.post("/api/furniture/move-opening", json={"id": ids["B2"], "direction": -1})
+ assert labels() == ["A1", "A2", "B2", "B1"]
+
+
def test_move_opening_boundaries_no_op(tmp_path):
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Den"})