-- 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_Actions.lua")
dofile("../Core/HDGR_Resolvers.lua")
dofile("../Core/HDGR_Format.lua")
dofile("../Core/HDGR_Paths.lua")
dofile("../Core/HDGR_Log.lua")
dofile("../Core/HDGR_ProjectsIDs.lua")
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_StoreFurnishings.lua")
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);
-- v7: layouts hold placements; rooms are persistent (account.rooms).
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

-- ===== 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.layouts,
        "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 (merge) =====
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")

end

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


-- ===== CAPTURE_COMMIT (atomic: upsert + delete + lastCapturedAt) =====
do
    local s = freshState()
    local lid = seedHouse(s)
    D(A.PROJECTS_CAPTURE_COMMIT, {
        houseID = "house:alliance", lastCapturedAt = 555,
        rooms = { ["house:alliance:f1:r1"] = { shape = "entry" } },
    })
    assertEq(s.account.projects.houses["house:alliance"].lastCapturedAt, 555, "commit stamps lastCapturedAt")
    -- v7: a new physical room with no lineage arrives as an unassigned slot.
    local lay = s.account.projects.layouts[lid]
    local slotKey
    for key, pl in pairs(lay.placements) do
        if pl.capturedID == "house:alliance:f1:r1" then slotKey = key end
    end
    assertTrue(slotKey, "commit places the captured room as an unassigned slot")
    assertEq(lay.placements[slotKey].shape, "entry", "slot carries the captured shape")
    -- deleteRoomIDs drops the slot again (partial-capture removal)
    D(A.PROJECTS_CAPTURE_COMMIT, {
        houseID = "house:alliance", lastCapturedAt = 556,
        rooms = {}, deleteRoomIDs = { "house:alliance:f1:r1" },
    })
    assertNil(lay.placements[slotKey], "deleteRoomIDs removes the captured slot")
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





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


-- ===== 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: v8 recapture prep -- placements PERSIST ========
-- (tags survive via ApplyCapture's in-place capturedID match); only
-- capture-owned placements above payload.maxFloor are pruned.
do
    local s, H = freshState(), "house:alliance"
    local lid = seedHouse(s, H)
    D(A.FURN_ROOM_CREATE, { shape = "hallway", name = "Hall" })
    local rid = s.session.furn.lastRoomID
    D(A.LAYOUT_PLACE, { layoutID = lid, roomID = rid, floor = 1, x = 0, y = 0 })
    D(A.LAYOUT_PLACE, { layoutID = lid, shape = "square_s", floor = 3, x = 5, y = 5 })
    -- A capture-owned placement on floor 2 (the floor we'll "delete").
    D(A.PROJECTS_CAPTURE_COMMIT, { houseID = H, lastCapturedAt = 700,
        rooms = { ["house:alliance:f2:r1"] = { shape = "square_m" } } })
    local lay, n = s.account.projects.layouts[lid], 0
    for _ in pairs(lay.placements) do n = n + 1 end
    assertEq(n, 3, "precondition: tagged + doodle + capture-owned present")

    D(A.PROJECTS_CLEAR_HOUSE, { houseID = H })
    n = 0; for _ in pairs(lay.placements) do n = n + 1 end
    assertEq(n, 3, "CLEAR without maxFloor keeps every placement (tags survive recapture)")
    assertEq(s.session.furn.lastCapture.matched, 0, "CLEAR resets the capture echo")

    D(A.PROJECTS_CLEAR_HOUSE, { houseID = H, maxFloor = 1 })
    local keptCaptured = false
    n = 0
    for _, pl in pairs(lay.placements) do
        n = n + 1
        if pl.capturedID then keptCaptured = true end
    end
    assertEq(n, 2, "maxFloor prune drops only the capture-owned upstairs placement")
    assertTrue(not keptCaptured, "the pruned one was the capture-owned record")
    assertTrue(s.account.rooms[rid], "the ROOM persists -- rooms are never destroyed by capture flows")

    D(A.PROJECTS_CLEAR_HOUSE, {})   -- empty-payload safety
end
-- ===== projects.crateDetail (Phase 3 selector: room's crate -> resolved rows) =====
do
    local s = freshState()
    -- v7: the panel projects the selected persistent room's LOCAL furnishing set.
    D(A.FURN_ROOM_CREATE, { shape = "square_m", name = "Throne Room" })
    local roomID = s.session.furn.lastRoomID
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedRoomID", value = roomID } })
    D(A.FURN_SET_CREATE, { name = "Throne Hall", isLocal = true, ownerRoom = roomID })
    local cid = s.session.furn.lastSetID
    D(A.FURN_ROOM_EQUIP, { roomID = roomID, setID = cid })
    D(A.FURN_SET_ITEM_ADD, { setID = cid, itemID = 111, count = 4 })
    D(A.FURN_SET_ITEM_ADD, { setID = cid, itemID = 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 (the local set)")
    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].setID, cid, "crateDetail: row carries setID (stepper target)")
    assertTrue(cd.decorRows[1].kind == "item" and cd.decorRows[1].isLocal,
        "crateDetail: rows are projectsFurnRow item envelopes")
    assertEq(cd.decorRows[1].name, "decor-111", "crateDetail: name via resolver boundary")

    D(A.FURN_SET_ITEM_REMOVE, { setID = cid, itemID = 111, all = true })
    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 lid = seedHouse(s)
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    -- v7: a doodle slot placed on the active layout renders like a room.
    D(A.LAYOUT_PLACE, { layoutID = lid, shape = "square_m", floor = 1, x = 2, y = 3 })
    local roomID = "slot:1"

    local pl = HDG.Selectors:Call("projects.planLayout", HDG.Store:GetState(), {})
    assertTrue(pl.layout[roomID] ~= nil, "planLayout includes the active layout's placement")
    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 placement (LAYOUT_MOVE / LAYOUT_REMOVE_PLACEMENT) =====
do
    local s = freshState()
    local lid = seedHouse(s)
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.LAYOUT_PLACE, { layoutID = lid, shape = "hallway", floor = 1, x = 1, y = 1 })
    local roomID = "slot:1"
    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.LAYOUT_MOVE, { layoutID = lid, key = roomID, x = 4, y = 2 })
    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.LAYOUT_MOVE, { layoutID = lid, key = roomID, rotation = 1 })
    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.LAYOUT_REMOVE_PLACEMENT, { layoutID = lid, key = roomID })
    assertTrue(pl().layout[roomID] == nil, "E6 remove deletes the placement")
