-- Smoke: filter state selectors + new filter composition.
-- Covers the surface added in 9b-first-cut:
--   - decor.topFilter / decor.activeTag selectors
--   - decor.topFilter.active_<value> generators
--   - decor.tagsForFilter for the static 'all' / 'crafted' / 'dyed' / 'dyeable' buckets
--   - decor.matchesTopFilter for the 'dyed' / 'dyeable' branches
--   - decor.matchesTag for Favorites / Crafted / House XP / nil / "All Decor"
--   - decor.items composition (especially cold-state hide-unreleased behaviour --
--     would have caught the fast-path bug)
--   - decor.filterActive on each filter axis
--   - COLLECTION_OWNERSHIP_DELTA reducer atomic apply
-- HDG.TagData branches (sizes/factions/etc.) need the Blizzard catalog API mocked;
-- that's in test_tagdata_smoke.lua.

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_StoreFurnishings.lua")
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_Selectors.lua")
-- Theme + Expansion: needed by decor.selectedItem.statusLabel /
-- sourceLabel selectors (which call HDG.Theme:ColorCode + HDG.Expansion).
-- Added after this test was written; load order: post-Constants, pre-selectors.
dofile("../UI/HDGR_Theme.lua")
dofile("../Core/HDGR_Expansion.lua")
dofile("../UI/HDGR_DecorFormat.lua")  -- HDG.DecorFormat; decor.selectedItem.statusLabel calls F:Collection
dofile("../UI/HDGR_Selectors_Status.lua")
-- Phase 3a: decor.allItems reads HousingCatalogObserver, not AllDecorDB.
-- Stub the observer here; byItemID + byDecorID seeded from AllDecorDB after load.
HDG.HousingCatalogObserver = HDG.HousingCatalogObserver or {}
-- Canonical IsOwned helper stub (mirrors production formula
-- quantity + remainingRedeemable + numPlaced > 0).
HDG.HousingCatalogObserver.IsOwned = HDG.HousingCatalogObserver.IsOwned
    or function(self, rowOrID)
        local row
        if type(rowOrID) == "table" then row = rowOrID
        elseif type(rowOrID) == "number" then
            row = HDG.HousingCatalogObserver.byItemID and HDG.HousingCatalogObserver.byItemID[rowOrID]
        end
        if not row then return false end
        return ((row.quantity or 0)
              + (row.remainingRedeemable or 0)
              + (row.numPlaced or 0)) > 0
    end

local obs = HDG.HousingCatalogObserver
obs.byItemID     = obs.byItemID     or {}
obs.byDecorID    = obs.byDecorID    or {}
obs.tagIDToGroup = obs.tagIDToGroup or {}
function obs:IsReady()
    local s = HDG.Store:GetState().session.catalog
    return s and s.status == "ready"
end
function obs:GetRow(itemID)
    if not self:IsReady() then return nil end
    return self.byItemID[itemID]
end
function obs:GetIcon(itemID)
    local row = self:GetRow(itemID)
    return row and row.iconTexture
end
function obs:GetExpansionForItem(itemID)
    local row = self:GetRow(itemID)
    if not (row and row.dataTagsByID) then return nil end
    for tagID, displayName in pairs(row.dataTagsByID) do
        if self.tagIDToGroup[tagID] == "Expansion" then return displayName end
    end
    return nil
end
function obs:IterateRows(fn)
    if not self:IsReady() then return end
    for itemID, row in pairs(self.byItemID) do fn(itemID, row) end
end
dofile("../UI/HDGR_Selectors_Decor.lua")
-- See test_acquisition_smoke for stub rationale (cookbook 10).
HDG.ItemNameResolver = HDG.ItemNameResolver or {
    ResolveIcon = function() return nil end,
    ResolveName = function() return nil end,
}
dofile("../UI/HDGR_Selectors_Acquisition.lua")
dofile("../UI/HDGR_Selectors_Recipes.lua")
dofile("../data/HDGR_DecorDB.lua")
-- AllDecorDB is retired (archived). Inline fixture seeded into obs.byItemID below.
-- ItemAugment data not loaded in this test; empty table satisfies the
-- StaticData guard. Phase 3a defaults sourceType to 0 when augment absent.
_G.HDGR_ItemAugment = _G.HDGR_ItemAugment or {}
dofile("../Modules/HDGR_StaticData.lua")

local function assertEq(actual, expected, label)
    if actual ~= expected then
        error(label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual), 2)
    end
end
local function assertTrue(v, label) if not v then error(label, 2) end end
local function assertFalse(v, label) if v then error(label, 2) end end

HDG_DB = {}
HDG.Store:LoadFromSavedVariables()
local state = HDG.Store:GetState()
local A     = HDG.Constants.ACTIONS

