-- Phase 1 reducer tests for the account.projects topology actions.
-- Skill ref: ~/.claude/skills/redux-style-wow-addon/testing.md (Layer 1.5).
dofile("wow_mock.lua")
dofile("../Core/HDGR_SchemeConstants.lua")
dofile("../Core/HDGR_Constants.lua")
dofile("../Core/HDGR_Format.lua")
dofile("../Core/HDGR_Paths.lua")
dofile("../Core/HDGR_Log.lua")
dofile("../Core/HDGR_ProjectsIDs.lua") -- REMAP_ROOM reducer uses IDs.parsePath (house lookup)
dofile("../Core/HDGR_ProjectsShapeAtlas.lua")   -- planLayout / floorLayout footprints
dofile("../Core/HDGR_ProjectsFloorMap.lua")     -- canvasModel projection + collision
dofile("../Core/HDGR_ProjectsAutoLayout.lua")   -- floorLayout (Reflect) cells
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_Selectors.lua")   -- _RawDispatch calls Selectors:InvalidateMemos
dofile("../UI/HDGR_CategoryNav.lua")
dofile("../UI/HDGR_Selectors_Projects.lua")   -- projects.crateDetail (Phase 3)
-- Stub the item-name resolver BOUNDARY (crateDetail calls ResolveName/ResolveIcon).
HDG.ItemNameResolver = {
    ResolveName = function(_, id) return "decor-" .. tostring(id) end,
    ResolveIcon = function() return nil end,
    GetTick     = function() return 0 end,
}

local A = HDG.Constants.ACTIONS
local function assertEq(a, e, l) if a ~= e then error(l .. ": expected " .. tostring(e) .. ", got " .. tostring(a), 2) end end
local function assertTrue(v, l) if not v then error(l, 2) end end
local function assertNil(v, l) if v ~= nil then error(l .. ": expected nil, got " .. tostring(v), 2) end end

local function freshState()
    HDG_DB = {}
    HDG.Store:LoadFromSavedVariables()
    return HDG.Store:GetState()
end
local function D(t, p) HDG.Store:Dispatch({ type = t, payload = p }) end

-- Reshape helpers: rooms now live under versions[vid].rooms (house -> version -> room).
-- seedHouse mints a house + its current version via CAPTURE_COMMIT (the capture path);
-- editor dispatches (UPSERT_ROOM / DELETE_ROOM) carry that versionID. vrooms reads it.
local function seedHouse(s, houseID)
    houseID = houseID or "house:alliance"
    HDG.Store:Dispatch({ type = A.PROJECTS_CAPTURE_COMMIT, payload = { houseID = houseID, lastCapturedAt = 1 } })
    return s.account.projects.houses[houseID].currentVersionID
end
local function vrooms(s, houseID)
    local h = s.account.projects.houses[houseID or "house:alliance"]
    local v = h and h.currentVersionID and s.account.projects.versions[h.currentVersionID]
    return (v and v.rooms) or {}
end

-- ===== Seeding (factories + EnsureStateShape) =====
do
    local s = freshState()
    assertTrue(s.account.projects, "account.projects seeded")
    assertEq(s.account.projects.schemaVersion, 2, "projects schemaVersion (house->version reshape)")
    assertTrue(s.account.projects.houses and s.account.projects.versions,
        "projects houses/versions seeded")
    assertNil(s.account.projects.rooms, "flat top-level rooms map dropped (rooms live under versions)")
    assertEq(s.session.ui.projects.activeView, "landing", "session.ui.projects.activeView default")
    assertEq(s.session.ui.projects.selectedFloor, 1, "selectedFloor default 1")
    assertTrue(s.session.house.budget, "session.house.budget seeded")
    assertEq(s.session.house.numFloors, 0, "session.house.numFloors default 0")
end

-- ===== UPSERT_HOUSE / UPSERT_ROOM (merge + cell seed) =====
do
    local s = freshState()
    D(A.PROJECTS_UPSERT_HOUSE, { houseID = "house:alliance", fields = { name = "Estate" } })
    assertEq(s.account.projects.houses["house:alliance"].name, "Estate", "upsert house name")
    D(A.PROJECTS_UPSERT_HOUSE, { houseID = "house:alliance", fields = { lastCapturedAt = 99 } })
    assertEq(s.account.projects.houses["house:alliance"].name, "Estate", "upsert house merges (keeps name)")
    assertEq(s.account.projects.houses["house:alliance"].lastCapturedAt, 99, "upsert house adds field")

    local vid = seedHouse(s)
    local RID = "house:alliance:f1:r7a3f"
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = RID, fields = { shape = "square_s" } })
    local room = vrooms(s)[RID]
    assertEq(room.shape, "square_s", "upsert room shape")
    assertTrue(room.cell and room.cell.x == 0 and room.cell.locked == false, "upsert room seeds default cell")
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = RID, fields = { cell = { x = 3, y = 2, rotation = 1, locked = true } } })
    assertEq(vrooms(s)[RID].cell.x, 3, "upsert room overrides cell")
    assertEq(vrooms(s)[RID].shape, "square_s", "upsert room merge keeps shape")
end

-- (CONNECTION_ASSERT/BLOCK/UNASSERT removed -- connectivity is derived from cell
-- adjacency at render now, not stored in account.projects.connections.)

-- ===== DELETE_ROOM orphan-detaches its crates (stamps last-known metadata) =====
do
    local s = freshState()
    local vid = seedHouse(s)
    local RID = "house:alliance:f1:r1"
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = RID, fields = { shape = "cross_shape", name = "Parlour" } })
    s.account.collections["crate:" .. RID .. ":c1"] =
        { id = "crate:" .. RID .. ":c1", type = "crate", parent = RID, versionID = vid, name = "Throne Hall", decor = {} }
    D(A.PROJECTS_DELETE_ROOM, { versionID = vid, roomID = RID, ts = 123 })
    assertNil(vrooms(s)[RID], "room deleted")
    local crate = s.account.collections["crate:" .. RID .. ":c1"]
    assertNil(crate.parent, "orphaned crate parent cleared")
    assertEq(crate.lastKnownRoomID, RID, "orphan stamps lastKnownRoomID")
    assertEq(crate.lastKnownShape, "cross_shape", "orphan stamps lastKnownShape")
    assertEq(crate.lastKnownRoomName, "Parlour", "orphan stamps lastKnownRoomName (crate-ledger label)")
    assertEq(crate.orphanedAt, 123, "orphan stamps orphanedAt")
