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
981 lines
33 KiB
Python
981 lines
33 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_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
|
||
)
|