"""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 first_cube = saved[0]["openings"][2]["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}) web.post( "/api/furniture/delete-opening", json={"id": saved[0]["openings"][3]["id"]} ) saved2 = json.loads(cfg.furniture_path.read_text())["units"] assert len(saved2[0]["openings"]) == 13 assert saved2[0]["openings"][1]["id"] == first_cube # moved up one 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