end

-- ===== CAPTURE_COMMIT (atomic: upsert + delete + lastCapturedAt) =====
do
    local s = freshState()
    seedHouse(s)
    vrooms(s)["old"] = { shape = "x" }   -- a stale room in the current version, to be deleted
    D(A.PROJECTS_CAPTURE_COMMIT, {
        houseID = "house:alliance", lastCapturedAt = 555,
        rooms = { ["house:alliance:f1:r1"] = { shape = "entry" } },
        deleteRoomIDs = { "old" },
    })
    assertEq(s.account.projects.houses["house:alliance"].lastCapturedAt, 555, "commit stamps lastCapturedAt")
    assertEq(vrooms(s)["house:alliance:f1:r1"].shape, "entry", "commit upserts new room")
    assertTrue(vrooms(s)["house:alliance:f1:r1"].cell, "commit seeds a cell on captured rooms")
    assertNil(vrooms(s)["old"], "commit deletes listed room")
    -- (connectivity no longer persisted by the commit -- derived at render)
end

-- ===== HOUSE_TICK (partial patch of live housing reads) =====
do
    local s = freshState()
    D(A.PROJECTS_HOUSE_TICK, { numFloors = 2 })
    assertEq(s.session.house.numFloors, 2, "house tick sets numFloors")
    assertEq(s.session.house.budget.decorSpent, 0, "partial patch keeps budget default")
    D(A.PROJECTS_HOUSE_TICK, { budget = { decorSpent = 40, decorCount = 12 }, editorActive = true })
    assertEq(s.session.house.budget.decorSpent, 40, "house tick sets budget (live decor spend)")
    assertEq(s.session.house.numFloors, 2, "house tick keeps prior numFloors")
    assertTrue(s.session.house.editorActive, "house tick sets editorActive")
end

