Opening moves become row-honest: ← → within the row, clamped at edges

Eric asked what the ↑↓ buttons did — the honest answer was "reorder a
flat list that no longer exists, and fragment your wall diagram if
pushed across a row boundary" (A1, B1, A2 renders as THREE rows under
the letter-grouped layout). The useful half survives as ← → "move
within its row"; the endpoint clamps at row-letter boundaries so the
diagram can't fragment. Tests pin both the clamp and the legitimate
within-row swap; the acceptance flow's reorder step updated to match
the new semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-09 14:28:15 -04:00
co-authored by Claude Fable 5
parent 75162c3e2e
commit a23b97d2f9
4 changed files with 71 additions and 20 deletions
+11 -11
View File
@@ -22,7 +22,7 @@
{ {
"id": "library-kallax-b1", "id": "library-kallax-b1",
"label": "B1", "label": "B1",
"zone": "cubes", "zone": "party",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -30,7 +30,7 @@
{ {
"id": "library-kallax-b2", "id": "library-kallax-b2",
"label": "B2", "label": "B2",
"zone": "cubes", "zone": "party",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -38,7 +38,7 @@
{ {
"id": "library-kallax-b3", "id": "library-kallax-b3",
"label": "B3", "label": "B3",
"zone": "cubes", "zone": "party",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -46,7 +46,7 @@
{ {
"id": "library-kallax-b4", "id": "library-kallax-b4",
"label": "B4", "label": "B4",
"zone": "cubes", "zone": "two-player",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -54,7 +54,7 @@
{ {
"id": "library-kallax-c1", "id": "library-kallax-c1",
"label": "C1", "label": "C1",
"zone": "cubes", "zone": "general",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -62,7 +62,7 @@
{ {
"id": "library-kallax-c2", "id": "library-kallax-c2",
"label": "C2", "label": "C2",
"zone": "cubes", "zone": "general",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -70,7 +70,7 @@
{ {
"id": "library-kallax-c3", "id": "library-kallax-c3",
"label": "C3", "label": "C3",
"zone": "cubes", "zone": "general",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -78,7 +78,7 @@
{ {
"id": "library-kallax-c4", "id": "library-kallax-c4",
"label": "C4", "label": "C4",
"zone": "cubes", "zone": "general",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -86,7 +86,7 @@
{ {
"id": "library-kallax-d1", "id": "library-kallax-d1",
"label": "D1", "label": "D1",
"zone": "cubes", "zone": "general",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -94,7 +94,7 @@
{ {
"id": "library-kallax-d2", "id": "library-kallax-d2",
"label": "D2", "label": "D2",
"zone": "cubes", "zone": "vintage wargames",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
@@ -102,7 +102,7 @@
{ {
"id": "library-kallax-d3", "id": "library-kallax-d3",
"label": "D3", "label": "D3",
"zone": "cubes", "zone": "heavy",
"width_in": 13.25, "width_in": 13.25,
"height_in": 13.25, "height_in": 13.25,
"depth_in": 15.4 "depth_in": 15.4
+4 -2
View File
@@ -48,8 +48,10 @@
<label>D <input name="depth_in" size="5"></label> <label>D <input name="depth_in" size="5"></label>
<span class="editactions"> <span class="editactions">
<button type="submit" class="primary">save</button> <button type="submit" class="primary">save</button>
<button type="button" id="openingup" aria-label="move opening earlier"></button> <button type="button" id="openingup"
<button type="button" id="openingdown" aria-label="move opening later"></button> aria-label="move opening left within its row" title="move left in its row"></button>
<button type="button" id="openingdown"
aria-label="move opening right within its row" title="move right in its row"></button>
<button type="button" id="openingdelete" class="danger">delete opening</button> <button type="button" id="openingdelete" class="danger">delete opening</button>
</span> </span>
</form> </form>
+14 -2
View File
@@ -20,6 +20,7 @@ import hashlib
import io import io
import json import json
import os import os
import re
import secrets import secrets
import socket import socket
import threading import threading
@@ -1329,14 +1330,25 @@ def create_app(
@app.post("/api/furniture/move-opening") @app.post("/api/furniture/move-opening")
def api_move_opening(body: OpeningMoveBody) -> dict: 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): def move(units):
for unit in units: for unit in units:
ids = [o["id"] for o in unit["openings"]] ids = [o["id"] for o in unit["openings"]]
if body.id in ids: if body.id in ids:
ops = unit["openings"]
i = ids.index(body.id) i = ids.index(body.id)
j = i + (1 if body.direction > 0 else -1) j = i + (1 if body.direction > 0 else -1)
if 0 <= j < len(unit["openings"]): if 0 <= j < len(ops) and row_of(ops[i]["label"]) == row_of(
ops = unit["openings"] ops[j]["label"]
):
ops[i], ops[j] = ops[j], ops[i] ops[i], ops[j] = ops[j], ops[i]
return return
raise HTTPException(404, "no such opening") raise HTTPException(404, "no such opening")
+42 -5
View File
@@ -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[0]["openings"][0]["label"] == "wide left"
assert saved[-1]["openings"][0].get("width_in") is None # virtual 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"] 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/edit-opening", json={"id": first_cube, "zone": "kids"})
web.post("/api/furniture/move-opening", json={"id": first_cube, "direction": -1}) web.post("/api/furniture/move-opening", json={"id": first_cube, "direction": -1})
web.post( saved_mid = json.loads(cfg.furniture_path.read_text())["units"]
"/api/furniture/delete-opening", json={"id": saved[0]["openings"][3]["id"]} 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"] saved2 = json.loads(cfg.furniture_path.read_text())["units"]
assert len(saved2[0]["openings"]) == 13 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): 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 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): def test_move_opening_boundaries_no_op(tmp_path):
web, cfg = _web(tmp_path, {}) web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Den"}) web.post("/api/furniture/add-unit", json={"name": "Den"})