end

-- ===== E5: plan validation (overlap + room cost) over the active version =====
do
    local s = freshState()
    local lid = seedHouse(s)
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.LAYOUT_PLACE, { layoutID = lid, shape = "square_s", floor = 1, x = 0, y = 0 })
    D(A.LAYOUT_PLACE, { layoutID = lid, shape = "square_s", floor = 1, x = 5, y = 0 })
    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 slot:2 onto slot:1 -> overlap
    D(A.LAYOUT_MOVE, { layoutID = lid, key = "slot:2", x = 0, y = 0 })
    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 lid = seedHouse(s)   -- current/reality layout
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.LAYOUT_PLACE, { layoutID = lid, shape = "square_m", floor = 1, x = 0, y = 0 })

    -- 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 layout (hallway instead of square_m) and make it active.
    local wid = "version_whatif1"
    s.account.projects.layouts[wid] = { houseID = "house:alliance", name = "What-if", basedOn = lid, placements = {} }
    D(A.LAYOUT_PLACE, { layoutID = wid, shape = "hallway", floor = 1, x = 0, y = 0 })
    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.changeSeq, 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.LAYOUT_PLACE, { layoutID = vid, shape = "square_m", floor = 1, x = 0,  y = 0 })   -- budget 12
    D(A.LAYOUT_PLACE, { layoutID = vid, shape = "hallway",  floor = 1, x = 10, y = 0 })   -- 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 liveLid = seedHouse(s)
    -- v7: a persistent room placed in Live.
    D(A.FURN_ROOM_CREATE, { shape = "square_m", name = "Bedroom" })
    local rid = s.session.furn.lastRoomID
    D(A.LAYOUT_PLACE, { layoutID = liveLid, roomID = rid, floor = 1, x = 0, y = 0 })

    -- branch a what-if: placements COPY; the room is SHARED BY REFERENCE.
    D(A.PROJECTS_CREATE_VERSION, { houseID = "house:alliance", name = "What-if 1", basedOn = liveLid, createdAt = 5 })
    local H = s.account.projects.houses["house:alliance"]
    local whatif = H.activeVersionID
    assertTrue(whatif and whatif ~= liveLid, "create_version mints a new what-if layout and makes it active")
    assertEq(H.currentVersionID, liveLid, "current/live layout unchanged")
    local function _taggedKey(layoutID)
        for k, pl in pairs(s.account.projects.layouts[layoutID].placements) do
            if pl.roomID == rid then return k end
        end
    end
    local wkey = _taggedKey(whatif)
    assertTrue(wkey, "what-if copied the placement (room shared by tag)")
    assertEq(s.session.furnIndex.roomLayouts[rid][whatif], 1,
        "branching counts the copied tags into the reverse index (review 17 #1)")

    -- placement independence: moving in the what-if doesn't touch live
    D(A.LAYOUT_MOVE, { layoutID = whatif, key = wkey, x = 9, y = 9 })
    assertEq(s.account.projects.layouts[whatif].placements[wkey].x, 9, "what-if placement moved")
    assertEq(s.account.projects.layouts[liveLid].placements[_taggedKey(liveLid)].x, 0,
        "live placement unchanged (independent copy)")

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

    -- switch active to live and back
    D(A.PROJECTS_SET_ACTIVE_VERSION, { houseID = "house:alliance", versionID = liveLid })
    assertEq(H.activeVersionID, liveLid, "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; the room PERSISTS; index GC'd
    D(A.PROJECTS_DELETE_VERSION, { houseID = "house:alliance", versionID = whatif })
    assertNil(s.account.projects.layouts[whatif], "what-if layout deleted")
    assertEq(H.activeVersionID, liveLid, "active fell back to live")
    assertTrue(s.account.rooms[rid], "room persists (layouts are disposable)")
    assertNil((s.session.furnIndex.roomLayouts[rid] or {})[whatif], "reverse index GC'd on layout delete")

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

-- ===== Phase G slice 2: room shopping list (what-if-vs-reality, expanded) =====
do
    local s = freshState()
    local liveLid = seedHouse(s)
    HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT, payload = { view = "projects", key = "selectedFloor", value = 1 } })
    D(A.LAYOUT_PLACE, { layoutID = liveLid, shape = "square_m", floor = 1, x = 0, y = 0 })

    -- 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 = liveLid, createdAt = 7 })
    local wid = s.account.projects.houses["house:alliance"].activeVersionID   -- reducer-minted id
    D(A.LAYOUT_REMOVE_PLACEMENT, { layoutID = wid, key = "slot:1" })
    D(A.LAYOUT_PLACE, { layoutID = wid, shape = "hallway", floor = 1, x = 0, y = 0 })
    D(A.LAYOUT_PLACE, { layoutID = wid, shape = "hallway", floor = 1, x = 5, y = 0 })

    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 } })
    -- From-scratch designs seed an Entry slot (every layout needs its anchor).
    local lay = s.account.projects.layouts[scratch]
    assertTrue(lay.placements["slot:1"] and lay.placements["slot:1"].shape == "entry",
        "scratch design seeds an Entry slot centre-canvas")
    D(A.LAYOUT_PLACE, { layoutID = scratch, shape = "square_m", floor = 1, x = 0, y = 0 })
    local rows = HDG.Selectors:Call("projects.shoppingListRows", HDG.Store:GetState(), {})
    assertEq(#rows, 2, "shopping list has the seeded entry + the placed room")
    local byShape = {}
    for _, r in ipairs(rows) do byShape[r.shape] = r end
    assertEq(byShape.square_m.kind, "build", "everything is 'to build' (no reality)")
    assertEq(byShape.square_m.count, 1, "1 square_m to build")
    assertTrue(byShape.entry ~= nil, "seeded entry shows in the build list")

    -- 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.layouts[scratch], "the scratch design survives capture")
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.changeSeq, 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.changeSeq, 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.changeSeq, 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