-- Phase 3a: seed HousingCatalogObserver.byItemID with an inline fixture.
-- AllDecorDB is retired; rows are self-contained. canCustomize must be set
-- correctly on 235523/235633 so the isDyeable predicate tests pass.
-- 235523 needs canCustomize=true; 235633 is overridden to false at the
-- test-assertion site (line below: obs.byItemID[235633].canCustomize = false).
do
    local seeded = {
        [235523] = { itemID = 235523, decorID = 494, name = "Sturdy Wooden Chair",
                     iconTexture = 134400, iconAtlas = nil, canCustomize = true,
                     isOwned = false, quantity = 0, numPlaced = 0, placementCost = 1,
                     isAllowedIndoors = true, isAllowedOutdoors = true,
                     firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
        [235633] = { itemID = 235633, decorID = 498, name = "Circular Woolen Rug",
                     iconTexture = 134400, iconAtlas = nil, canCustomize = true,
                     isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                     isAllowedIndoors = true, isAllowedOutdoors = true,
                     firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
        [235675] = { itemID = 235675, decorID = 377, name = "Three-Candle Wrought Iron Chandelier",
                     iconTexture = 134400, iconAtlas = nil, canCustomize = true,
                     isOwned = false, quantity = 0, numPlaced = 0, placementCost = 1,
                     isAllowedIndoors = true, isAllowedOutdoors = true,
                     firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
        [235677] = { itemID = 235677, decorID = 383, name = "Wrought Iron Floor Lamp",
                     iconTexture = 134400, iconAtlas = nil, canCustomize = false,
                     isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                     isAllowedIndoors = true, isAllowedOutdoors = true,
                     firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
        [238860] = { itemID = 238860, decorID = 676, name = "Deluxe Val'sharah Bed",
                     iconTexture = 134400, iconAtlas = nil, canCustomize = false,
                     isOwned = false, quantity = 0, numPlaced = 0, placementCost = 3,
                     isAllowedIndoors = true, isAllowedOutdoors = true,
                     firstAcquisitionBonus = 0, dataTagsByID = {}, vendors = {}, category = nil },
    }
    obs.byItemID = seeded
    -- Phase 4.4: also build byDecorID for tagsForFilter/storedCount selectors.
    local byDecorID = {}
    for _, row in pairs(seeded) do
        if row.decorID then byDecorID[row.decorID] = row end
    end
    obs.byDecorID = byDecorID
    HDG.Store:Dispatch({
        type    = A.CATALOG_LOAD_COMPLETED,
        payload = { loadedAt = 0, itemCount = 0, vendorCount = 0, generation = 1 },
    })
end

-- ============================================================================
-- 1. Default cold state
-- ============================================================================

assertEq(HDG.Selectors:Call("decor.topFilter",      state, {}), "all",
    "topFilter default = 'all'")
assertEq(HDG.Selectors:Call("decor.activeTag",      state, {}), nil,
    "activeTag default = nil")
assertEq(HDG.Selectors:Call("decor.onlyUncollected", state, {}), false,
    "onlyUncollected default = false")
assertEq(HDG.Selectors:Call("decor.onlyStored",      state, {}), false,
    "onlyStored default = false")
assertEq(HDG.Selectors:Call("decor.filterActive",    state, {}), false,
    "filterActive false at cold defaults")

-- ============================================================================
-- 2. Per-top-filter active selectors (9 generated)
-- ============================================================================

for _, entry in ipairs(HDG.Constants.TOP_FILTERS) do
    local name = "decor.topFilter.active_" .. entry.value
    assertTrue(HDG.Selectors:Has(name), name .. " registered")
end

-- Only 'all' is active on cold state.
assertEq(HDG.Selectors:Call("decor.topFilter.active_all",      state, {}), true,
    "active_all true at cold defaults")
assertEq(HDG.Selectors:Call("decor.topFilter.active_crafted",  state, {}), false,
    "active_crafted false at cold defaults")
assertEq(HDG.Selectors:Call("decor.topFilter.active_expansions", state, {}), false,
    "active_expansions false at cold defaults")

-- Flip to 'crafted' -> active_crafted flips on, active_all flips off
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "crafted" } })
assertEq(HDG.Selectors:Call("decor.topFilter.active_all",     state, {}), false,
    "active_all false after switch to 'crafted'")
assertEq(HDG.Selectors:Call("decor.topFilter.active_crafted", state, {}), true,
    "active_crafted true after switch")

-- Invalid value is rejected (reducer validates via TOP_FILTERS membership)
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "bogus" } })
assertEq(HDG.Selectors:Call("decor.topFilter", state, {}), "crafted",
    "invalid top filter value rejected by reducer; kept 'crafted'")