-- ===== CRATE_* (ordered decor records in account.collections) =====
do
    local s = freshState()
    local CID = "crate:house:alliance:f1:r1:c1"
    D(A.CRATE_UPSERT, { crateID = CID, fields = { parent = "house:alliance:f1:r1", name = "Throne Hall" } })
    local crate = s.account.collections[CID]
    assertEq(crate.type, "crate", "crate upsert sets type")
    assertEq(crate.name, "Throne Hall", "crate upsert name")
    assertTrue(crate.decor and #crate.decor == 0, "crate seeds empty ordered decor")

    D(A.CRATE_ADD_DECOR, { crateID = CID, decorID = 111, count = 4 })
    D(A.CRATE_ADD_DECOR, { crateID = CID, decorID = 222 })
    assertEq(#crate.decor, 2, "two decor records, ordered")
    assertEq(crate.decor[1].id, 111, "first added is first")
    assertEq(crate.decor[1].count, 4, "count recorded")
    assertEq(crate.decor[2].count, 1, "count defaults to 1")
    D(A.CRATE_ADD_DECOR, { crateID = CID, decorID = 111, count = 7 })
    assertEq(#crate.decor, 2, "re-add dedups by id")
    assertEq(crate.decor[1].count, 7, "re-add updates count")

    D(A.CRATE_REMOVE_DECOR, { crateID = CID, decorID = 111 })
    assertEq(#crate.decor, 1, "remove drops the record")
    assertEq(crate.decor[1].id, 222, "remaining record is 222")

    D(A.CRATE_SET_FIELD, { crateID = CID, field = "name", value = "Renamed" })
    assertEq(crate.name, "Renamed", "set_field renames")
    D(A.CRATE_SET_FIELD, { crateID = CID, field = "type", value = "hacked" })
    assertEq(crate.type, "crate", "set_field cannot change protected type")

    D(A.CRATE_DELETE, { crateID = CID })
    assertNil(s.account.collections[CID], "crate deleted")
end

-- ===== LIBRARY_STAMP clones a template into an INDEPENDENT crate =====
do
    local s = freshState()
    s.account.collections["lib_x"] = { id = "lib_x", type = "library", name = "Vrykul", decor = { { id = 1, count = 2 }, { id = 2 } } }
    local CID = "crate:house:alliance:f1:r9:c1"
    D(A.LIBRARY_STAMP, { libID = "lib_x", crateID = CID, parent = "house:alliance:f1:r9" })
    local crate = s.account.collections[CID]
    assertEq(crate.type, "crate", "stamped crate type")
    assertEq(crate.derivedFrom, "lib_x", "stamp records lineage")
    assertEq(crate.parent, "house:alliance:f1:r9", "stamp sets parent")
    assertEq(#crate.decor, 2, "stamp clones decor")
    D(A.CRATE_ADD_DECOR, { crateID = CID, decorID = 99 })
    assertEq(#crate.decor, 3, "stamped crate edited independently")
    assertEq(#s.account.collections["lib_x"].decor, 2, "library template unaffected (clone, not reference)")
end

-- ===== PROJECTS_REMAP_ROOM re-keys room + re-parents its crates =====
do
    -- REMAP is a CAPTURE-path action -> it resolves the version from fromRoomID's parsed
    -- houseID, so fromRoomID must be a valid grammar roomID (captured rooms always are).
    local s = freshState()
    local vid = seedHouse(s)
    local FROM, TO = "house:alliance:f1:r0001", "house:alliance:f1:r0005"
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = FROM, fields = { shape = "l_shape" } })
    s.account.collections["crate:" .. FROM .. ":c1"] = { id = "crate:" .. FROM .. ":c1", type = "crate", parent = FROM, versionID = vid, decor = {} }
    D(A.PROJECTS_REMAP_ROOM, { fromRoomID = FROM, toRoomID = TO })
    assertNil(vrooms(s)[FROM], "old room id removed")
    assertEq(vrooms(s)[TO].shape, "l_shape", "room re-keyed to existing id")
    assertEq(s.account.collections["crate:" .. FROM .. ":c1"].parent, TO, "crate re-parented")
end

-- ===== REGRESSION (review C1): CAPTURE_COMMIT orphan-stamps deleted rooms' crates =====
do
    local s = freshState()
    local vid = seedHouse(s)
    local RID = "house:alliance:f1:rOld"
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = RID, fields = { shape = "octagon_m" } })
    s.account.collections["crate:" .. RID .. ":c1"] =
        { id = "crate:" .. RID .. ":c1", type = "crate", parent = RID, versionID = vid, decor = {} }
    D(A.PROJECTS_CAPTURE_COMMIT, {
        houseID = "house:alliance", lastCapturedAt = 777,
        rooms = {}, deleteRoomIDs = { RID },
    })
    assertNil(vrooms(s)[RID], "commit deleted the stale room")
    local crate = s.account.collections["crate:" .. RID .. ":c1"]
    assertNil(crate.parent, "commit orphan-stamps: parent cleared")
    assertEq(crate.lastKnownRoomID, RID, "commit orphan-stamps lastKnownRoomID")
    assertEq(crate.lastKnownShape, "octagon_m", "commit orphan-stamps lastKnownShape")
    assertEq(crate.orphanedAt, 777, "commit orphan-stamps orphanedAt (= lastCapturedAt)")
end

-- (CONNECTION re-assert + REMAP connection-rekey tests removed -- connectivity is
-- DERIVED from cell-adjacency at render now, not stored in account.projects.connections.)

-- ===== CRATE_ADD_DECOR honors count = 0 (was dropped by the truthiness check) =====
do
    local s = freshState()
    local CID = "crate:house:alliance:f1:r1:c2"
    D(A.CRATE_UPSERT, { crateID = CID, fields = {} })
    D(A.CRATE_ADD_DECOR, { crateID = CID, decorID = 5, count = 3 })
    D(A.CRATE_ADD_DECOR, { crateID = CID, decorID = 5, count = 0 })
    assertEq(s.account.collections[CID].decor[1].count, 0, "count=0 update is honored (not dropped)")
end

-- ===== SHIPPING_CRATE_PACK / DELETE =====
do
    local s = freshState()
    local SID = "ship:abc123"
    local record = {
        id = SID, type = "shippingCrate", name = "Pre-redesign backup",
        houseID = "house:alliance", packedAt = 1000, captureLevel = "manifest",
        topology = { rooms = { r1 = { shape = "square_s" }, r2 = { shape = "entry" } } },
        contents = { { id = 101, count = 2 }, { id = 202, count = 1 } },
        placementsByRoom = nil, houseLayoutBlob = nil,
    }
    D(A.SHIPPING_CRATE_PACK, { shipID = SID, record = record })
    local stored = s.account.collections[SID]
    assertEq(stored.type, "shippingCrate", "PACK sets type")
    assertEq(stored.name, "Pre-redesign backup", "PACK stores name")
    assertEq(stored.captureLevel, "manifest", "PACK stores captureLevel")
    assertEq(stored.packedAt, 1000, "PACK stores packedAt")
    assertNil(stored.placementsByRoom, "PACK reserved-nil slot: placementsByRoom")
    assertNil(stored.houseLayoutBlob, "PACK reserved-nil slot: houseLayoutBlob")

    D(A.SHIPPING_CRATE_DELETE, { shipID = SID })
    assertNil(s.account.collections[SID], "DELETE removes the record")

    -- Empty-payload safety (smoke: must not crash, must be a no-op)
    D(A.SHIPPING_CRATE_PACK, {})
    D(A.SHIPPING_CRATE_DELETE, {})
end

-- ===== PROJECTS_CLEAR_HOUSE: full re-capture wipe (rooms, orphan crates) =====
do
    local s, H = freshState(), "house:alliance"
    local vid = seedHouse(s, H)
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = H .. ":f1:r0001", fields = { shape = "hallway" } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = H .. ":f3:r0003", fields = { shape = "square_s" } })  -- a since-deleted floor
    D(A.CRATE_UPSERT, { crateID = H .. ":f1:r0001:cAAA", fields = { name = "Crate", parent = H .. ":f1:r0001", versionID = vid } })
    assertTrue(next(vrooms(s)), "precondition: rooms present")

    D(A.PROJECTS_CLEAR_HOUSE, { houseID = H })
    assertNil(next(vrooms(s)), "CLEAR_HOUSE wipes every room of the house current version (incl. deleted floors)")
    local crate = s.account.collections[H .. ":f1:r0001:cAAA"]
    assertTrue(crate and crate.parent == nil, "CLEAR_HOUSE orphans the room's crate (parent cleared)")
    assertEq(crate.lastKnownShape, "hallway", "orphaned crate stamped with lastKnownShape")

    D(A.PROJECTS_CLEAR_HOUSE, {})   -- empty-payload safety
end

-- ===== projects.crateDetail (Phase 3 selector: room's crate -> resolved rows) =====
do
    local s = freshState()
    local vid = seedHouse(s)
    local roomID = "house:alliance:f1:r0001"
    local cid    = "crate:" .. roomID
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedRoomID", value = roomID } })
    D(A.CRATE_UPSERT,    { crateID = cid, fields = { parent = roomID, versionID = vid, name = "Throne Hall" } })
    D(A.CRATE_ADD_DECOR, { crateID = cid, decorID = 111, count = 4 })
    D(A.CRATE_ADD_DECOR, { crateID = cid, decorID = 222 })   -- default count 1
    local cd = HDG.Selectors:Call("projects.crateDetail", HDG.Store:GetState(), {})
    assertTrue(cd.hasCrate, "crateDetail: hasCrate")
    assertEq(cd.crateID, cid, "crateDetail: crateID")
    assertEq(cd.crateName, "Throne Hall", "crateDetail: crateName")
    assertEq(#cd.decorRows, 2, "crateDetail: 2 decor rows")
    assertEq(cd.decorCount, 5, "crateDetail: decorCount = 4 + 1")
    assertEq(cd.decorRows[1].crateID, cid, "crateDetail: row carries crateID (remove-button source)")
    assertEq(cd.decorRows[1].name, "decor-111", "crateDetail: name via resolver boundary")

    D(A.CRATE_REMOVE_DECOR, { crateID = cid, decorID = 111 })
    local cd2 = HDG.Selectors:Call("projects.crateDetail", HDG.Store:GetState(), {})
    assertEq(#cd2.decorRows, 1, "crateDetail: decor removed")
    assertEq(cd2.decorCount, 1, "crateDetail: decorCount after remove")

    -- composer + no-selection path
    assertTrue(HDG.Selectors:Call("projects.crateDetailHasCrate", HDG.Store:GetState(), {}), "crateDetailHasCrate true")
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedRoomID", value = nil } })
    local cd3 = HDG.Selectors:Call("projects.crateDetail", HDG.Store:GetState(), {})
    assertTrue(cd3.hasCrate == false, "crateDetail: no selection -> hasCrate false")
end

-- ===== planLayout + canvasModel render the active version's rooms (by cell) =====
do
    local s = freshState()
    local vid = seedHouse(s)
    local roomID = "house:alliance:f1:r1"
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = roomID, fields = { shape = "square_m", name = "Plan A",
        cell = { x = 2, y = 3, rotation = 0, locked = false } } })

    local pl = HDG.Selectors:Call("projects.planLayout", HDG.Store:GetState(), {})
    assertTrue(pl.layout[roomID] ~= nil, "planLayout includes the active version's room")
    assertEq(pl.layout[roomID].cell.x, 2, "room at explicit cell.x")
    assertEq(pl.layout[roomID].cell.y, 3, "room at explicit cell.y")
    assertEq(pl.layout[roomID].w, 6, "square_m footprint w=6 (half-module grid)")

    local cm = HDG.Selectors:Call("projects.canvasModel", HDG.Store:GetState(), {})
    local found
    for _, r in ipairs(cm.rooms) do if r.roomID == roomID then found = r end end
    assertTrue(found ~= nil, "canvasModel emits the active version's room")
    assertEq(found.x, 2, "canvasModel room x"); assertEq(found.w, 6, "canvasModel room w")
end

-- ===== E6: move / rotate / remove a room (PROJECTS_UPSERT_ROOM cell, with versionID) =====
do
    local s = freshState()
    local vid = seedHouse(s)
    local roomID = "house:alliance:f1:raa"
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = roomID, fields = { shape = "hallway", name = "Hall",
        cell = { x = 1, y = 1, rotation = 0, locked = false } } })
    local function pl() return HDG.Selectors:Call("projects.planLayout", HDG.Store:GetState(), {}) end
    assertEq(pl().layout[roomID].w, 4, "hallway footprint w=4 at rotation 0 (half-module grid)")
    -- move
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = roomID, fields = { cell = { x = 4, y = 2, rotation = 0, locked = false } } })
    assertEq(pl().layout[roomID].cell.x, 4, "E6 move updates cell.x")
    assertEq(pl().layout[roomID].cell.y, 2, "E6 move updates cell.y")
    -- rotate: hallway 2x1 -> 1x2
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = roomID, fields = { cell = { x = 4, y = 2, rotation = 1, locked = false } } })
    assertEq(pl().layout[roomID].rotation, 1, "E6 rotate stores rotation")
    assertEq(pl().layout[roomID].w, 2, "E6 rotated hallway w=2 (was 4)")
    assertEq(pl().layout[roomID].d, 4, "E6 rotated hallway d=4 (was 2)")
    -- remove
    D(A.PROJECTS_DELETE_ROOM, { versionID = vid, roomID = roomID })
    assertTrue(pl().layout[roomID] == nil, "E6 remove deletes the room")