-- ===== Rooms list (landing): every persistent room w/ furnishings count =====
do
    local S = HDG.Selectors
    local s = freshState()
    D(A.FURN_ROOM_CREATE, { shape = "square_m", name = "Hall" })
    local r1 = s.session.furn.lastRoomID
    D(A.FURN_ROOM_CREATE, { shape = "cross_shape", name = "Nook" })
    local r2 = s.session.furn.lastRoomID
    D(A.FURN_SET_CREATE, { name = "Hall stuff", isLocal = true, ownerRoom = r1,
                           items = { { id = 111, count = 3 }, { id = 222, count = 2 } } })
    D(A.FURN_ROOM_EQUIP, { roomID = r1, setID = s.session.furn.lastSetID })

    local rows = S:Call("projects.roomsRows", s, {})
    assertEq(#rows, 2, "every persistent room listed (headers live outside the box)")
    assertEq(S:Call("projects.roomsHeaderText", s, {}), "MY DESIGNS (2)", "header band text carries the count")
    local byID = {}
    for _, r in ipairs(rows) do
        if r.kind == "room" then byID[r.roomID] = r end
    end
    assertEq(byID[r1].decorCount, 5, "furnishings summed (3+2)")
    assertEq(byID[r2].decorCount, 0, "unfurnished room listed at 0 (an ordinary row)")
    assertEq(byID[r1].name, "Hall", "row name = room name")
    assertEq(byID[r2].layoutCount, 0, "room placed nowhere reads 'unplaced' (0 layouts)")
    assertEq(#byID[r1].setChips, 0, "local set is not a chip (chips = library sets only)")
end

print("test_reducers_projects.lua passed")
