Units draw their real arrangement; suggestions learn the owner's plan

Three requests from Eric plus his reviewer's ranking doctrine.

The unit renders its PHYSICAL shape: openings group into rows by
their label letters and each cell's width is proportional to its
interior width — the Kallax reads as two double-wides spanning the
top, then four-across cube rows. A diagram of the wall, not a list.

The toggle buttons died: "Add a unit" and "Add an opening" are
labeled, always-visible forms — the button that merely revealed the
form below it was ceremony.

Suggestions got their relevance doctrine, three rules deep:
(1) Reunification beats geometry — an opening holding a series-mate
or base game (kinship = the normalized pre-colon name stem, plus the
series field where enrich has one; no taxonomy invented) ranks first,
marked ♥. Every placement the owner confirms teaches the suggester
their organization by example; expansions chase their base games
automatically during the move. (2) "Fits" means the REMAINING
opening: the stack budget already spent is subtracted before
offering, so the suggester's honesty holds precisely as shelves fill
— the direction it used to degrade. (3) Then tightest verified fit,
so a small box is offered cubes and never the oversize row. Nothing
matching says "no matching openings yet" instead of offering
everything the wall has.

One composed test pins the interplay: a full shelf isn't offered even
to a series-mate (honesty outranks reunification), and freeing the
space flips the same suggestion to ♥-first. 382 tests.

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:20:16 -04:00
co-authored by Claude Fable 5
parent 446579e96c
commit f4f3877651
5 changed files with 232 additions and 42 deletions
+146
View File
@@ -617,3 +617,149 @@ def test_move_opening_boundaries_no_op(tmp_path):
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
def test_suggestions_prefer_the_tightest_verified_fit(tmp_path):
"""The relevance gate: a small box is offered cubes, never the
oversize row; only a box too big for cubes gets the double-wide."""
games = {
"1": {
"bgg_id": 1,
"name": "Small",
"dims": {
"width_in": 10,
"length_in": 10,
"depth_in": 2,
"source": "version",
},
},
"2": {
"bgg_id": 2,
"name": "Wide Boi",
"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": "Wall"})
# oversize row FIRST in list order — the gate must not care
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"label": "A1",
"zone": "oversize",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 1,
"cols": 2,
"zone": "cubes",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
state = web.get("/api/shelves").json()
ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]}
by_name = {g["name"]: g for g in state["unshelved"]}
# the small box: cubes first (tightest fit), the wide never leads
assert by_name["Small"]["suggestions"][0]["id"] in (ids["B1"], ids["B2"])
# the 17" box fits ONLY the double-wide
assert [s["id"] for s in by_name["Wide Boi"]["suggestions"]] == [ids["A1"]]
def test_reunification_outranks_tightest_fit_and_full_shelves_stop_lying(tmp_path):
"""The two ranking rules together: an opening holding a series-mate
ranks above a geometrically tighter empty cube, and an opening whose
stack budget is spent stops being offered at all."""
games = {
"base": {
"bgg_id": 1,
"name": "Castle Panic",
"dims": {
"width_in": 10,
"length_in": 10,
"depth_in": 3,
"source": "version",
},
},
"exp": {
"bgg_id": 2,
"name": "Castle Panic: The Wizard's Tower",
"dims": {
"width_in": 10,
"length_in": 10,
"depth_in": 2,
"source": "version",
},
},
"fat": {
"bgg_id": 3,
"name": "Shelf Hog",
"dims": {
"width_in": 12,
"length_in": 12,
"depth_in": 11,
"source": "version",
},
},
}
web, cfg = _web(tmp_path, games)
web.post("/api/furniture/add-unit", json={"name": "Wall"})
# C1 (holds the base game), C2 (empty, geometrically identical),
# and a BIGGER wide opening — reunification must beat both
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"rows": 1,
"cols": 2,
"zone": "cubes",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Wall",
"label": "wide",
"zone": "oversize",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
},
)
state = web.get("/api/shelves").json()
ids = {o["label"]: o["id"] for o in state["units"][0]["openings"]}
web.post("/api/locate", json={"key": "base", "opening_id": ids["A1"]})
# nearly fill A1: the 11"-thick hog leaves ~0 budget behind the base
web.post("/api/locate", json={"key": "fat", "opening_id": ids["A1"]})
state = web.get("/api/shelves").json()
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
# A1 holds the series-mate BUT its remaining height (13.25 - 3 - 11 < 2)
# can't take the expansion: honesty beats reunification, so the empty
# twin cube leads and A1 is not offered at all
offered = [s["id"] for s in exp["suggestions"]]
assert ids["A1"] not in offered
assert offered[0] == ids["A2"]
# free the space: reunification now wins over the identical empty cube
web.post("/api/locate", json={"key": "fat", "opening_id": ids["wide"]})
state = web.get("/api/shelves").json()
exp = next(g for g in state["unshelved"] if g["name"].endswith("Tower"))
first = exp["suggestions"][0]
assert first["id"] == ids["A1"] and first["reunites"] is True