end

-- ===== E5: plan validation (overlap + room cost) over the active version =====
do
    local s = freshState()
    local vid = seedHouse(s)
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = "house:alliance:f1:rb1", fields = { shape = "square_s", cell = { x = 0, y = 0, rotation = 0, locked = false } } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = "house:alliance:f1:rb2", fields = { shape = "square_s", cell = { x = 5, y = 0, rotation = 0, locked = false } } })
    local v1 = HDG.Selectors:Call("projects.planValidation", HDG.Store:GetState(), {})
    assertTrue(not v1.collide, "non-overlapping plan -> no collision")
    assertEq(v1.roomCount, 2, "2 rooms counted")
    assertEq(v1.roomCost, 10, "2x square_s budget = 10")
    assertTrue(v1.hardOK, "valid plan -> hardOK")
    -- move rb2 onto rb1 -> overlap
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = "house:alliance:f1:rb2", fields = { cell = { x = 0, y = 0, rotation = 0, locked = false } } })
    local v2 = HDG.Selectors:Call("projects.planValidation", HDG.Store:GetState(), {})
    assertTrue(v2.collide, "overlapping plan -> collision detected")
    assertTrue(not v2.hardOK, "collision -> hardOK false")
end

-- ===== E7: room shopping list = active (what-if) version vs current (reality) version =====
do
    local s = freshState()
    local vid = seedHouse(s)   -- current/reality version
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = "house:alliance:f1:r1", fields = { shape = "square_m", cell = { x = 0, y = 0, rotation = 0, locked = false } } })

    -- active == current -> the diff is empty (the design matches reality).
    local d0 = HDG.Selectors:Call("projects.planDiff", HDG.Store:GetState(), {})
    assertEq(d0.added, 0, "diff: active==current -> nothing to build")
    assertEq(d0.missing, 0, "diff: active==current -> nothing to remove")

    -- Branch a what-if version (hallway instead of square_m) and make it active.
    local wid = "version_whatif1"
    s.account.projects.versions[wid] = { houseID = "house:alliance", name = "What-if", basedOn = vid,
        rooms = { ["house:alliance:f1:raaa1"] = { shape = "hallway", cell = { x = 0, y = 0, rotation = 0, locked = false } } } }
    D(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:alliance", versionID = wid })
    local d = HDG.Selectors:Call("projects.planDiff", HDG.Store:GetState(), {})
    assertEq(d.added,   1, "diff: +1 hallway to build (in what-if, not reality)")
    assertEq(d.missing, 1, "diff: -1 square_m to remove (in reality, not what-if)")
end

-- ===== Phase B: ShapeAtlas.ShapeForRecordID (catalog recordID -> shapeID) =====
do
    local SA = HDG.Projects.ShapeAtlas
    assertEq(SA.ShapeForRecordID(1),   "square_s",       "rec 1 -> square_s")
    assertEq(SA.ShapeForRecordID(2),   "hallway",        "rec 2 -> hallway")
    assertEq(SA.ShapeForRecordID(13),  "cross_shape",    "rec 13 -> cross_shape")
    assertEq(SA.ShapeForRecordID(233), "circle_evening", "rec 233 -> circle_evening")
    assertNil(SA.ShapeForRecordID(113),   "prefab rec 113 -> nil (no ShapeAtlas geometry)")
    assertNil(SA.ShapeForRecordID(99999), "unknown rec -> nil")
end

-- ===== Phase B: PROJECTS_ROOM_CATALOG_UPDATED + palette live-stock merge =====
do
    local s = freshState()
    -- Default (empty catalog): palette lists placeable shapes, none flagged owned, Entry excluded.
    local p0 = HDG.Selectors:Call("projects.paletteShapes", HDG.Store:GetState(), {})
    assertTrue(#p0 > 0, "palette lists placeable shapes")
    for _, row in ipairs(p0) do
        assertTrue(row.shape ~= "entry", "palette excludes the Entry anchor")
        assertTrue(row.owned == false, "no stock by default")
    end

    -- Snapshot a catalog: own 2 square_s in storage, 1 hallway placed (not in storage).
    D(A.PROJECTS_ROOM_CATALOG_UPDATED, { byShapeID = {
        square_s = { shape = "square_s", numStored = 2, numPlaced = 0, owned = true },
        hallway  = { shape = "hallway",  numStored = 0, numPlaced = 1, owned = true },
    }, entries = { { recordID = 1, shapeID = "square_s" }, { recordID = 2, shapeID = "hallway" } } })
    assertEq(s.session.house.roomCatalog.tick, 1, "catalog dispatch bumps tick")
    assertEq(#HDG.Selectors:Call("projects.roomCatalog", HDG.Store:GetState(), {}), 2, "roomCatalog selector returns the entries list")

    local p1 = HDG.Selectors:Call("projects.paletteShapes", HDG.Store:GetState(), {})
    local sq, hall
    for _, row in ipairs(p1) do
        if row.shape == "square_s" then sq = row elseif row.shape == "hallway" then hall = row end
    end
    assertEq(sq.numStored, 2, "palette merges live numStored for square_s")
    assertTrue(sq.owned, "square_s flagged owned")
    assertTrue(hall.owned, "hallway owned (placed counts toward owned)")
    assertEq(hall.numStored, 0, "hallway numStored 0 (placed, not in storage)")
end

-- ===== Phase D: room budget (planned weight vs live cap) =====
do
    local s = freshState()
    local vid = seedHouse(s)
    -- Cap comes from the captured level-reward snapshot (atMax -> reward newValue),
    -- NOT the layout-state-dependent live API. type 0=Value, valueType 2=Rooms.
    D(A.HOUSE_SNAPSHOT_UPDATED, { snapshot = { nextRewards = { atMax = true, rewards = {
        { type = 0, valueType = 2, oldValue = 76, newValue = 95 },
    } } } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = "house:alliance:f1:r1", fields = { shape = "square_m" } })   -- budget 12
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = "house:alliance:f1:r2", fields = { shape = "hallway" } })    -- budget 3
    local b = HDG.Selectors:Call("projects.roomBudget", HDG.Store:GetState(), {})
    assertEq(b.cost, 15, "roomBudget sums ShapeAtlas weights (square_m 12 + hallway 3)")
    assertEq(b.max, 95, "roomBudget uses reward-derived cap (placementCaps), not the layout API")
    assertTrue(not b.over, "15/95 -> not over")
    -- non-over path returns plain text (no Theme:ColorCode -> harness-safe)
    assertEq(HDG.Selectors:Call("projects.roomBudgetText", HDG.Store:GetState(), {}), "|A:house-room-limit-icon:16:16|a 15 / 95", "roomBudgetText composes the title string with the room-limit icon")
    assertTrue(HDG.Selectors:Call("projects.roomBudgetProgress", HDG.Store:GetState(), {}) > 0, "roomBudgetProgress non-zero")
end

-- ===== projects.placementCaps: reward-derived room + decor caps =====
do
    freshState()
    -- atMax: cap = reward newValue (the bump landed on the current/max level).
    D(A.HOUSE_SNAPSHOT_UPDATED, { snapshot = { nextRewards = { atMax = true, rewards = {
        { type = 0, valueType = 2, oldValue = 76,   newValue = 95   },  -- Rooms
        { type = 0, valueType = 0, oldValue = 3500, newValue = 4340 },  -- InteriorDecor
        { type = 1, objectName = "A Decor Object" },                    -- Object reward (ignored)
    } } } })
    local caps = HDG.Selectors:Call("projects.placementCaps", HDG.Store:GetState(), {})
    assertEq(caps.roomMax,  95,   "placementCaps atMax: roomMax = reward newValue")
    assertEq(caps.decorMax, 4340, "placementCaps atMax: decorMax = reward newValue")
    -- decor budget text/progress now read the reward-derived cap too.
    assertEq(HDG.Selectors:Call("projects.budgetText", HDG.Store:GetState(), {}),
        "|A:house-decor-budget-icon:16:16|a 0 / 4340", "budgetText decorMax from placementCaps")

    -- below max: nextRewards targets level+1, so the current cap is the oldValue.
    D(A.HOUSE_SNAPSHOT_UPDATED, { snapshot = { nextRewards = { atMax = false, rewards = {
        { type = 0, valueType = 2, oldValue = 76,   newValue = 95   },
        { type = 0, valueType = 0, oldValue = 3500, newValue = 4340 },
    } } } })
    caps = HDG.Selectors:Call("projects.placementCaps", HDG.Store:GetState(), {})
    assertEq(caps.roomMax,  76,   "placementCaps below max: roomMax = reward oldValue")
    assertEq(caps.decorMax, 3500, "placementCaps below max: decorMax = reward oldValue")

    -- pre-fetch: snapshot has no nextRewards yet -> caps 0 ("no cap" -> roomBudgetText empty).
    D(A.HOUSE_SNAPSHOT_UPDATED, { snapshot = {} })
    caps = HDG.Selectors:Call("projects.placementCaps", HDG.Store:GetState(), {})
    assertEq(caps.roomMax,  0, "placementCaps pre-fetch: roomMax 0")
    assertEq(caps.decorMax, 0, "placementCaps pre-fetch: decorMax 0")
    assertEq(HDG.Selectors:Call("projects.roomBudgetText", HDG.Store:GetState(), {}), "",
        "roomBudgetText empty when no cap (max 0)")
end

-- ===== Phase G: version branch (create) / switch / delete =====
do
    local s = freshState()
    local liveVid = seedHouse(s)
    D(A.PROJECTS_UPSERT_ROOM, { versionID = liveVid, roomID = "house:alliance:f1:r1", fields = { shape = "square_m", cell = { x = 0, y = 0, rotation = 0, locked = false } } })

    -- branch a what-if from live: copies the room, becomes active, independent.
    -- The reducer MINTS the versionID from the counter (the payload no longer carries
    -- one) -- capture the active id it assigned and use it downstream.
    D(A.PROJECTS_CREATE_VERSION, { houseID = "house:alliance", name = "What-if 1", basedOn = liveVid, createdAt = 5 })
    local H = s.account.projects.houses["house:alliance"]
    local whatif = H.activeVersionID
    assertTrue(whatif and whatif ~= liveVid, "create_version mints a new what-if and makes it active")
    assertEq(H.currentVersionID, liveVid, "current/live version unchanged")
    assertTrue(s.account.projects.versions[whatif].rooms["house:alliance:f1:r1"], "what-if copied the live room")

    -- deep-copy independence: editing the what-if doesn't touch live
    D(A.PROJECTS_UPSERT_ROOM, { versionID = whatif, roomID = "house:alliance:f1:r1", fields = { cell = { x = 9, y = 9, rotation = 0, locked = false } } })
    assertEq(s.account.projects.versions[whatif].rooms["house:alliance:f1:r1"].cell.x, 9, "what-if room moved")
    assertEq(s.account.projects.versions[liveVid].rooms["house:alliance:f1:r1"].cell.x, 0, "live room unchanged (deep copy)")

    -- versionTabs: both listed, Live first
    local tabs = HDG.Selectors:Call("projects.versionTabs", HDG.Store:GetState(), {})
    assertEq(#tabs, 2, "two versions listed")
    assertTrue(tabs[1].isCurrent, "Live sorts first")

    -- switch active to live and back
    D(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:alliance", versionID = liveVid })
    assertEq(H.activeVersionID, liveVid, "switch active to live")
    D(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:alliance", versionID = whatif })
    assertEq(H.activeVersionID, whatif, "switch back to what-if")

    -- delete the active what-if -> active falls back to live
    D(A.PROJECTS_DELETE_VERSION, { houseID = "house:alliance", versionID = whatif })
    assertNil(s.account.projects.versions[whatif], "what-if deleted")
    assertEq(H.activeVersionID, liveVid, "active fell back to live")

    -- delete refuses the live/current version
    D(A.PROJECTS_DELETE_VERSION, { houseID = "house:alliance", versionID = liveVid })
    assertTrue(s.account.projects.versions[liveVid], "delete refuses the current/live version")
end

-- ===== Phase G slice 2: room shopping list (what-if-vs-reality, expanded) =====
do
    local s = freshState()
    local liveVid = seedHouse(s)
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = liveVid, roomID = "house:alliance:f1:r1", fields = { shape = "square_m", cell = { x = 0, y = 0, rotation = 0, locked = false } } })

    -- Stock mode (active == live): empty shopping list.
    assertTrue(not HDG.Selectors:Call("projects.hasShoppingList", HDG.Store:GetState(), {}), "stock mode -> no shopping list")

    -- Branch a what-if: swap the square_m for two hallways.
    D(A.PROJECTS_CREATE_VERSION, { houseID = "house:alliance", name = "What-if", basedOn = liveVid, createdAt = 7 })
    local wid = s.account.projects.houses["house:alliance"].activeVersionID   -- reducer-minted id
    D(A.PROJECTS_DELETE_ROOM, { versionID = wid, roomID = "house:alliance:f1:r1" })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = wid, roomID = "house:alliance:f1:ra1", fields = { shape = "hallway", cell = { x = 0, y = 0, rotation = 0, locked = false } } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = wid, roomID = "house:alliance:f1:ra2", fields = { shape = "hallway", cell = { x = 5, y = 0, rotation = 0, locked = false } } })

    assertTrue(HDG.Selectors:Call("projects.hasShoppingList", HDG.Store:GetState(), {}), "what-if with a diff -> shopping list")
    local rows = HDG.Selectors:Call("projects.shoppingListRows", HDG.Store:GetState(), {})
    assertEq(rows[1].kind, "build", "build rows sort first")
    assertEq(rows[1].count, 2, "build 2 hallways")
    assertEq(rows[1].weight, 6, "hallway weight 3 x 2 = 6")
    local removeRow
    for _, r in ipairs(rows) do if r.kind == "remove" then removeRow = r end end
    assertTrue(removeRow, "a remove row exists")
    assertEq(removeRow.count, 1, "remove 1 square_m")
    assertEq(removeRow.weight, 12, "square_m weight 12")
