Duplicate a unit: describe the furniture once, stamp out the rest

Eric's ask. duplicate-unit clones the STRUCTURE — openings with their
sizes, zones, and descriptions, under fresh ids and the first free
"<name> 2"-style name — never the game assignments. And because a
copy immediately wants a real name, units gained rename: opening ids
are stable through it so locations and export URLs never notice, and
a rename-only edit no longer risks wiping the description (the body
field learned the None-means-leave-alone convention). Tests pin the
disjoint ids, the empty copy, the numbering past taken names, and
the wipe-nothing rename.

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 15:10:47 -04:00
co-authored by Claude Fable 5
parent e90a53fb6c
commit 10f99e23f5
6 changed files with 224 additions and 5 deletions
+103
View File
@@ -875,3 +875,106 @@ def test_unit_descriptions_round_trip(tmp_path):
saved = _json.loads(cfg.furniture_path.read_text())["units"][0]
assert saved["description"] == "now in the study"
def test_duplicate_unit_copies_structure_never_games(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "Catan",
"dims": {
"width_in": 11,
"length_in": 11,
"depth_in": 3,
"source": "version",
},
}
}
web, cfg = _web(tmp_path, games)
web.post(
"/api/furniture/add-unit",
json={"name": "Kallax", "description": "the original"},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Kallax",
"rows": 1,
"cols": 2,
"zone": "cubes",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
first = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
web.post(
"/api/furniture/edit-opening", json={"id": first, "description": "top left"}
)
web.post("/api/locate", json={"key": "1", "opening_id": first})
web.post("/api/furniture/duplicate-unit", json={"name": "Kallax"})
state = web.get("/api/shelves").json()
names = [u["name"] for u in state["units"]]
assert names == ["Kallax", "Kallax 2"]
copy = state["units"][1]
assert copy["description"] == "the original"
assert [o["label"] for o in copy["openings"]] == ["A1", "A2"]
assert copy["openings"][0]["description"] == "top left"
# fresh ids, no residents copied
original_ids = {o["id"] for o in state["units"][0]["openings"]}
copy_ids = {o["id"] for o in copy["openings"]}
assert original_ids.isdisjoint(copy_ids)
assert all(o["games"] == 0 for o in copy["openings"])
# duplicating again numbers past the taken name
web.post("/api/furniture/duplicate-unit", json={"name": "Kallax"})
names = [u["name"] for u in web.get("/api/shelves").json()["units"]]
assert names == ["Kallax", "Kallax 2", "Kallax 3"]
def test_rename_unit_keeps_openings_locations_and_description(tmp_path):
games = {
"1": {
"bgg_id": 1,
"name": "Catan",
"dims": {
"width_in": 11,
"length_in": 11,
"depth_in": 3,
"source": "version",
},
}
}
web, cfg = _web(tmp_path, games)
web.post(
"/api/furniture/add-unit", json={"name": "Kallax", "description": "keep me"}
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Kallax",
"label": "A1",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
oid = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
web.post("/api/locate", json={"key": "1", "opening_id": oid})
web.post(
"/api/furniture/edit-unit", json={"name": "Kallax", "new_name": "Den wall"}
)
state = web.get("/api/shelves").json()
unit = state["units"][0]
assert unit["name"] == "Den wall"
assert unit["description"] == "keep me" # rename-only edit wipes nothing
assert unit["openings"][0]["games"] == 1 # opening ids stable: game stays
# rename onto a taken name refuses
web.post("/api/furniture/add-unit", json={"name": "Loft"})
assert (
web.post(
"/api/furniture/edit-unit",
json={"name": "Den wall", "new_name": "Loft"},
).status_code
== 409
)