-- ============================================================================
-- 3. decor.tagsForFilter -- hardcoded buckets
-- ============================================================================

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- 'all' returns the curated sub-tags. Phase 9c second-pass: dropped the
-- redundant "All Decor" entry (top='all' + no activeTag already means
-- "everything"); added Dyed/Dyeable from the retired Row 3 toggles. Hitlist
-- item 7 appended the source-awareness sub-tags Rep/Quest/Ach at the tail.
local allTags = HDG.Selectors:Call("decor.tagsForFilter", state, {})
assertEq(#allTags, 7,             "tagsForFilter('all') has 7 entries")
assertEq(allTags[1], "Favorites",   "tagsForFilter('all')[1] = 'Favorites'")
assertEq(allTags[2], "Crafted",     "tagsForFilter('all')[2] = 'Crafted'")
assertEq(allTags[3], "House XP",    "tagsForFilter('all')[3] = 'House XP'")
assertEq(allTags[4], "Dyed",        "tagsForFilter('all')[4] = 'Dyed'")
assertEq(allTags[5], "Dyeable",     "tagsForFilter('all')[5] = 'Dyeable'")
-- Rep/Quest/Ach dropped (duplicated the Sources top-filter); Placed + Redeemable added.
assertEq(allTags[6], "Placed",      "tagsForFilter('all')[6] = 'Placed'")
assertEq(allTags[7], "Redeemable",  "tagsForFilter('all')[7] = 'Redeemable'")

-- 'crafted' returns the 9 profession names from HDG.Constants.DECOR_PROFESSIONS.
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "crafted" } })
local craftedTags = HDG.Selectors:Call("decor.tagsForFilter", state, {})
assertEq(#craftedTags, 9, "tagsForFilter('crafted') has 9 profession entries")
local seen = {}
for _, p in ipairs(craftedTags) do seen[p] = true end
for _, prof in ipairs(HDG.Constants.DECOR_PROFESSIONS) do
    assertTrue(seen[prof], "profession '" .. prof .. "' present in crafted tags")
end

-- Dyed + Dyeable retired from top-row chips, moved to Row 3
-- toggles. The values 'dyed' and 'dyeable' no longer pass the topFilter
-- validator -- DECOR_SET_TOP_FILTER rejects them silently.
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "dyed" } })
assertEq(HDG.Selectors:Call("decor.topFilter", state, {}), "crafted",
    "'dyed' is no longer a valid top filter; reducer kept previous value")
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "dyeable" } })
assertEq(HDG.Selectors:Call("decor.topFilter", state, {}), "crafted",
    "'dyeable' is no longer a valid top filter")

-- ============================================================================
-- 5. decor.matchesTopFilter -- now pass-through for all top filters
-- ============================================================================

HDG.Store:Dispatch({ type = A.COLLECTION_BULK_LOAD, payload = {
    entries = {
        [100] = { decorID = 100, itemID = 235523, name = "DyableThing",
                  canCustomize = true,  isOwned = false, numStored = 0 },
        [101] = { decorID = 101, itemID = 235633, name = "PlainThing",
                  canCustomize = false, isOwned = false, numStored = 0 },
    },
    owned = {},
    live  = { [100] = true, [101] = true },
} })

-- MatchesTopFilter is pass-all for every value (Dyed/Dyeable
-- moved to Row 3 toggles which apply via decor.isDyed / decor.isDyeable
-- curried selectors composed by decor.items directly).
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })
local matchAll = HDG.Selectors:Call("decor.matchesTopFilter", state, {})
assertTrue(matchAll(235523), "matchesTopFilter('all') passes anything")
assertTrue(matchAll(235633), "matchesTopFilter('all') passes anything (2)")

-- decor.isDyeable curried filter (used by matchesTag under 'all' -> 'Dyeable')
-- Phase 4.4: isDyeable reads observer byItemID directly. AllDecorDB has
-- canCustomize=true for 235633 (Circular Woolen Rug), but this test needs a
-- non-dyeable row. Override explicitly to match test intent.
obs.byItemID[235633] = obs.byItemID[235633] or {}
obs.byItemID[235633].canCustomize = false
local isDyeable = HDG.Selectors:Call("decor.isDyeable", state, {})
assertEq(isDyeable(235523), true,  "isDyeable accepts canCustomize=true")
assertEq(isDyeable(235633), false, "isDyeable rejects canCustomize=false")

-- ============================================================================
-- 6. decor.matchesTag -- special sub-tags under 'all'
-- ============================================================================

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- nil/"All Decor" -> pass-all
local matchNil = HDG.Selectors:Call("decor.matchesTag", state, {})
assertTrue(matchNil(235523),  "matchesTag(nil activeTag) is pass-all")

HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "All Decor" } })
local matchAllDecor = HDG.Selectors:Call("decor.matchesTag", state, {})
assertTrue(matchAllDecor(235523), "matchesTag('All Decor') is pass-all")

-- 'Favorites': only favorited items pass
HDG.Store:Dispatch({ type = A.FAVORITE_TOGGLE, payload = { itemID = 235523 } })
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Favorites" } })
local matchFav = HDG.Selectors:Call("decor.matchesTag", state, {})
assertEq(matchFav(235523), true,  "matchesTag('Favorites') accepts favorited item")
assertEq(matchFav(235633), false, "matchesTag('Favorites') rejects non-favorited")

