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:
co-authored by
Claude Fable 5
parent
446579e96c
commit
f4f3877651
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user