Eric's ask. Free text on any opening ("tall bookcase by the window —
kids reach the bottom rows"), edited in the opening settings sheet,
shown under the sheet's title, and surfaced as the cell's hover title
on the wall diagram. Stored in furniture.json like everything else.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
823 lines
28 KiB
Python
823 lines
28 KiB
Python
"""The location layer: stores, fit epistemics, inheritance, CSV import."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
import typer
|
|
|
|
from bggpipe.config import Config
|
|
from bggpipe.shelves import (
|
|
effective_location,
|
|
fits_opening,
|
|
import_assignments,
|
|
load_furniture,
|
|
new_opening_id,
|
|
opening_report,
|
|
save_furniture,
|
|
)
|
|
|
|
|
|
def _entry(name="Game", w=11.6, length=11.6, d=2.8, **extra):
|
|
dims = (
|
|
{"width_in": w, "length_in": length, "depth_in": d, "source": "version"}
|
|
if w
|
|
else {"width_in": None, "length_in": None, "depth_in": None, "source": "absent"}
|
|
)
|
|
return {"name": name, "dims": dims, **extra}
|
|
|
|
|
|
KALLAX = {
|
|
"id": "k1",
|
|
"label": "A1",
|
|
"zone": "party",
|
|
"width_in": 13.25,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
}
|
|
DOUBLE_WIDE = {
|
|
"id": "dw",
|
|
"label": "wide top",
|
|
"zone": "big boxes",
|
|
"width_in": 26.5,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
}
|
|
TRAVEL = {"id": "travel", "label": "travel case", "zone": ""}
|
|
|
|
|
|
def test_fits_opening_speaks_the_house_language():
|
|
assert fits_opening(_entry(), KALLAX) is True
|
|
# 17" box: fails the cube, fits the double-wide — the acceptance case
|
|
big = _entry("Bugs in the Kitchen", 11.5, 17, 2.25)
|
|
assert fits_opening(big, KALLAX) is False
|
|
assert fits_opening(big, DOUBLE_WIDE) is True
|
|
# no game dims -> can't verify, never "fits"
|
|
assert fits_opening(_entry("Mystery", w=None), KALLAX) is None
|
|
# a virtual location imposes no limits
|
|
assert fits_opening(big, TRAVEL) is None
|
|
|
|
|
|
def test_opening_report_counts_stack_and_warns():
|
|
thin = _entry("Thin", 11, 11, 2)
|
|
thick = _entry("Thick", 11, 11, 4)
|
|
unmeasured = _entry("Mystery", w=None)
|
|
report = opening_report(KALLAX, [("a", thin), ("b", thick), ("c", unmeasured)])
|
|
assert report["games"] == 3
|
|
assert report["stacked_in"] == 6
|
|
assert report["unmeasured"] == 1
|
|
assert report["overfull"] is False
|
|
# seven thick boxes overflow a 13.25" interior
|
|
stack = [(str(i), thick) for i in range(7)]
|
|
assert opening_report(KALLAX, stack)["overfull"] is True
|
|
# a misfit is named even when the stack has room
|
|
report = opening_report(KALLAX, [("a", _entry("Too Long", 11.5, 17, 2.25))])
|
|
assert report["misfits"] == ["Too Long"]
|
|
|
|
|
|
def test_effective_location_inherits_from_container():
|
|
games = {
|
|
"173634": _entry("Trove", bgg_id=173634),
|
|
"999": _entry("Witchdoctor", bgg_id=999, stored_in="173634"),
|
|
"13": _entry("Catan", bgg_id=13),
|
|
}
|
|
locations = {"173634": {"opening_id": "k1", "note": "top shelf"}}
|
|
oid, note, via = effective_location("999", games["999"], games, locations)
|
|
assert (oid, note, via) == ("k1", "top shelf", "173634")
|
|
# the container itself reads its own record
|
|
assert effective_location("173634", games["173634"], games, locations)[2] is None
|
|
# unassigned, uncontained: unshelved
|
|
assert effective_location("13", games["13"], games, locations) == (None, "", None)
|
|
|
|
|
|
def test_opening_ids_are_stable_and_unique():
|
|
units = [
|
|
{
|
|
"name": "Den Kallax",
|
|
"openings": [{"id": "den-kallax-a1", "label": "A1", "zone": ""}],
|
|
}
|
|
]
|
|
assert new_opening_id(units, "Den Kallax", "A2") == "den-kallax-a2"
|
|
assert new_opening_id(units, "Den Kallax", "A1") == "den-kallax-a1-2"
|
|
|
|
|
|
def test_furniture_round_trip_and_corrupt_store(tmp_path):
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
cfg.data_dir.mkdir(parents=True)
|
|
units = [{"name": "Den", "openings": [KALLAX, DOUBLE_WIDE]}]
|
|
save_furniture(cfg, units)
|
|
assert load_furniture(cfg) == units
|
|
cfg.furniture_path.write_text("{torn")
|
|
with pytest.raises(ValueError, match="furniture.json is corrupt"):
|
|
load_furniture(cfg)
|
|
|
|
|
|
def _import_cfg(tmp_path):
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
cfg.data_dir.mkdir(parents=True)
|
|
cfg.games_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"1": _entry("Catan", 11.6, 11.6, 3, bgg_id=1),
|
|
"2": _entry("Bugs in the Kitchen", 11.5, 17, 2.25, bgg_id=2),
|
|
"3": _entry("Witchdoctor", 8, 8, 2, bgg_id=3, stored_in="1"),
|
|
"4a": _entry("Twin", 8, 8, 2, bgg_id=40),
|
|
"4b": _entry("Twin", 8, 8, 2, bgg_id=41),
|
|
}
|
|
)
|
|
)
|
|
save_furniture(
|
|
cfg,
|
|
[
|
|
{"name": "Den Kallax", "openings": [KALLAX]},
|
|
{"name": "Loft", "openings": [DOUBLE_WIDE, TRAVEL]},
|
|
],
|
|
)
|
|
return cfg
|
|
|
|
|
|
def test_csv_import_assigns_and_rejects_honestly(tmp_path, capsys):
|
|
cfg = _import_cfg(tmp_path)
|
|
plan = tmp_path / "plan.csv"
|
|
plan.write_text(
|
|
"name,opening\n"
|
|
"Catan,A1\n" # by label
|
|
"Bugs in the Kitchen,dw\n" # by id, needs the double-wide
|
|
"Bugs in the Kitchen,A1\n" # doesn't fit the cube: reject
|
|
"Witchdoctor,A1\n" # contained: reject
|
|
"Twin,A1\n" # two copies: reject
|
|
"Ghost Game,A1\n" # unknown name: reject
|
|
"Catan,Z9\n" # unknown opening: reject
|
|
)
|
|
result = import_assignments(cfg, plan)
|
|
assert result["assigned"] == 2
|
|
assert len(result["rejects"]) == 5
|
|
saved = json.loads(cfg.locations_path.read_text())
|
|
assert saved["1"]["opening_id"] == "k1"
|
|
assert saved["2"]["opening_id"] == "dw"
|
|
out = capsys.readouterr().out
|
|
assert "doesn't fit" in out and "lives inside another box" in out
|
|
assert "matches 2 copies" in out and "no game by that name" in out
|
|
|
|
|
|
def test_import_without_furniture_exits_with_guidance(tmp_path):
|
|
cfg = Config(data_dir=tmp_path / "data")
|
|
cfg.data_dir.mkdir(parents=True)
|
|
cfg.games_path.write_text("{}")
|
|
plan = tmp_path / "plan.csv"
|
|
plan.write_text("name,opening\n")
|
|
with pytest.raises(typer.Exit):
|
|
import_assignments(cfg, plan)
|
|
|
|
|
|
# -- web layer ----------------------------------------------------------
|
|
|
|
|
|
def _web(tmp_path, games):
|
|
import httpx
|
|
from fastapi.testclient import TestClient
|
|
|
|
from bggpipe.bgg_client import BGGClient
|
|
from bggpipe.resolve import write_matches
|
|
from bggpipe.webreview import create_app
|
|
|
|
cfg = Config(data_dir=tmp_path / "data", photos_dir=tmp_path / "photos")
|
|
cfg.photos_dir.mkdir(parents=True)
|
|
cfg.data_dir.mkdir(parents=True)
|
|
write_matches(cfg.matches_path, [])
|
|
cfg.games_path.write_text(json.dumps(games))
|
|
client = BGGClient(
|
|
cache_dir=tmp_path / "no_cache",
|
|
transport=httpx.MockTransport(
|
|
lambda req: httpx.Response(401, text="Unauthorized")
|
|
),
|
|
)
|
|
return TestClient(create_app(cfg, client=client)), cfg
|
|
|
|
|
|
def test_acceptance_full_furniture_flow_without_touching_json(tmp_path):
|
|
"""The spec's bar: two double-wide openings above three rows of four
|
|
cubes, plus two bookcases of shelves, plus a travel case — expressed
|
|
entirely through the endpoints the UI calls."""
|
|
web, cfg = _web(tmp_path, {})
|
|
|
|
assert (
|
|
web.post("/api/furniture/add-unit", json={"name": "Den wall"}).status_code
|
|
== 200
|
|
)
|
|
# two double-wides, one at a time
|
|
for label in ("wide left", "wide right"):
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den wall",
|
|
"label": label,
|
|
"zone": "big boxes",
|
|
"width_in": 26.5,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
)
|
|
# three rows of four cubes as a grid
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den wall",
|
|
"rows": 3,
|
|
"cols": 4,
|
|
"zone": "party",
|
|
"width_in": 13.25,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
)
|
|
for name in ("Bookcase north", "Bookcase south"):
|
|
web.post("/api/furniture/add-unit", json={"name": name})
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": name,
|
|
"rows": 5,
|
|
"cols": 1,
|
|
"zone": "long games",
|
|
"width_in": 30.75,
|
|
"height_in": 13,
|
|
"depth_in": 11,
|
|
},
|
|
)
|
|
web.post("/api/furniture/add-unit", json={"name": "Travel"})
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Travel",
|
|
"label": "travel case",
|
|
},
|
|
)
|
|
|
|
state = web.get("/api/shelves").json()
|
|
counts = {u["name"]: len(u["openings"]) for u in state["units"]}
|
|
assert counts == {
|
|
"Den wall": 14,
|
|
"Bookcase north": 5,
|
|
"Bookcase south": 5,
|
|
"Travel": 1,
|
|
}
|
|
# the store round-trips as plain committed JSON
|
|
saved = json.loads(cfg.furniture_path.read_text())["units"]
|
|
assert saved[0]["openings"][0]["label"] == "wide left"
|
|
assert saved[-1]["openings"][0].get("width_in") is None # virtual
|
|
|
|
# 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})
|
|
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"][2]["id"] == second_cube # swapped within row
|
|
|
|
|
|
def test_locate_flow_and_stored_in_refusal(tmp_path):
|
|
games = {
|
|
"1": {
|
|
"bgg_id": 1,
|
|
"name": "Trove",
|
|
"dims": {
|
|
"width_in": 12,
|
|
"length_in": 12,
|
|
"depth_in": 4,
|
|
"source": "version",
|
|
},
|
|
},
|
|
"2": {"bgg_id": 2, "name": "Witchdoctor", "stored_in": "1"},
|
|
"3": {
|
|
"bgg_id": 3,
|
|
"name": "Longboi",
|
|
"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": "Den"})
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den",
|
|
"rows": 1,
|
|
"cols": 2,
|
|
"zone": "",
|
|
"width_in": 13.25,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
)
|
|
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
|
|
|
|
# a contained game refuses direct assignment, naming its container
|
|
res = web.post("/api/locate", json={"key": "2", "opening_id": opening})
|
|
assert res.status_code == 409 and "Trove" in res.json()["detail"]
|
|
|
|
# the container shelves; its contents ride along into the opening
|
|
assert (
|
|
web.post("/api/locate", json={"key": "1", "opening_id": opening}).status_code
|
|
== 200
|
|
)
|
|
state = web.get("/api/shelves").json()
|
|
resident = state["units"][0]["openings"][0]["resident_games"]
|
|
assert {g["name"] for g in resident} == {"Trove", "Witchdoctor"}
|
|
assert next(g for g in resident if g["name"] == "Witchdoctor")["inherited"]
|
|
|
|
# suggestions never offer an opening the game can't fit
|
|
unshelved = state["unshelved"]
|
|
longboi = next(g for g in unshelved if g["name"] == "Longboi")
|
|
assert longboi["suggestions"] == [] # 17" beats every 13.25" cube
|
|
|
|
# the library list knows where everything lives
|
|
lib = {g["name"]: g for g in web.get("/api/library").json()}
|
|
assert lib["Trove"]["location"]["text"] == "Den · A1"
|
|
assert lib["Witchdoctor"]["location"]["via"] == "1"
|
|
assert lib["Longboi"]["location"]["text"] == ""
|
|
|
|
# a virtual note without an opening reads as the location
|
|
web.post("/api/locate", json={"key": "3", "note": "lent to Sarah, June"})
|
|
lib = {g["name"]: g for g in web.get("/api/library").json()}
|
|
assert lib["Longboi"]["location"]["text"] == "lent to Sarah, June"
|
|
|
|
|
|
def test_shelves_page_serves_with_phone_sheet(tmp_path):
|
|
"""Phone smoke: the page serves, and the template carries the
|
|
bottom-sheet structure the mobile styles dock to the thumb."""
|
|
web, _ = _web(tmp_path, {})
|
|
html = web.get("/shelves").text
|
|
assert 'id="opensheet"' in html and 'id="sheetsearch"' in html
|
|
assert 'href="/shelves" aria-current="page"' in html
|
|
css = web.get("/static/app.css").text
|
|
assert "max-width: 900px" in css and ".sheetcard" in css
|
|
|
|
|
|
def test_stored_in_chain_resolves_to_the_outermost_box(tmp_path):
|
|
"""Minis inside an insert inside a big box live wherever the big box
|
|
does — a two-level chain must not vanish from the shelves page."""
|
|
games = {
|
|
"1": {
|
|
"bgg_id": 1,
|
|
"name": "Trove",
|
|
"dims": {
|
|
"width_in": 12,
|
|
"length_in": 12,
|
|
"depth_in": 4,
|
|
"source": "version",
|
|
},
|
|
},
|
|
"2": {"bgg_id": 2, "name": "Insert", "stored_in": "1"},
|
|
"3": {"bgg_id": 3, "name": "Minis", "stored_in": "2"},
|
|
}
|
|
web, cfg = _web(tmp_path, games)
|
|
web.post("/api/furniture/add-unit", json={"name": "Den"})
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den",
|
|
"label": "A1",
|
|
"width_in": 13.25,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
)
|
|
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
|
|
web.post("/api/locate", json={"key": "1", "opening_id": opening})
|
|
state = web.get("/api/shelves").json()
|
|
resident = state["units"][0]["openings"][0]["resident_games"]
|
|
assert {g["name"] for g in resident} == {"Trove", "Insert", "Minis"}
|
|
# only the physical box is stacked and counted
|
|
assert state["units"][0]["openings"][0]["games"] == 1
|
|
assert state["units"][0]["openings"][0]["stacked_in"] == 4
|
|
assert state["unshelved"] == []
|
|
|
|
# deep cycles refuse: Trove into Minis would close the loop
|
|
res = web.post("/api/stored-in", json={"key": "1", "container": "3"})
|
|
assert res.status_code == 400
|
|
|
|
|
|
def test_stored_in_clears_the_games_own_shelf_spot(tmp_path):
|
|
"""Shelve X, then declare X lives inside B: X's own location record
|
|
must go — one box must never consume capacity in two openings."""
|
|
games = {
|
|
"1": {
|
|
"bgg_id": 1,
|
|
"name": "Big Box",
|
|
"dims": {
|
|
"width_in": 12,
|
|
"length_in": 12,
|
|
"depth_in": 4,
|
|
"source": "version",
|
|
},
|
|
},
|
|
"2": {
|
|
"bgg_id": 2,
|
|
"name": "Little Box",
|
|
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
|
|
},
|
|
}
|
|
web, cfg = _web(tmp_path, games)
|
|
from bggpipe.resolve import MATCH_COLUMNS, write_matches
|
|
|
|
write_matches(
|
|
cfg.matches_path,
|
|
[
|
|
{
|
|
**dict.fromkeys(MATCH_COLUMNS, ""),
|
|
"title_raw": name,
|
|
"bgg_id": str(i),
|
|
"bgg_name": name,
|
|
"match_status": "approved",
|
|
}
|
|
for i, name in ((1, "Big Box"), (2, "Little Box"))
|
|
],
|
|
)
|
|
web.post("/api/furniture/add-unit", json={"name": "Den"})
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den",
|
|
"rows": 1,
|
|
"cols": 2,
|
|
"width_in": 13.25,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
)
|
|
openings = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
|
|
web.post("/api/locate", json={"key": "1", "opening_id": openings[0]})
|
|
web.post("/api/locate", json={"key": "2", "opening_id": openings[1]})
|
|
assert (
|
|
web.post("/api/stored-in", json={"key": "2", "container": "1"}).status_code
|
|
== 200
|
|
)
|
|
locations = json.loads(cfg.locations_path.read_text())
|
|
assert "2" not in locations # its own spot is gone; it rides with Big Box
|
|
state = web.get("/api/shelves").json()
|
|
assert state["units"][0]["openings"][1]["games"] == 0
|
|
|
|
|
|
def test_delete_unit_clears_only_its_own_locations(tmp_path):
|
|
games = {
|
|
"1": {
|
|
"bgg_id": 1,
|
|
"name": "A",
|
|
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
|
|
},
|
|
"2": {
|
|
"bgg_id": 2,
|
|
"name": "B",
|
|
"dims": {"width_in": 8, "length_in": 8, "depth_in": 2, "source": "version"},
|
|
},
|
|
}
|
|
web, cfg = _web(tmp_path, games)
|
|
for name in ("Den", "Loft"):
|
|
web.post("/api/furniture/add-unit", json={"name": name})
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": name,
|
|
"label": "A1",
|
|
"width_in": 13.25,
|
|
"height_in": 13.25,
|
|
"depth_in": 15.4,
|
|
},
|
|
)
|
|
state = web.get("/api/shelves").json()
|
|
den = state["units"][0]["openings"][0]["id"]
|
|
loft = state["units"][1]["openings"][0]["id"]
|
|
web.post("/api/locate", json={"key": "1", "opening_id": den})
|
|
web.post("/api/locate", json={"key": "2", "opening_id": loft})
|
|
web.post("/api/furniture/delete-unit", json={"name": "Den"})
|
|
locations = json.loads(cfg.locations_path.read_text())
|
|
assert "1" not in locations and locations["2"]["opening_id"] == loft
|
|
state = web.get("/api/shelves").json()
|
|
assert [g["name"] for g in state["unshelved"]] == ["A"]
|
|
|
|
|
|
def test_locate_clearing_both_fields_deletes_the_record(tmp_path):
|
|
games = {"1": {"bgg_id": 1, "name": "A"}}
|
|
web, cfg = _web(tmp_path, games)
|
|
web.post("/api/locate", json={"key": "1", "note": "lent out"})
|
|
assert json.loads(cfg.locations_path.read_text())["1"]["note"] == "lent out"
|
|
web.post("/api/locate", json={"key": "1", "opening_id": "", "note": ""})
|
|
assert "1" not in json.loads(cfg.locations_path.read_text())
|
|
|
|
|
|
def test_ghost_opening_assignment_surfaces_as_unshelved(tmp_path):
|
|
"""A location pointing at a vanished opening (hand-edited or reverted
|
|
store) must SHOW — silently disappearing from every list is how a
|
|
game gets lost for real."""
|
|
from bggpipe.shelves import save_locations
|
|
|
|
games = {"1": {"bgg_id": 1, "name": "Ghosted"}}
|
|
web, cfg = _web(tmp_path, games)
|
|
save_locations(cfg, {"1": {"opening_id": "gone-a9", "note": ""}})
|
|
state = web.get("/api/shelves").json()
|
|
(row,) = state["unshelved"]
|
|
assert row["name"] == "Ghosted" and row["lost_home"] is True
|
|
|
|
|
|
def test_partial_dims_refuse_everywhere(tmp_path):
|
|
web, cfg = _web(tmp_path, {})
|
|
web.post("/api/furniture/add-unit", json={"name": "Den"})
|
|
res = web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den",
|
|
"label": "half",
|
|
"width_in": 13.0,
|
|
},
|
|
)
|
|
assert res.status_code == 400
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den",
|
|
"label": "whole",
|
|
"width_in": 13.0,
|
|
"height_in": 13.0,
|
|
"depth_in": 15.0,
|
|
},
|
|
)
|
|
oid = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
|
|
assert (
|
|
web.post(
|
|
"/api/furniture/edit-opening",
|
|
json={
|
|
"id": oid,
|
|
"height_in": 14.0,
|
|
},
|
|
).status_code
|
|
== 400
|
|
) # partial edit refused
|
|
# all three blanks makes it virtual, deliberately
|
|
web.post("/api/furniture/edit-opening", json={"id": oid})
|
|
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]
|
|
assert opening["width_in"] is None
|
|
|
|
|
|
def test_second_grid_continues_row_letters(tmp_path):
|
|
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,
|
|
},
|
|
)
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Wall",
|
|
"rows": 1,
|
|
"cols": 2,
|
|
"width_in": 26.5,
|
|
"height_in": 13.0,
|
|
"depth_in": 15.0,
|
|
},
|
|
)
|
|
labels = [
|
|
o["label"] for o in web.get("/api/shelves").json()["units"][0]["openings"]
|
|
]
|
|
assert labels == ["A1", "A2", "B1", "B2", "C1", "C2"]
|
|
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"})
|
|
web.post(
|
|
"/api/furniture/add-openings",
|
|
json={
|
|
"unit": "Den",
|
|
"rows": 1,
|
|
"cols": 2,
|
|
"width_in": 13.0,
|
|
"height_in": 13.0,
|
|
"depth_in": 15.0,
|
|
},
|
|
)
|
|
ids = [o["id"] for o in web.get("/api/shelves").json()["units"][0]["openings"]]
|
|
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
|
|
|
|
|
|
def test_opening_descriptions_round_trip(tmp_path):
|
|
web, cfg = _web(tmp_path, {})
|
|
web.post("/api/furniture/add-unit", json={"name": "Den"})
|
|
web.post("/api/furniture/add-openings", json={"unit": "Den", "label": "top"})
|
|
oid = web.get("/api/shelves").json()["units"][0]["openings"][0]["id"]
|
|
web.post(
|
|
"/api/furniture/edit-opening",
|
|
json={
|
|
"id": oid,
|
|
"description": "tall bookcase by the window",
|
|
},
|
|
)
|
|
opening = web.get("/api/shelves").json()["units"][0]["openings"][0]
|
|
assert opening["description"] == "tall bookcase by the window"
|
|
import json as _json
|
|
|
|
saved = _json.loads(cfg.furniture_path.read_text())["units"][0]["openings"][0]
|
|
assert saved["description"] == "tall bookcase by the window"
|