-- 'Crafted': items in account.recipes pass
HDG.Store:Dispatch({ type = A.RECIPE_KNOWLEDGE_UPDATED, payload = {
    entries = {
        [235633] = { spellID = 12345, selfKnown = true, altKnown = false },
    },
} })
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Crafted" } })
local matchCrafted = HDG.Selectors:Call("decor.matchesTag", state, {})
assertEq(matchCrafted(235633), true,  "matchesTag('Crafted') accepts recipe-backed item")
assertEq(matchCrafted(235523), false, "matchesTag('Crafted') rejects non-recipe item")

-- 'House XP': items with firstAcquisitionBonus > 0 AND not owned.
-- Phase 4.4: COLLECTION_BULK_LOAD no longer populates the observer row store
-- (BULK_LOAD reducer only writes ownedDecorIDs; _PopulateReverseIndexes runs
-- after the live catalog sweep). Seed obs.byItemID + ownedDecorIDs directly.
do
    local col = HDG.Store:GetState().account.collection
    -- 2026-05-26: ownership flows through R:IsOwned reading row counters.
    -- Set quantity=1 on owned rows; isOwned stamp is now derived (not authoritative).
    obs.byItemID[245308] = { itemID = 245308, decorID = 200, name = "XPGiver",
                             firstAcquisitionBonus = 5, isOwned = false }
    obs.byItemID[251484] = { itemID = 251484, decorID = 201, name = "XPGiverOwned",
                             firstAcquisitionBonus = 5, isOwned = true, quantity = 1 }
    obs.byItemID[253604] = { itemID = 253604, decorID = 202, name = "NoXP",
                             firstAcquisitionBonus = 0, isOwned = false }
    obs.byDecorID[200] = obs.byItemID[245308]
    obs.byDecorID[201] = obs.byItemID[251484]
    obs.byDecorID[202] = obs.byItemID[253604]
    col.ownedDecorIDs = col.ownedDecorIDs or {}
    col.ownedDecorIDs[201] = true  -- XPGiverOwned is owned
end
-- Nudge sweepGeneration so decor.isCollected/matchesTag invalidate.
HDG.Store:Dispatch({ type = A.DECOR_CATALOG_READY })
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "House XP" } })
local matchHouseXP = HDG.Selectors:Call("decor.matchesTag", state, {})
assertEq(matchHouseXP(245308), true,  "matchesTag('House XP') accepts unowned XP-eligible")
assertEq(matchHouseXP(251484), false, "matchesTag('House XP') rejects owned XP-eligible")
assertEq(matchHouseXP(253604), false, "matchesTag('House XP') rejects unowned no-XP")

-- ============================================================================
-- 7. decor.items cold-state hide-unreleased behaviour
-- ============================================================================
-- Catalog-by-construction: decor.items returns allItems without any
-- released-gate filter (decor.isLive deleted in Phase 4.3).

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

local items = HDG.Selectors:Call("decor.items", state, {})
local allItems = HDG.Selectors:Call("decor.allItems", state, {})
assertTrue(#allItems > 0, "allItems has rows (observer seeded)")
assertEq(#items, #allItems, "decor.items == decor.allItems with no filters (released-gate deleted)")

-- ============================================================================
-- 8. filterActive on each axis
-- ============================================================================

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), false,
    "filterActive false at defaults (post-reset)")

-- search non-empty -> active
HDG.Store:Dispatch({ type = A.DECOR_SET_SEARCH, payload = { query = "chair" } })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), true,
    "filterActive true with non-empty search")
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- topFilter != 'all' -> active
HDG.Store:Dispatch({ type = A.DECOR_SET_TOP_FILTER, payload = { value = "crafted" } })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), true,
    "filterActive true with topFilter='crafted'")
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- onlyUncollected -> active
HDG.Store:Dispatch({ type = A.DECOR_TOGGLE_ONLY_UNCOLLECTED })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), true,
    "filterActive true with onlyUncollected on")
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- FilterActive does NOT check legacy activeProfessions (it no longer
-- composes into decor.items, so it would falsely report active).
HDG.Store:Dispatch({
    type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "activeProfessions", value = { Alchemy = true } },
})
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), false,
    "filterActive ignores legacy activeProfessions (Phase 9b)")

-- ============================================================================
-- 9. (COLLECTION_OWNERSHIP_DELTA retired 2026-05-27 alongside VerifyOwnedIDsAsync;
--     the bulk-delta dispatch path no longer exists. Test section deleted.)
-- ============================================================================

