Files
bggpipe/tests/test_shelves.py
T
Eric WagonerandClaude Fable 5 7ed21de82a The space view lists boxes, not their contents
Eric, looking at the Red Dragon Inn shelf: a game stored inside
another box doesn't need its own row in the space view — it's clutter.
Right: the shelf holds physical boxes; what's inside them is the
container's business, already listed on its game page.

The opening sheet now renders only physical residents, collapsing
contents into a "holds N inside" hint on the container's row (hover
for the names). Eric's real B2 goes from 20 rows to 5 — Speechless,
Superfight, the Character Trove ("holds 15 inside"), Smorgasbox, Throw
Throw Avocado. The exported wall page mirrors it with "(+N inside)"
after the container's link. The payload keeps every rider (with a new
via field naming its box) so the sheet's add-a-game search still
excludes games already present through containment, and riders still
never appear in the unshelved backlog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
2026-08-09 16:21:58 -04:00

1293 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
load_locations,
new_opening_id,
opening_report,
save_furniture,
save_locations,
)
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_packs_two_lanes():
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["flat_in"] == 6 # both lie flat: 2 + 4 within 13.25
assert report["unmeasured"] == 1
assert report["overfull"] is False
# a misfit is named even when the lanes have room
report = opening_report(KALLAX, [("a", _entry("Too Long", 11.5, 17, 2.25))])
assert report["misfits"] == ["Too Long"]
def test_reviewer_lane_cases():
"""The two acceptance cases: 3 flat + 4 standing in a 26.8" wide
passes; 12 standard boxes FORCED all-flat in a cube still warns."""
wide = {
"id": "w",
"label": "top",
"zone": "",
"width_in": 26.8,
"height_in": 13.25,
"depth_in": 15.4,
}
big = [_entry(f"Big{i}", 11.6, 11.6, 2.8) for i in range(3)]
small = [_entry(f"Small{i}", 11.5, 9, 2) for i in range(4)]
report = opening_report(
wide,
[(f"b{i}", g) for i, g in enumerate(big)]
+ [(f"s{i}", g) for i, g in enumerate(small)],
)
assert report["overfull"] is False and report["laneless"] == []
assert report["flat_in"] == 8.4 # 3 × 2.8 lying flat
assert report["standing_in"] == 8 # 4 × 2 on edge beside the stack
assert sorted(set(report["lanes"].values())) == ["flat", "standing"]
# 12 standard boxes forced flat: only ~4 fit the height; warn
twelve = [(f"g{i}", _entry(f"G{i}", 11.6, 11.6, 2.8)) for i in range(12)]
forced = opening_report(
KALLAX, twelve, overrides={f"g{i}": "flat" for i in range(12)}
)
assert forced["overfull"] is True
assert len(forced["laneless"]) == 8 # 4 fit the 13.25" height, 8 cannot
# unforced, the same twelve mostly still can't fit a single cube —
# flat takes 4, standing takes what remains of the width, rest warn
free = opening_report(KALLAX, twelve)
assert free["overfull"] is True
assert len(free["laneless"]) > 0
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]["flat_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 packing already fails (the base
# is laneless behind the hog): a broken opening is never offered, so
# honesty beats reunification and the empty twin cube leads
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"
def test_unit_descriptions_round_trip(tmp_path):
web, cfg = _web(tmp_path, {})
web.post(
"/api/furniture/add-unit",
json={"name": "Den", "description": "the wall behind the couch"},
)
assert (
web.get("/api/shelves").json()["units"][0]["description"]
== "the wall behind the couch"
)
web.post(
"/api/furniture/edit-unit",
json={"name": "Den", "description": "now in the study"},
)
import json as _json
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
)
def test_add_openings_at_top(tmp_path):
"""Things live ON the furniture too: an opening added at_top lands
above the existing rows and renders first in the wall diagram."""
web, cfg = _web(tmp_path, {})
web.post("/api/furniture/add-unit", json={"name": "Kallax"})
web.post(
"/api/furniture/add-openings",
json={
"unit": "Kallax",
"rows": 2,
"cols": 2,
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
},
)
web.post(
"/api/furniture/add-openings",
json={
"unit": "Kallax",
"label": "on top",
"zone": "display",
"at_top": True,
},
)
labels = [
o["label"] for o in web.get("/api/shelves").json()["units"][0]["openings"]
]
assert labels == ["on top", "A1", "A2", "B1", "B2"]
def test_acknowledged_overhang(tmp_path):
"""Etherfields on the office towers: 11.8\" boxes on a 9.5\"-deep
shelf stand 2.3\" proud. The checker honestly refuses — until the
overhang is acknowledged, after which it takes the standing lane
(width-honest) and stops counting as a warning."""
tower = {
"id": "tower-e1",
"label": "E1",
"zone": "",
"width_in": 23.25,
"height_in": 19.0,
"depth_in": 9.5,
}
ether = _entry("Etherfields", 11.8, 11.8, 2.9)
# face-out it FITS the opening; it's the lane packing that refuses
# (can't stand spine-out, can't stack flat — 11.8" > 9.5" depth)
report = opening_report(tower, [("g1", ether)])
assert report["misfits"] == []
assert report["laneless"] == ["Etherfields"]
assert report["overfull"]
report = opening_report(tower, [("g1", ether)], acknowledged={"g1"})
assert report["misfits"] == []
assert report["laneless"] == []
assert report["lanes"] == {"g1": "standing"}
assert report["standing_in"] == pytest.approx(2.9)
# the width budget stays honest: nine acknowledged monoliths
# overflow a 23.25" row — the surplus goes wedged (the human said
# they're here), never silently placed in a lane
crowd = [(f"g{i}", ether) for i in range(9)]
report = opening_report(tower, crowd, acknowledged={k for k, _ in crowd})
assert not report["overfull"]
assert report["wedged"]
assert (
len(report["wedged"]) + len([lane for lane in report["lanes"].values() if lane])
== 9
)
def test_import_acknowledge_column(tmp_path):
"""The CSV import rejects a doesn't-fit row with a hint, accepts it
with acknowledge=yes, and a re-import keeps lane + acknowledgment
when the opening is unchanged (upsert, not reset)."""
cfg = Config(data_dir=tmp_path / "data")
cfg.data_dir.mkdir(parents=True)
cfg.games_path.write_text(json.dumps({"1": _entry("Etherfields", 11.8, 11.8, 2.9)}))
save_furniture(
cfg,
[
{
"name": "Tower",
"openings": [
{
"id": "tower-e1",
"label": "E1",
"zone": "",
"width_in": 23.25,
"height_in": 10.0,
"depth_in": 9.5,
}
],
}
],
)
csv_path = tmp_path / "plan.csv"
csv_path.write_text("name,opening\nEtherfields,tower-e1\n")
result = import_assignments(cfg, csv_path)
assert result["assigned"] == 0
assert "acknowledge column" in result["rejects"][0]
csv_path.write_text("name,opening,acknowledge\nEtherfields,tower-e1,yes\n")
result = import_assignments(cfg, csv_path)
assert result["assigned"] == 1
locations = load_locations(cfg)
assert locations["1"]["acknowledged"] is True
# hand-set a lane, re-import WITHOUT the column: same opening, so
# the note, lane, and acknowledgment all survive
locations["1"]["lane"] = "standing"
locations["1"]["note"] = "top row"
save_locations(cfg, locations)
csv_path.write_text("name,opening\nEtherfields,tower-e1\n")
result = import_assignments(cfg, csv_path)
assert result["assigned"] == 1
record = load_locations(cfg)["1"]
assert record == {
"opening_id": "tower-e1",
"note": "top row",
"lane": "standing",
"acknowledged": True,
}
def test_here_anyway_wedges_the_unplaceable(tmp_path):
""" "It's here anyway": acknowledging a box the lanes can't take
marks it wedged — really there, outside the capacity math — and the
opening stops warning about it."""
web, cfg = _web(
tmp_path,
{
"1": _entry("Wide Flat", 13.0, 13.0, 12.0),
"2": _entry("Squeezed Out", 10.0, 10.0, 3.0),
},
)
save_furniture(
cfg,
[
{
"name": "Mix",
"openings": [
{
"id": "cube",
"label": "A1",
"zone": "",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(
cfg,
{
"1": {"opening_id": "cube", "note": ""},
"2": {"opening_id": "cube", "note": ""},
},
)
state = web.get("/api/shelves").json()
a1 = state["units"][0]["openings"][0]
squeezed = next(g for g in a1["resident_games"] if g["name"] == "Squeezed Out")
assert squeezed["laneless"] is True and squeezed["wedged"] is False
assert a1["overfull"] is True
state = web.post("/api/acknowledge", json={"key": "2", "acknowledged": True}).json()
a1 = state["units"][0]["openings"][0]
squeezed = next(g for g in a1["resident_games"] if g["name"] == "Squeezed Out")
assert squeezed["wedged"] is True and squeezed["laneless"] is False
assert a1["overfull"] is False
def test_measurements_overlay(tmp_path):
"""The owner's tape measure outranks BGG: /api/measure records
dimensions for an unmeasured game, the library sees source
"measured", and clearing restores the unmeasured state."""
web, cfg = _web(tmp_path, {"1": {"name": "Mystery Box"}})
save_furniture(
cfg,
[
{
"name": "Mix",
"openings": [
{
"id": "cube",
"label": "A1",
"zone": "",
"width_in": 13.25,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(cfg, {"1": {"opening_id": "cube", "note": ""}})
state = web.get("/api/shelves").json()
g = state["units"][0]["openings"][0]["resident_games"][0]
assert g["measured"] is False
state = web.post(
"/api/measure",
json={"key": "1", "width_in": 10.0, "length_in": 10.0, "depth_in": 2.5},
).json()
g = state["units"][0]["openings"][0]["resident_games"][0]
assert g["measured"] is True and g["lane"] == "flat"
# partial numbers are refused, never guessed
assert (
web.post("/api/measure", json={"key": "1", "width_in": 10.0}).status_code == 400
)
# clearing (all empty) restores honest ignorance
state = web.post("/api/measure", json={"key": "1"}).json()
g = state["units"][0]["openings"][0]["resident_games"][0]
assert g["measured"] is False
def test_away_whereabouts(tmp_path):
"""Where it lives vs where it IS: an away note marks the box out
(travel case, a friend's house) without touching its home opening,
contents ride along, and "" brings it back — cleaning up records
that held nothing else."""
web, cfg = _web(
tmp_path,
{
"1": _entry("Frosthaven", 12.0, 12.0, 7.0, bgg_id="174430"),
"2": _entry(
"Frosthaven: Solo Scenarios", 8.0, 8.0, 1.0, stored_in="174430"
),
},
)
save_furniture(
cfg,
[
{
"name": "Wall",
"openings": [
{
"id": "big",
"label": "A1",
"zone": "",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(cfg, {"1": {"opening_id": "big", "note": ""}})
state = web.post("/api/away", json={"key": "1", "where": "travel case"}).json()
a1 = state["units"][0]["openings"][0]
frost = next(g for g in a1["resident_games"] if g["name"] == "Frosthaven")
solo = next(g for g in a1["resident_games"] if "Solo" in g["name"])
# home unchanged — still a resident, still packed — but marked out,
# and the contents ride along on the trip
assert frost["away"] == "travel case"
assert solo["away"] == "travel case" and solo["inherited"]
assert frost["lane"] # the home spot stays reserved
record = load_locations(cfg)["1"]
assert record["opening_id"] == "big" and record["away"] == "travel case"
# coming home clears the field but never the home
state = web.post("/api/away", json={"key": "1", "where": ""}).json()
assert "away" not in load_locations(cfg)["1"]
# an unshelved game can be out too; bringing it back leaves no husk
web.post("/api/away", json={"key": "2", "where": "loaned to Ben"})
assert load_locations(cfg)["2"] == {
"opening_id": None,
"note": "",
"away": "loaned to Ben",
}
web.post("/api/away", json={"key": "2", "where": ""})
assert "2" not in load_locations(cfg)
def test_contained_games_collapse_in_sheet_payload(tmp_path):
"""The space view lists physical boxes; contents carry `via` so the
UI can collapse them into the container's "holds N" hint — and they
still count as located (never re-offered for shelving)."""
web, cfg = _web(
tmp_path,
{
"1": _entry("Red Dragon Inn", 12.0, 12.0, 4.0, bgg_id="4991"),
"2": _entry("RDI: Allies", 8.0, 8.0, 1.0, stored_in="4991"),
},
)
save_furniture(
cfg,
[
{
"name": "Wall",
"openings": [
{
"id": "big",
"label": "A1",
"zone": "",
"width_in": 26.5,
"height_in": 13.25,
"depth_in": 15.4,
}
],
}
],
)
save_locations(cfg, {"1": {"opening_id": "big", "note": ""}})
state = web.get("/api/shelves").json()
rows = state["units"][0]["openings"][0]["resident_games"]
box = next(g for g in rows if g["name"] == "Red Dragon Inn")
allies = next(g for g in rows if g["name"] == "RDI: Allies")
assert box["via"] == "" and allies["via"] == "1"
assert allies["inherited"] is True
# riding along is being located: never in the unshelved backlog
assert all(u["key"] != "2" for u in state["unshelved"])