end

-- ===== Phase G slice 3: mode (Stock = live / What-if = branched) =====
do
    local s = freshState()
    local liveVid = seedHouse(s)
    -- captured house, active == live -> Stock mode
    assertTrue(HDG.Selectors:Call("projects.isStockMode", HDG.Store:GetState(), {}), "active == live -> Stock mode")
    assertTrue(not HDG.Selectors:Call("projects.isWhatIfMode", HDG.Store:GetState(), {}), "active == live -> not What-if")

    -- branch -> active is a what-if -> What-if mode
    D(A.PROJECTS_CREATE_VERSION, { houseID = "house:alliance", basedOn = liveVid, createdAt = 3 })
    assertTrue(HDG.Selectors:Call("projects.isWhatIfMode", HDG.Store:GetState(), {}), "active == what-if -> What-if mode")
    assertTrue(not HDG.Selectors:Call("projects.isStockMode", HDG.Store:GetState(), {}), "active == what-if -> not Stock")

    -- switch back to live -> Stock again
    D(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:alliance", versionID = liveVid })
    assertTrue(HDG.Selectors:Call("projects.isStockMode", HDG.Store:GetState(), {}), "switched back to live -> Stock")
end

-- ===== Phase G slice 4: standalone planning (design from scratch, no capture) =====
do
    local s = freshState()
    -- No house captured. Create the faction house + a scratch version (basedOn nil).
    D(A.PROJECTS_UPSERT_HOUSE, { houseID = "house:alliance", fields = { name = "My House" } })
    D(A.PROJECTS_CREATE_VERSION, { houseID = "house:alliance", name = "Design 1", basedOn = nil, createdAt = 1 })
    local H = s.account.projects.houses["house:alliance"]
    local scratch = H.activeVersionID
    assertTrue(scratch, "scratch version is active (reducer-minted)")
    assertNil(H.currentVersionID, "no captured reality (currentVersionID nil)")

    -- Mode: what-if (palette unlocked), NOT stock.
    assertTrue(HDG.Selectors:Call("projects.isWhatIfMode", HDG.Store:GetState(), {}), "standalone design -> what-if (palette unlocked)")
    assertTrue(not HDG.Selectors:Call("projects.isStockMode", HDG.Store:GetState(), {}), "standalone design -> not stock")

    -- Place a room; the shopping list shows it as "to build" (no reality to diff against).
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = scratch, roomID = "house:alliance:f1:r1", fields = { shape = "square_m", cell = { x = 0, y = 0, rotation = 0, locked = false } } })
    local rows = HDG.Selectors:Call("projects.shoppingListRows", HDG.Store:GetState(), {})
    assertEq(#rows, 1, "shopping list has the placed room")
    assertEq(rows[1].kind, "build", "everything is 'to build' (no reality)")
    assertEq(rows[1].count, 1, "1 square_m to build")

    -- Capture later promotes a fresh Live version; the scratch design survives as a what-if.
    D(A.PROJECTS_CAPTURE_COMMIT, { houseID = "house:alliance", lastCapturedAt = 9 })
    assertTrue(H.currentVersionID, "capture mints a live/current version")
    assertTrue(H.currentVersionID ~= scratch, "the scratch design is NOT the live version")
    assertTrue(s.account.projects.versions[scratch], "the scratch design survives capture")
end

-- ===== Phase G slice 5: version-scoped crate authoring (no key collision) =====
do
    local s = freshState()
    local live = seedHouse(s)
    local R = "house:alliance:f1:r1"
    D(A.PROJECTS_UPSERT_ROOM, { versionID = live, roomID = R, fields = { shape = "square_m", cell = { x = 0, y = 0, rotation = 0, locked = false } } })
    -- crate on the LIVE room (version-scoped key)
    D(A.CRATE_UPSERT, { crateID = "crate:" .. live .. ":" .. R, fields = { parent = R, versionID = live, name = "Live crate" } })
    -- branch a what-if (copies r1) + a DIFFERENT crate on the SAME room
    D(A.PROJECTS_CREATE_VERSION, { houseID = "house:alliance", basedOn = live, createdAt = 2 })
    local wf = s.account.projects.houses["house:alliance"].activeVersionID   -- reducer-minted id
    D(A.CRATE_UPSERT, { crateID = "crate:" .. wf .. ":" .. R, fields = { parent = R, versionID = wf, name = "What-if crate" } })
    -- both coexist (the version-scoped keys don't collide)
    assertTrue(s.account.collections["crate:" .. live .. ":" .. R], "live crate exists")
    assertTrue(s.account.collections["crate:" .. wf .. ":" .. R], "what-if crate exists (separate key)")

    -- crateDetail resolves the ACTIVE version's crate (active is the what-if after CREATE_VERSION)
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedRoomID", value = R } })
    assertEq(HDG.Selectors:Call("projects.crateDetail", HDG.Store:GetState(), {}).crateName, "What-if crate", "crateDetail shows the active what-if's crate")
    D(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:alliance", versionID = live })
    assertEq(HDG.Selectors:Call("projects.crateDetail", HDG.Store:GetState(), {}).crateName, "Live crate", "crateDetail shows the live crate after switching")
end

-- ===== Category nav tree snapshot (CATALOG_CATEGORY_TREE_UPDATED) =====
-- Producer-side reducer: the observer dispatches the Blizzard category/subcategory
-- nav snapshot; selectors (curator + projects picker) read session.house.categoryTree.
do
    local s = freshState()
    local ct0 = s.session.house.categoryTree
    assertEq(ct0.tick, 0, "categoryTree starts at tick 0")
    assertEq(next(ct0.byID), nil, "categoryTree starts empty")

    D(A.CATALOG_CATEGORY_TREE_UPDATED, {
        byID = {
            [10] = { id = 10, name = "Accents", iconBase = "category-icons_accents", orderIndex = 1,
                     subcategoryIDs = { 100 }, anyStoredEntries = true },
            [20] = { id = 20, name = "Furniture", iconBase = "category-icons_furniture", orderIndex = 2,
                     subcategoryIDs = {}, anyStoredEntries = false },
        },
        subcatByID = {
            [100] = { id = 100, name = "Wall Hangings", iconBase = "category-icons_wallhangings",
                      orderIndex = 1, parentCategoryID = 10, anyStoredEntries = true },
        },
        rootIDs = { 10, 20 },
    })
    local ct = s.session.house.categoryTree
    assertEq(ct.tick, 1, "tick bumps on snapshot")
    assertEq(ct.byID[10].name, "Accents", "category name stored")
    assertEq(ct.byID[10].iconBase, "category-icons_accents", "stripped iconBase stored")
    assertEq(ct.byID[20].anyStoredEntries, false, "ownership flag stored (false = no owned)")
    assertEq(ct.subcatByID[100].parentCategoryID, 10, "subcategory parent stored")
    assertEq(ct.rootIDs[1], 10, "rootIDs preserve order")

    -- Atomic replace: a second snapshot wholly swaps byID (no stale merge).
    D(A.CATALOG_CATEGORY_TREE_UPDATED, { byID = { [30] = { id = 30, name = "Lighting" } }, subcatByID = {}, rootIDs = { 30 } })
    assertEq(s.session.house.categoryTree.tick, 2, "tick bumps again")
    assertNil(s.session.house.categoryTree.byID[10], "old categories cleared on atomic replace")
    assertEq(s.session.house.categoryTree.byID[30].name, "Lighting", "new snapshot applied")
end

-- ===== Crate decor quantities (stepper: add increments, decrement, remove at 0) =====
do
    local s = freshState()
    local CR = "crate:v1:room1"
    s.account.collections[CR] = { id = CR, type = "crate", parent = "room1", versionID = "v1", name = "Crate", decor = {} }
    D(A.CRATE_ADD_DECOR, { crateID = CR, decorID = 100 })
    assertEq(s.account.collections[CR].decor[1].count, 1, "new decor adds at count 1")
    D(A.CRATE_ADD_DECOR, { crateID = CR, decorID = 100 })
    assertEq(s.account.collections[CR].decor[1].count, 2, "re-add (no count) increments")
    D(A.CRATE_ADD_DECOR, { crateID = CR, decorID = 100, count = 6 })
    assertEq(s.account.collections[CR].decor[1].count, 6, "explicit count SETS (import / stepper set)")
    D(A.CRATE_DECREMENT_DECOR, { crateID = CR, decorID = 100 })
    assertEq(s.account.collections[CR].decor[1].count, 5, "decrement -1")
    for _ = 1, 5 do D(A.CRATE_DECREMENT_DECOR, { crateID = CR, decorID = 100 }) end
    assertEq(#s.account.collections[CR].decor, 0, "decrement to 0 removes the entry")
end

-- ===== Crate ledger (landing): orphan bay + inventory composition =====
do
    local S = HDG.Selectors
    local s = freshState()
    local vid = seedHouse(s, "house:alliance")
    local R1, R2 = "house:alliance:f1:r1", "house:alliance:f1:r2"
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = R1, fields = { shape = "square_m", name = "Hall" } })
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = R2, fields = { shape = "cross_shape", name = "Nook" } })
    -- R1 crate holds decor (-> inventory); R2 has an EMPTY crate (-> excluded from inventory).
    D(A.CRATE_UPSERT, { crateID = "c:1", fields = { parent = R1, versionID = vid, name = "Hall stuff" } })
    D(A.CRATE_ADD_DECOR, { crateID = "c:1", decorID = 111, count = 3 })
    D(A.CRATE_ADD_DECOR, { crateID = "c:1", decorID = 222, count = 2 })
    D(A.CRATE_UPSERT, { crateID = "c:2", fields = { parent = R2, versionID = vid, name = "Empty" } })
    -- An orphan-to-be: a packed crate on R2, then demolish R2.
    D(A.CRATE_UPSERT, { crateID = "c:orphan", fields = { parent = R2, versionID = vid, name = "Keepme" } })
    D(A.CRATE_ADD_DECOR, { crateID = "c:orphan", decorID = 333, count = 6 })
    D(A.PROJECTS_DELETE_ROOM, { versionID = vid, roomID = R2, ts = 500 })   -- orphans c:2 (empty) + c:orphan

    assertEq(S:Call("projects.orphanCount", s, {}), 2, "orphanCount = all parentless crates")
    assertTrue(S:Call("projects.hasOrphans", s, {}), "hasOrphans true with orphans")

    local rows = S:Call("projects.crateLedgerRows", s, {})
    local byKind, invRow, orphanRow, hdr = {}, nil, nil, nil
    for _, r in ipairs(rows) do
        byKind[r.kind] = (byKind[r.kind] or 0) + 1
        if r.kind == "inventoryRoom" then invRow = r end
        if r.kind == "orphan" and r.name == "Keepme" then orphanRow = r end
        if r.kind == "orphanHeader" then hdr = r end
    end
    assertEq(byKind.orphanHeader, 1, "one orphan header")
    assertEq(byKind.orphan, 2, "two orphan rows")
    assertNil(byKind.orphanEmpty, "no empty-state row when orphans exist")
    assertEq(byKind.inventoryHeader, 1, "one inventory header")
    assertEq(byKind.inventoryRoom, 1, "only the room WITH decor appears (empty room excluded)")
    assertEq(invRow.roomID, R1, "inventory row is the room holding decor")
    assertEq(invRow.decorCount, 5, "inventory decor summed (3+2)")
    assertEq(orphanRow.decorCount, 6, "orphan decor summed")
    assertEq(orphanRow.was, "Nook", "orphan 'was' uses lastKnownRoomName")
    assertEq(#hdr.orphanIDs, 2, "orphan header carries all orphan IDs for bulk reclaim")

    local targets = S:Call("projects.reclaimTargets", s, {})
    assertEq(#targets, 1, "reclaim targets = remaining rooms")
    assertEq(targets[1].roomID, R1, "reclaim target is the surviving room R1")

    -- Resolve all orphans -> empty state; reclaimed crate folds into R1's inventory.
    D(A.CRATE_DELETE, { crateID = "c:2" })
    D(A.CRATE_REATTACH, { crateID = "c:orphan", versionID = vid, roomID = R1 })
    assertEq(S:Call("projects.orphanCount", s, {}), 0, "no orphans after discard + reclaim")
    local rows2, has2, inv2 = S:Call("projects.crateLedgerRows", s, {}), {}, nil
    for _, r in ipairs(rows2) do
        has2[r.kind] = true
        if r.kind == "inventoryRoom" and r.roomID == R1 then inv2 = r end
    end
    assertTrue(has2.orphanEmpty, "orphanEmpty row shown when no orphans")
    assertTrue(not has2.orphanHeader, "no orphan header when empty")
    assertEq(inv2.decorCount, 11, "inventory sums all crates on the room (reclaimed 6 + existing 5)")
end

-- ===== CRATE_DETACH: orphan one crate off its room (reattachable, not deleted) =====
do
    local s = freshState()
    local vid = seedHouse(s, "house:alliance")
    local R = "house:alliance:f1:r1"
    D(A.PROJECTS_UPSERT_ROOM, { versionID = vid, roomID = R, fields = { shape = "square_m", name = "Study" } })
    D(A.CRATE_UPSERT, { crateID = "c:d", fields = { parent = R, versionID = vid, name = "Keepme" } })
    D(A.CRATE_ADD_DECOR, { crateID = "c:d", decorID = 42, count = 2 })

    D(A.CRATE_DETACH, { crateID = "c:d", ts = 999 })
    local crate = s.account.collections["c:d"]
    assertTrue(crate ~= nil, "detached crate still EXISTS (not deleted)")
    assertNil(crate.parent, "detach clears parent")
    assertNil(crate.versionID, "detach clears versionID")
    assertEq(crate.lastKnownRoomID, R, "detach stamps lastKnownRoomID")
    assertEq(crate.lastKnownShape, "square_m", "detach stamps lastKnownShape")
    assertEq(crate.lastKnownRoomName, "Study", "detach stamps lastKnownRoomName")
    assertEq(crate.orphanedAt, 999, "detach stamps orphanedAt")
    assertEq(#crate.decor, 1, "detached crate keeps its decor")

    -- It now reads as an orphan in the ledger, and the room no longer has a crate.
    assertEq(HDG.Selectors:Call("projects.orphanCount", s, {}), 1, "detached crate counts as an orphan")
end

print("test_reducers_projects.lua passed")