-- ============================================================================
-- 10. Phase 9c-second-pass: detail pane selectors
-- ============================================================================

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- Seed the observer byItemID for decor.selectedItem (catalog-first 4.2:
-- selectedItem now reads HousingCatalogObserver:GetRow instead of the store).
obs.byItemID[235523] = {
    itemID = 235523, decorID = 494, name = "Sturdy Wooden Chair",
    quantity = 2, numPlaced = 1, isOwned = true, firstAcquisitionBonus = 0,
    canCustomize = true, categoryName = "Furniture", subcategoryName = "Seating",
    isAllowedIndoors = true, isAllowedOutdoors = true, placementCost = 0,
    placementLabel = "",  -- baked by BuildRow (placementCost 0 -> empty); statusLabel reads it
    dataTagsByID = {}, vendors = {}, factionGate = nil,
    destroyableInstanceCount = 0, entryID = nil,
    variants = {
        [1] = { variantIdentifier = 11,
                dyeIDs = { 100 },
                dyeColorsByChannel = { { r = 0.8, g = 0.2, b = 0.2 } } },
        [2] = { variantIdentifier = 12,
                dyeIDs = { 101 },
                dyeColorsByChannel = { { r = 0.2, g = 0.6, b = 0.2 } } },
        [3] = { variantIdentifier = 13, dyeIDs = nil, dyeColorsByChannel = nil },
    },
    -- Baked by the observer's _bakeVariantDyes at sweep time; the mock supplies
    -- it directly. decor.isDyed + decor.items now read this (not row.variants):
    -- owned variants with an applied dye, channel-keyed sparse + flat list.
    dyedVariants = {
        { variantIdentifier = 11, numStored = 1, dyeColorsByChannel = { [0] = 100 }, dyeColorIDs = { 100 } },
        { variantIdentifier = 12, numStored = 1, dyeColorsByChannel = { [0] = 101 }, dyeColorIDs = { 101 } },
    },
}
obs.byItemID[235633] = {
    itemID = 235633, decorID = 498, name = "Circular Woolen Rug",
    quantity = 0, numPlaced = 0, isOwned = false, firstAcquisitionBonus = 5,
    canCustomize = false, categoryName = nil, subcategoryName = nil,
    isAllowedIndoors = true, isAllowedOutdoors = true, placementCost = 0,
    placementLabel = "",  -- baked by BuildRow; statusLabel reads it
    dataTagsByID = {}, vendors = {}, factionGate = nil,
    destroyableInstanceCount = 0, entryID = nil,
}
-- Phase 4.4: selectors that resolve via decorID (selectedItem.category,
-- selectedItem.variants, etc.) read obs.byDecorID. Mirror byItemID into byDecorID.
obs.byDecorID[494] = obs.byItemID[235523]
obs.byDecorID[498] = obs.byItemID[235633]
-- Also dispatch COLLECTION_BULK_LOAD for coverage of other selectors that
-- still read from state (decor.allItems, filter counts, etc.).
HDG.Store:Dispatch({ type = A.COLLECTION_BULK_LOAD, payload = {
    entries = {
        [494] = {
            decorID = 494, itemID = 235523,
            name = "Sturdy Wooden Chair",
            quantity = 2, numPlaced = 1, remainingRedeemable = 0,
            canCustomize = true,
            categoryName = "Furniture", subcategoryName = "Seating",
            isOwned = true, firstAcquisitionBonus = 0,
            sourceType = 2, exp = "Midnight", subcats = {1},
            variants = {
                [1] = { variantIdentifier = 11,
                        dyeIDs = { 100 },
                        dyeColorsByChannel = { { r = 0.8, g = 0.2, b = 0.2 } } },
                [2] = { variantIdentifier = 12,
                        dyeIDs = { 101 },
                        dyeColorsByChannel = { { r = 0.2, g = 0.6, b = 0.2 } } },
                [3] = { variantIdentifier = 13, dyeIDs = nil, dyeColorsByChannel = nil },
            },
        },
        [498] = {
            decorID = 498, itemID = 235633,
            name = "Circular Woolen Rug",
            numStored = 0, quantity = 0, numPlaced = 0, remainingRedeemable = 0,
            canCustomize = false,
            isOwned = false, firstAcquisitionBonus = 5,
            sourceType = 5, exp = "Midnight", subcats = {14},
        },
    },
    owned = { [494] = true },
    live  = { [494] = true, [498] = true },
} })

-- Select item 235523
HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = 235523 } })

-- decor.selectedItem.category: pure read of pre-resolved row.categoryName/subcategoryName.
assertEq(HDG.Selectors:Call("decor.selectedItem.category", state, {}),
    "Furniture  >  Seating",
    "category selector formats categoryName  >  subcategoryName")

-- decor.selectedItem.sourceLabel: source type + name + detail dedup.
-- AllDecorDB only has sourceType + exp here; sourceName/Detail empty.
local sourceLabel = HDG.Selectors:Call("decor.selectedItem.sourceLabel", state, {})
assertTrue(type(sourceLabel) == "string", "sourceLabel returns a string")
-- sourceType=2 is "Quest" -- label should start with "Quest" or "Quest:" depending
-- on whether sourceName is set. AllDecorDB row #235523 has no sourceName in this seed.

