"""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