-- decor.selectedItem.statusLabel: "Collected * 2 in storage" (numStored=2).
-- Production switched from "Owned" to "Collected" in HDGR_Selectors_Decor.lua
-- to mirror HDG's terminology; test updated to match.
local statusLabel = HDG.Selectors:Call("decor.selectedItem.statusLabel", state, {})
assertTrue(type(statusLabel) == "string", "statusLabel returns a string")
assertTrue(statusLabel:find("Collected", 1, true) ~= nil,
    "statusLabel includes 'Collected' for owned item (got: " .. statusLabel .. ")")
-- Storage format switched from "2 in storage" to "(2 [chest-icon])" -- the
-- chest atlas marker replaces the literal " in storage". Test asserts the
-- count is present + the chest icon escape, which together cover the segment.
assertTrue(statusLabel:find("2", 1, true) ~= nil,
    "statusLabel includes numStored (got: " .. statusLabel .. ")")
assertTrue(statusLabel:find("house-chest-icon", 1, true) ~= nil,
    "statusLabel includes chest icon escape for stored segment")

-- (Dye-variant swatch selectors removed -- the in-card strip was dropped. The
-- owned-dyed-variant ROW system below (isDyed / Dyed tag) is still covered.)

-- decor.isDyed: variant has dyeIDs populated.
local isDyed = HDG.Selectors:Call("decor.isDyed", state, {})
assertEq(isDyed(235523), true,  "isDyed(item with dyed variants) true")
assertEq(isDyed(235633), false, "isDyed(no variants) false")

-- decor.isDyeable: row.canCustomize.
local isDyeable2 = HDG.Selectors:Call("decor.isDyeable", state, {})
assertEq(isDyeable2(235523), true,  "isDyeable(canCustomize=true) true")
assertEq(isDyeable2(235633), false, "isDyeable(canCustomize=false) false")

-- Switch selection to an item without variants (also sets up the category test below).
HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = 235633 } })

-- decor.selectedItem.category: empty string when row has no categoryName
assertEq(HDG.Selectors:Call("decor.selectedItem.category", state, {}), "",
    "category empty when row has no resolved categoryName")

-- ============================================================================
-- 11. decor.items: per-row stamping (Phase 9c review fixes)
-- ============================================================================

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- 235523 is already favorited (set in section 6). Make sure it stays
-- favorited going into the stamp tests below by explicitly setting,
-- not toggling.
if not state.account.favorites[235523] then
    HDG.Store:Dispatch({ type = A.FAVORITE_TOGGLE, payload = { itemID = 235523 } })
end
-- Make sure 235633 is NOT favorited (different item, never touched).
HDG.Store:Dispatch({ type = A.RECIPE_KNOWLEDGE_UPDATED, payload = {
    entries = { [235523] = { spellID = 999, selfKnown = true, altKnown = false } },
} })

-- Select item 235523 -- decor.items should stamp isSelected on that row
HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = 235523 } })

local items = HDG.Selectors:Call("decor.items", state, {}) or {}
local stamped235523, stamped235633
for _, item in ipairs(items) do
    if item.itemID == 235523 then stamped235523 = item end
    if item.itemID == 235633 then stamped235633 = item end
end

assertTrue(stamped235523, "decor.items contains seeded item 235523")
assertEq(stamped235523.isFavorite,     true, "isFavorite stamped for favorited item")
assertEq(stamped235523.craftableState, "known_self", "craftableState stamped (known recipe)")
if stamped235633 then
    assertEq(stamped235633.isFavorite, false, "isFavorite stamped false for non-favorited")
end

-- isSelected is no longer stamped by decor.items -- selection is owned by
-- SelectionBehaviorMixin on the decorPanel.list scrollbox (Intrusive flag
-- stamps ed.selected when behavior:SelectElementData is called). Row
-- Configure reads ed.selected for highlight chrome. decor.items deliberately
-- does NOT depend on selectedItemID anymore so clicks don't invalidate
-- the items list. See HDG.UI.WireStoreSelectionSync.
assertEq(stamped235523.isSelected, nil, "isSelected NOT stamped by decor.items (owned by SelectionBehavior)")

-- The old test below verified that the selector's items list re-stamped
-- isSelected after a SET_TRANSIENT(selectedItemID) dispatch -- the exact
-- O(visible-rows) work the migration retired. Switching selection no
-- longer rebuilds the items list (decor.items reads no longer include
-- selectedItemID); the in-game behavior path is verified by the
-- SelectionBehaviorMixin Intrusive flag setting ed.selected and the row
-- factory re-rendering 2 affected rows. Pure-function tests can't cover
-- that path without a live ScrollBox.

-- ============================================================================
-- 12. Dyed / Dyeable as sub-tags under topFilter='all' (Phase 9c-second-pass)
-- ============================================================================
-- Dyed + Dyeable were briefly Row 3 toggles, then moved into the tags row
-- under 'all' so the filter strip stays uncluttered. Trade-off: can no
-- longer combine Dyed with a profession sub-tag under 'crafted'.

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- decor.tagsForFilter under 'all' surfaces the lens tags including Dyed/Dyeable.
-- "All Decor" tag dropped (redundant; top='all' + no activeTag already means
-- "everything").
local allTags = HDG.Selectors:Call("decor.tagsForFilter", state, {}) or {}
local seenAllDecor, seenDyed, seenDyeable = false, false, false
for _, t in ipairs(allTags) do
    if t == "All Decor" then seenAllDecor = true end
    if t == "Dyed"      then seenDyed = true end
    if t == "Dyeable"   then seenDyeable = true end
end
assertEq(seenAllDecor, false, "'All Decor' tag dropped under topFilter='all'")
assertEq(seenDyed,    true, "'Dyed' tag present under topFilter='all'")
assertEq(seenDyeable, true, "'Dyeable' tag present under topFilter='all'")

-- Activate 'Dyeable' tag -- decor.items should narrow to canCustomize=true rows.
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Dyeable" } })
local dyeable = HDG.Selectors:Call("decor.items", state, {}) or {}
local saw235523, saw235633 = false, false
for _, item in ipairs(dyeable) do
    if item.itemID == 235523 then saw235523 = true end
    if item.itemID == 235633 then saw235633 = true end
end
assertEq(saw235523, true,  "Dyeable tag shows item with canCustomize=true")
assertEq(saw235633, false, "Dyeable tag hides item with canCustomize=false")

-- Switch to 'Dyed' tag -- narrows to items with dyed variants.
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Dyed" } })
local dyed = HDG.Selectors:Call("decor.items", state, {}) or {}
local sawDyed235523, sawDyed235633 = false, false
for _, item in ipairs(dyed) do
    if item.itemID == 235523 then sawDyed235523 = true end
    if item.itemID == 235633 then sawDyed235633 = true end
end
assertEq(sawDyed235523, true,  "Dyed tag shows item with dyed variants")
assertEq(sawDyed235633, false, "Dyed tag hides item without variants")

-- filterActive picks up activeTag.
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Dyed" } })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), true,
    "filterActive true with Dyed tag active")
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Dyeable" } })
assertEq(HDG.Selectors:Call("decor.filterActive", state, {}), true,
    "filterActive true with Dyeable tag active")

-- ============================================================================
-- 13. decor.items does NOT mutate cached decor.allItems rows
-- ============================================================================
-- Regression for Phase 9c second-pass review (C1/P0): per-row stamping was
-- writing isFavorite / craftableState / isSelected directly onto rows from
-- the memoized decor.allItems array, corrupting the shared cache. The fix
-- shallow-copies each row before stamping. This test asserts that the
-- allItems cache stays clean of those fields after a decor.items pass.

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })
HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = 235523 } })

-- Run decor.items twice so any in-place stamping would land.
local _ = HDG.Selectors:Call("decor.items", state, {})
local _ = HDG.Selectors:Call("decor.items", state, {})

local cachedAll = HDG.Selectors:Call("decor.allItems", state, {}) or {}
local cleanRow
for _, row in ipairs(cachedAll) do
    if row.itemID == 235523 then cleanRow = row; break end
end
assertTrue(cleanRow, "allItems still contains 235523")
assertEq(cleanRow.isFavorite,     nil,
    "decor.items must NOT stamp isFavorite onto the memoized allItems row")
assertEq(cleanRow.craftableState, nil,
    "decor.items must NOT stamp craftableState onto the memoized allItems row")
assertEq(cleanRow.isSelected,     nil,
    "decor.items must NOT stamp isSelected onto the memoized allItems row")

-- ============================================================================
-- 14. decor.headerLabel appends "N shown" when filter active
-- ============================================================================
-- Regression for Phase 9c second-pass review polish item.

-- Clean baseline: FILTER_RESET restores filter defaults.
-- filterActive is true only when state diverges from defaults.
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })
HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "selectedItemID", value = nil } })
local headerUnfiltered = HDG.Selectors:Call("decor.headerLabel", state, {})
assertTrue(not headerUnfiltered:find("shown", 1, true),
    "headerLabel has no 'shown' tail at filter defaults (got: " .. headerUnfiltered .. ")")

HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "searchQuery", value = "zzzzqqqzzz" } })
local filteredItems = HDG.Selectors:Call("decor.items", state, {}) or {}
local headerFiltered = HDG.Selectors:Call("decor.headerLabel", state, {})
-- Filter-scoped: "collected/shown (pct%)" -- the shown count is now the denominator.
assertTrue(headerFiltered:find("/" .. tostring(#filteredItems) .. " (", 1, true) ~= nil,
    "headerLabel: shown count is the denominator when filter active (got: " .. headerFiltered .. ")")
HDG.Store:Dispatch({ type = A.UI_SET_TRANSIENT,
    payload = { view = "decor", key = "searchQuery", value = "" } })

-- Dyeable tag: the denominator counts unique BASE decor, not rows. The fixture
-- has 2 dyeable bases -- 235523 (collected, 2 dyed variants) and 235675
-- (uncollected) -- so 4 rows but "1/2 (50%)", with the variant rows surfaced
-- as a "+ 2 dyed" tail. Regression for the VDS-vs-HDG dyeable count mismatch
-- (143 vs 157: variant rows inflated the ratio).
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "Dyeable" } })
local dyeableRows = HDG.Selectors:Call("decor.items", state, {}) or {}
assertEq(#dyeableRows, 4, "Dyeable tag emits 2 base rows + 2 dyed-variant rows")
local headerDyeable = HDG.Selectors:Call("decor.headerLabel", state, {})
assertEq(headerDyeable, "1/2 (50%)  Dyeable + 2 dyed",
    "headerLabel counts unique base decor with a dyed tail (got: " .. headerDyeable .. ")")
HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- ============================================================================
-- 15. decor.headerDenominator (catalog-by-construction; Phase 4.3)
-- ============================================================================
-- Released-gate deleted: headerDenominator always returns #allItems.
-- unreleasedMode tri-state retired in Phase 4.5 (catalog-by-construction
-- ships released items only, so the toggle was meaningless).

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

local total = #(HDG.Selectors:Call("decor.allItems", state, {}) or {})
assertTrue(total > 0, "sanity: allItems non-empty (observer seeded)")

assertEq(HDG.Selectors:Call("decor.headerDenominator", state, {}), total,
    "headerDenominator equals allItems total (released-gate deleted)")

-- ============================================================================
-- 16. Catalog vintage: "New in <patch>" chip (CATALOG_VINTAGE_UPDATE)
-- ============================================================================
-- Observer diffs live itemIDs against account.catalogVintage.snapshot on every
-- full sweep; the reducer owns seed / same-build merge / build-change retire.

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

-- Seed: snapshot fills, NO batch, no chip.
HDG.Store:Dispatch({ type = A.CATALOG_VINTAGE_UPDATE, payload = {
    ids = { 235523, 235675, 235677, 238860 }, build = 120005, label = "12.0.5", isSeed = true,
} })
local cv = state.account.catalogVintage
assertEq(cv.snapshot[235523], true,  "seed fills snapshot")
assertEq(next(cv.newIds),     nil,   "seed creates no new-batch")
local tags = HDG.Selectors:Call("decor.tagsForFilter", state, {}) or {}
assertTrue(tags[1] ~= "New in 12.0.5", "no New chip after seed (got: " .. tostring(tags[1]) .. ")")

-- New build batch: chip leads the 'all' tag row; tag narrows decor.items.
HDG.Store:Dispatch({ type = A.CATALOG_VINTAGE_UPDATE, payload = {
    ids = { 235633 }, build = 120007, label = "12.0.7",
} })
tags = HDG.Selectors:Call("decor.tagsForFilter", state, {}) or {}
assertEq(tags[1], "New in 12.0.7", "New chip leads the 'all' tags after a diff batch")
HDG.Store:Dispatch({ type = A.DECOR_SET_TAG, payload = { tag = "New in 12.0.7" } })
local newRows = HDG.Selectors:Call("decor.items", state, {}) or {}
assertEq(#newRows, 1,            "New tag narrows to the batch")
assertEq(newRows[1].itemID, 235633, "New tag matches the batch itemID")

-- Same-build merge (mid-patch hotfix): batch accumulates; label re-stamps
-- (regression: merge once kept a stale label from an earlier dispatch).
HDG.Store:Dispatch({ type = A.CATALOG_VINTAGE_UPDATE, payload = {
    ids = { 235675 }, build = 120007, label = "12.0.7b",
} })
assertEq(cv.newIds[235633], true, "same-build merge keeps prior batch ids")
assertEq(cv.newIds[235675], true, "same-build merge adds new ids")
assertEq(cv.newBuildLabel, "12.0.7b", "same-build merge re-stamps the label")

-- Build change: previous batch retires; chip label moves on.
HDG.Store:Dispatch({ type = A.CATALOG_VINTAGE_UPDATE, payload = {
    ids = { 238860 }, build = 120100, label = "12.1.0",
} })
assertEq(cv.newIds[235633], nil,  "build change retires the previous batch")
assertEq(cv.newIds[238860], true, "build change starts a fresh batch")
tags = HDG.Selectors:Call("decor.tagsForFilter", state, {}) or {}
assertEq(tags[1], "New in 12.1.0", "chip label follows the new build")
assertEq(cv.snapshot[235633], true, "retired batch ids remain in the snapshot")

HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "decor" } })

io.write("test_filter_smoke.lua passed\n")
