-- Smoke: Recipes tab selectors + reducer flow.
-- Verifies allRecipes -> filteredRecipes -> groupedRows -> selectedRecipe ->
-- materials.direct round-trip against a synthetic ProfessionsDB double.

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_StoreFurnishings.lua")
dofile("../Core/HDGR_Store.lua")
dofile("../UI/HDGR_LayoutConfig.lua")
-- HDG.Expansion lookup is needed by recipes.allRecipes' expansionShort()
-- (added after this test was written; not in the original Phase 11.3 boot
-- set). Load it after Constants so EXPANSION_DATA is in place.
dofile("../Core/HDGR_Expansion.lua")

-- Synthetic ProfessionsDB BEFORE selectors load. ProfessionsDB is PowerCrafter-only
-- after the migration; keyed by itemID (the produced item). spellID lives in
-- the recipeID field. Slot shape: type/qty/itemID/name (same as production).
_G.HDGR_ProfessionsDB = {
    [20001] = { recipeID = 10001, itemID = 20001, name = "Pine Stool",
        profession = "Carpentry", expansion = "Midnight",
        slots = {
            { type = "basic", qty = 4, itemID = 30001, name = "Pine Plank" },
            { type = "basic", qty = 1, itemID = 30002, name = "Iron Nails" },
        } },
    [20002] = { recipeID = 10002, itemID = 20002, name = "Pine Bench",
        profession = "Carpentry", expansion = "Midnight",
        slots = {
            { type = "basic", qty = 8, itemID = 30001, name = "Pine Plank" },
            { type = "basic", qty = 2, itemID = 30002, name = "Iron Nails" },
            { type = "modifying", qty = 1, itemID = 30099, name = "Polish" },
        } },
    [20003] = { recipeID = 10003, itemID = 20003, name = "Iron Cauldron",
        profession = "Blacksmithing", expansion = "Midnight",
        slots = {
            { type = "basic", qty = 6, itemID = 30002, name = "Iron Nails" },
        } },
}

-- Need data files referenced by other selectors not under test, to avoid
-- nil-globals when their reads-closures are computed.
dofile("../data/HDGR_DecorDB.lua")
-- Synthetic decor recipes in DecorDB, keyed off the same itemIDs as the
-- ProfessionsDB stubs above (20001/20002). The recipe search reads reagent
-- names from DecorDB (Recipes:Get(itemID).reagents), not ProfessionsDB slots,
-- so the search assertions below resolve through here. Iron Cauldron (20003)
-- stays absent -> non-decor, excluded by the decor scope before search runs.
_G.HDGR_DecorDB[90001] = { itemID = 20001, name = "Pine Stool", spellID = 10001,
    profession = "Carpentry", expansion = "Midnight", category = "Furniture",
    reagents = { [30001] = { name = "Pine Plank", qty = 4 }, [30002] = { name = "Iron Nails", qty = 1 } } }
_G.HDGR_DecorDB[90002] = { itemID = 20002, name = "Pine Bench", spellID = 10002,
    profession = "Carpentry", expansion = "Midnight", category = "Furniture",
    reagents = { [30001] = { name = "Pine Plank", qty = 8 }, [30002] = { name = "Iron Nails", qty = 2 } } }
HDGR_AllDecorDB = {}  -- stub: deprecated DB archived; test does not assert on its content
HDGR_VendorDB = {}  -- stub: deprecated DB archived; test does not assert on its content
-- HDGR_ReagentsDB isn't a shipped data file in this test repo (lives
-- under HousingDecorGuide). HDG.StaticData errors loud on missing
-- tables (the right behavior), so stub an empty one. The reagentInfo
-- fallback path returns "item N" / "Other" for unknown items.
_G.HDGR_ReagentsDB = {}
dofile("../Modules/HDGR_StaticData.lua")

-- Stub HousingCatalogObserver (Phase 3h: recipes.decorItemSet reads observer,
-- not AllDecorDB). Seed itemIDs 20001 + 20002 so the decor scope chip sees
-- them as decor items. 20003 is intentionally absent -- scope filter excludes it.
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 = { [20001] = { name = "Pine Stool" }, [20002] = { name = "Pine Bench" } }
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:IterateRows(fn)
    if not self:IsReady() then return end
    for itemID, row in pairs(self.byItemID) do fn(itemID, row) end
end
function obs:GetExpansionForItem() return nil end

-- Stub ItemNameResolver: recipes.allRecipes calls ResolveIcon (DecorDB path).
HDG.ItemNameResolver = HDG.ItemNameResolver or {
    ResolveIcon = function() return nil end,
    ResolveName = function() return nil end,
}

-- Stub modules table so PowerCrafter's registration block doesn't fail.
HDG.Modules = HDG.Modules or {}
HDG.Modules._decls = {}
function HDG.Modules:Declare(def) self._decls[def.name] = def end

dofile("../Modules/HDGR_PowerCrafter.lua")
dofile("../Modules/HDGR_BagObserver.lua")

dofile("../UI/HDGR_Selectors.lua")
dofile("../UI/HDGR_Selectors_Status.lua")
dofile("../UI/HDGR_Selectors_Decor.lua")
dofile("../UI/HDGR_Selectors_Acquisition.lua")
dofile("../UI/HDGR_Selectors_Recipes.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

HDG_DB = {}
HDG.Store:LoadFromSavedVariables()

-- Mark catalog ready so recipes.decorItemSet (Phase 3h: reads observer, not
-- AllDecorDB) returns the stubbed observer rows (20001 + 20002).
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CATALOG_LOAD_COMPLETED,
    payload = { loadedAt = 0, itemCount = 2, vendorCount = 0, generation = 1 },
})

local state = HDG.Store:GetState()
local Selectors = HDG.Selectors

-- ===== allRecipes ===========================================================
-- allRecipes now iterates DecorDB (305 real + 2 synthetic = 307). The count
-- reflects the real DB + the synthetic stubs injected above. Iron Cauldron
-- (20003) is ProfessionsDB-only so it is absent here (correct post-migration).
local all = Selectors:Call("recipes.allRecipes", state, {})
assertTrue(#all >= 307, "allRecipes returns 305 real + 2 synthetic (>= 307)")
-- Verify the 2 synthetic entries are present and well-formed.
local stoolRow, benchRow
for _, r in ipairs(all) do
    if r.itemID == 20001 then stoolRow = r end
    if r.itemID == 20002 then benchRow = r end
end
assertTrue(stoolRow ~= nil, "Pine Stool (20001) present in allRecipes")
assertTrue(benchRow ~= nil, "Pine Bench (20002) present in allRecipes")
assertEq(stoolRow.profession, "Carpentry", "Pine Stool profession = Carpentry")
assertEq(stoolRow.isKnown, false, "isKnown=false default (account.recipes empty)")
-- Sort order: sorted by profession then name. Verify Bench < Stool (alphabetic within Carpentry).
assertTrue(stoolRow.name < benchRow.name or benchRow.name < stoolRow.name,
           "Carpentry rows present and name-sorted")
-- Iron Cauldron (20003) is ProfessionsDB-only -> must NOT appear in allRecipes.
local cauldronRow
for _, r in ipairs(all) do if r.itemID == 20003 then cauldronRow = r end end
assertTrue(cauldronRow == nil, "Iron Cauldron (20003) absent from allRecipes (DecorDB-only source)")

-- ===== Decor-only list (Decor/All scope toggle removed 2026-06-03). Observer
-- stub carries itemIDs 20001 + 20002 as decor items (Phase 3h); only those 2
-- are in the observer so filteredRecipes returns exactly 2 entries.
assertEq(#Selectors:Call("recipes.filteredRecipes", state, {}), 2,
         "decor-only list shows only the 2 synthetic decor recipes (20001 + 20002)")

-- ===== groupedRows produces profession headers (decor-only: only Carpentry --
-- the non-decor Blacksmithing recipe, Iron Cauldron, is excluded) ============
local rows = Selectors:Call("recipes.groupedRows", state, {})
assertEq(rows[1].kind, "profHeader", "row[1] is profession header")
assertEq(rows[1].label, "Carpentry", "first (only) header is Carpentry")
assertEq(rows[2].kind, "recipeRow", "row[2] is a recipe")
assertEq(rows[2].name, "Pine Bench", "first recipe row (sorted by name)")
assertEq(rows[3].name, "Pine Stool", "second recipe row")
assertEq(#rows, 3, "3 rows total (1 header + 2 decor recipes)")

-- ===== Search filtering (matches recipe name OR reagent name) ==============
-- "Cauldron" appears only in the non-decor Iron Cauldron, which the decor-only
-- view excludes -> 0 matches (also verifies the decor filter applies to search).
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SET_SEARCH,
    payload = { query = "Cauldron" },
})
assertEq(#Selectors:Call("recipes.filteredRecipes", state, {}), 0,
         "search 'Cauldron' -> 0 (non-decor Iron Cauldron is excluded)")

-- "Iron" matches the "Iron Nails" reagent in Pine Stool + Pine Bench (the
-- non-decor Iron Cauldron is excluded) -> 2.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SET_SEARCH,
    payload = { query = "Iron" },
})
assertEq(#Selectors:Call("recipes.filteredRecipes", state, {}), 2,
         "search 'Iron' matches the Iron Nails reagent in the 2 decor recipes")

-- "Stuffing" matches no recipe name but IS a reagent in synthetic DB. Pure
-- material-search assertion: query that's only in slot.name should still
-- return recipes that use it. (Synthetic DB doesn't have stuffing -- use
-- "Plank" which is in slot.name for Pine Stool + Pine Bench but not in
-- any recipe name.)
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SET_SEARCH,
    payload = { query = "Plank" },
})
assertEq(#Selectors:Call("recipes.filteredRecipes", state, {}), 2,
         "search 'Plank' matches recipes via reagent name (Pine Stool + Pine Bench)")

-- Reset. UI_FILTER_RESET wipes the search, so the decor-only list returns to
-- its 2 decor recipes (matching the defaults a fresh user would see).
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_FILTER_RESET, payload = { tab = "recipes" } })
assertEq(#Selectors:Call("recipes.filteredRecipes", state, {}), 2,
         "reset restores the 2 decor recipes")

-- ===== Selection + selected.* ===============================================
assertTrue(Selectors:Call("recipes.selectedRecipeID", state, {}) == nil,
           "selectedRecipeID nil before dispatch")
assertEq(Selectors:Call("recipes.selected.name", state, {}), "Click a recipe",
         "selected.name falls back to placeholder")
assertEq(Selectors:Call("recipes.hasSelectedRecipe", state, {}), false,
         "hasSelectedRecipe false when nil")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SELECT_RECIPE,
    payload = { recipeID = 20001 },  -- itemID-based after migration
})
assertEq(Selectors:Call("recipes.selected.name", state, {}), "Pine Stool",
         "selected.name updates")
assertEq(Selectors:Call("recipes.selected.profession", state, {}), "Carpentry",
         "selected.profession")
assertEq(Selectors:Call("recipes.hasSelectedRecipe", state, {}), true,
         "hasSelectedRecipe true after select")

-- ===== Materials direct mode (basic slots only, ignores modifying) ==========
local mats = Selectors:Call("recipes.materials.direct", state, {})
assertEq(#mats, 2, "Pine Stool direct: 2 basic slots")
assertEq(mats[1].kind, "matRow", "material element kind")
-- Stable sort by name -> "item 30001" < "item 30002".
assertEq(mats[1].itemID, 30001, "first material itemID after sort")
assertEq(mats[1].qty, 4, "first material qty")
-- Bag-count stamp: empty BagObserver -> have=0, covered=false.
assertEq(mats[1].have, 0, "have=0 with no BagObserver cache")
assertEq(mats[1].covered, false, "covered=false when have<need")

-- Pine Bench (20002) has 2 basic + 1 modifying -> direct returns 2 only.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SELECT_RECIPE,
    payload = { recipeID = 20002 },  -- itemID-based after migration
})
local mats2 = Selectors:Call("recipes.materials.direct", state, {})
assertEq(#mats2, 2, "Pine Bench direct: 2 basic slots (modifying excluded)")

-- materials.current defaults to direct mode.
local current = Selectors:Call("recipes.materials.current", state, {})
assertEq(#current, 2, "materials.current defaults to direct")

-- ===== Materials raw mode -- PowerCrafter live ==============================
-- Pine Bench (10002) has 2 basic slots (8 plank + 2 nails), no intermediates
-- because the knownRecipes set is empty (no account.recipes entries).
-- Raw materials = same as direct.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SET_MATERIALS_DEPTH,
    payload = { value = "raw" },
})
local rawCurrent = Selectors:Call("recipes.materials.current", state, {})
assertEq(#rawCurrent, 2, "raw mode: 2 leaf materials (no expansion, knownRecipes empty)")
-- The actual qty in raw comes from PowerCrafter; for Pine Bench with
-- remaining=1 in the fallback queue, expected = slot qty.
local seenPlank = false
for _, m in ipairs(rawCurrent) do
    if m.itemID == 30001 then
        assertEq(m.qty, 8, "raw qty for Pine Plank = slot qty (8) * remaining (1)")
        seenPlank = true
    end
end
assertTrue(seenPlank, "raw materials include Pine Plank")

-- ===== Mark Pine Stool known (state setup for the gauge/craftableState below)
-- Inject a known recipe for Pine Stool (itemID 20001) on this character; the
-- collection gauge + craftableState assertions further down depend on it.
state.account.recipes[20001] = { spellID = 1, selfKnown = true }
HDG.Selectors:InvalidateMemos({ "account.recipes" })

-- ===== Material axis helpers (grouping x depth) =============================
assertEq(Selectors:Call("recipes.isDepth_direct", state, {}), false, "isDepth_direct false in raw")
assertEq(Selectors:Call("recipes.isDepth_raw", state, {}), true,   "isDepth_raw true in raw")
-- Switch grouping to byRecipe (depth stays raw).
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_MATERIALS_GROUPING })
assertEq(Selectors:Call("recipes.isGrouping_byRecipe", state, {}), true, "grouping byRecipe after toggle")

-- byRecipe emits matSubHeader rows (recipe name) followed by matRow children.
-- Queue is empty at this point so effectiveQueue fabricates from selected
-- recipe (recipeID 20002 = Pine Bench itemID, set earlier).
local byR = Selectors:Call("recipes.materials.byRecipe", state, {})
assertTrue(#byR > 0, "byRecipe produces rows")
assertEq(byR[1].kind, "matSubHeader", "byRecipe[1] is a recipe subheader")
assertTrue(byR[1].label:find("Pine Bench", 1, true) ~= nil,
           "subheader labels with selected recipe name (fallback queue: Pine Bench x1)")
assertEq(byR[1].fromPosition, 1, "subheader carries fromPosition")
-- Find the next matRow under the subheader.
local firstChild
for i = 2, #byR do
    if byR[i].kind == "matRow" then firstChild = byR[i]; break end
end
assertTrue(firstChild ~= nil, "byRecipe emits matRow children under subheader")
assertEq(firstChild.fromPosition, 1, "child carries same fromPosition")
assertEq(firstChild.fromRecipeID, 20002, "child carries fromRecipeID (itemID after migration)")

-- Now exercise the real-queue path: add two recipes and verify TWO subheaders
-- appear in byRecipe output.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.CRAFT_QUEUE_CLEAR })
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CRAFT_QUEUE_ADD,
    payload = { recipeID = 20001, itemID = 20001, qty = 2 },  -- itemID-based after migration
})
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CRAFT_QUEUE_ADD,
    payload = { recipeID = 20002, itemID = 20002, qty = 1 },  -- itemID-based after migration
})
local byR2 = Selectors:Call("recipes.materials.byRecipe", state, {})
local headers = 0
for _, e in ipairs(byR2) do
    if e.kind == "matSubHeader" then headers = headers + 1 end
end
assertEq(headers, 2, "byRecipe with 2 queue rows produces 2 subheaders")

-- Clean up the queue so downstream tests can assert empty state.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.CRAFT_QUEUE_CLEAR })

-- ===== Queue + queue selectors ==============================================
assertEq(Selectors:Call("recipes.queueCount", state, {}), 0, "queue starts empty")
assertEq(Selectors:Call("recipes.queueIsEmpty", state, {}), true, "queueIsEmpty true")
assertEq(Selectors:Call("recipes.queueHasEntries", state, {}), false, "queueHasEntries false")
assertEq(Selectors:Call("recipes.queueLabel", state, {}), "Queue: empty", "queueLabel empty")

HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CRAFT_QUEUE_ADD,
    payload = { recipeID = 20001, itemID = 20001, qty = 3 },  -- itemID-based after migration
})
assertEq(Selectors:Call("recipes.queueCount", state, {}), 1, "queue count after add")
assertEq(Selectors:Call("recipes.queueLabel", state, {}), "Queue: 1", "queueLabel after add")

-- ===== Direct mode now aggregates across the queue (not selected recipe) ====
-- Queue has 1 row: Pine Stool x3 (recipeID 20001 = itemID with remaining=3). Pine
-- Stool basic slots: 4 Pine Plank + 1 Iron Nails. Direct mode = 12 plank +
-- 3 nails (slot qty * remaining).
-- Reset axes to defaults (totals + direct) for the queue-direct assertions.
-- grouping is byRecipe here (from the helpers block above) -> toggle to totals.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_MATERIALS_GROUPING })
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SET_MATERIALS_DEPTH,
    payload = { value = "direct" },
})
local qDirect = Selectors:Call("recipes.materials.direct", state, {})
local plank, nails
for _, m in ipairs(qDirect) do
    if m.itemID == 30001 then plank = m end
    if m.itemID == 30002 then nails = m end
end
assertTrue(plank ~= nil, "queue direct includes Pine Plank")
assertEq(plank.qty, 12, "queue direct: Pine Plank = 4 slot x 3 remaining = 12")
assertEq(nails.qty, 3,  "queue direct: Iron Nails = 1 slot x 3 remaining = 3")

-- Add another row -> queue direct should SUM both rows' reagents.
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.CRAFT_QUEUE_ADD,
    payload = { recipeID = 20002, itemID = 20002, qty = 1 },   -- Pine Bench (itemID-based)
})
-- Pine Bench adds 8 plank + 2 nails. Total: plank 12+8=20, nails 3+2=5.
local qDirect2 = Selectors:Call("recipes.materials.direct", state, {})
plank, nails = nil, nil
for _, m in ipairs(qDirect2) do
    if m.itemID == 30001 then plank = m end
    if m.itemID == 30002 then nails = m end
end
assertEq(plank.qty, 20, "queue direct sums across rows: plank 12+8=20")
assertEq(nails.qty, 5,  "queue direct sums across rows: nails 3+2=5")

-- ===== countLabel display string =============================================
-- Decor-only view: countLabel is the decor-recipe COLLECTION gauge "known / total"
-- (filterResult), not a filtered-list count. recipe 20001 (Pine Stool) was marked
-- known above; 20002 (Pine Bench) isn't -> "1 / 2". Search does NOT change it (it's
-- collection progress, profession/expansion-filter-aware only).
local d1 = Selectors:Call("recipes.countLabel", state, {})
assertEq(d1, "1 / 2", "countLabel = known decor / total decor")
HDG.Store:Dispatch({
    type    = HDG.Constants.ACTIONS.RECIPES_SET_SEARCH,
    payload = { query = "Pine" },
})
local d2 = Selectors:Call("recipes.countLabel", state, {})
assertEq(d2, "1 / 2", "countLabel unchanged by search (collection gauge, not filter count)")

-- ===== craftableState stamp ================================================
-- groupedRows stamps craftableState onto every recipeRow.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_FILTER_RESET, payload = { tab = "recipes" } })
local stampedRows = Selectors:Call("recipes.groupedRows", state, {})
local stampedRecipe
for _, row in ipairs(stampedRows) do
    if row.kind == "recipeRow" then stampedRecipe = row; break end
end
assertTrue(stampedRecipe ~= nil, "at least one recipeRow stamped")
assertTrue(stampedRecipe.craftableState ~= nil, "craftableState stamped on recipeRow")
-- The first stamped decor recipe (Pine Bench, 20002) has no account.recipes
-- entry -> maps to "not_a_recipe" via decor.craftableState. That's the contract.
assertEq(stampedRecipe.craftableState, "not_a_recipe",
         "no account.recipes entry -> not_a_recipe (Phase 9 contract)")

-- ===== Profession dropdown + active-filter run + gauge (2026-05-31) =========
-- professionMenuItems: STATIC 9 (PROFESSION_DATA order) + an All row. The
-- synthetic DB's decor recipes are all Carpentry (not in PROFESSION_DATA), so
-- every offered row is 0/0 + isEmpty -- exercises the back-fill path.
local menu = Selectors:Call("recipes.professionMenuItems", state, {})
assertEq(#menu, 10, "professionMenuItems = All + 9 static profession rows")
assertEq(menu[1].isAll, true, "menu[1] is the All row")
assertEq(menu[1].value, "all", "All row value = 'all'")
assertEq(menu[2].kind, "checkbox", "profession rows are multi-select checkbox kind")
assertEq(menu[2].value, "Alchemy", "row 2 = PROFESSION_DATA[1] (Alchemy)")
assertEq(menu[2].total, 0, "Alchemy total = 0 (no decor recipes in synthetic DB)")
assertEq(menu[2].isEmpty, true, "zero-decor profession flagged isEmpty (dim 0/0 row)")

-- runVisible: collapses ONLY at rest (nothing selected); shows for ANY selection.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_FILTER_RESET, payload = { tab = "recipes" } })
assertEq(Selectors:Call("recipes.runVisible", state, {}), false, "run collapsed at rest (nothing selected)")
assertEq(#Selectors:Call("recipes.activeFilterChips", state, {}), 0, "no chips at rest")

HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_PROFESSION, payload = { profession = "Alchemy" } })
assertEq(Selectors:Call("recipes.runVisible", state, {}), true, "1 profession -> run shows (whatever is selected)")
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_PROFESSION, payload = { profession = "Cooking" } })
assertEq(Selectors:Call("recipes.runVisible", state, {}), true, "2 professions -> run visible")

local chips = Selectors:Call("recipes.activeFilterChips", state, {})
assertEq(#chips, 2, "2 profession chips in the run")
assertEq(chips[1].kind, "profession", "chip kind = profession")
assertEq(chips[1].id, "Alchemy", "chips in PROFESSION_DATA order: Alchemy first")
assertEq(chips[2].id, "Cooking", "Cooking second")

-- Per-chip removal = toggle-off; 1 still selected -> run stays visible.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_PROFESSION, payload = { profession = "Alchemy" } })
assertEq(#Selectors:Call("recipes.activeFilterChips", state, {}), 1, "chip removed via toggle-off")
assertEq(Selectors:Call("recipes.runVisible", state, {}), true, "1 still selected -> run still visible")
-- Remove the last one -> run collapses.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_PROFESSION, payload = { profession = "Cooking" } })
assertEq(Selectors:Call("recipes.runVisible", state, {}), false, "nothing selected -> run collapses")

-- filterResult: known <= totalPossible <= master count.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.UI_FILTER_RESET, payload = { tab = "recipes" } })
local g = Selectors:Call("recipes.filterResult", state, {})
assertTrue(g.known <= g.totalPossible, "gauge known <= totalPossible")
assertTrue(g.totalPossible <= #all, "gauge totalPossible <= master recipe count")
assertEq(Selectors:Call("recipes.countLabel", state, {}), g.known .. " / " .. g.totalPossible,
         "countLabel matches filterResult (known / total)")

-- "all" sentinel (Clear all + All-row) empties the persisted set.
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_PROFESSION, payload = { profession = "Tailoring" } })
HDG.Store:Dispatch({ type = HDG.Constants.ACTIONS.RECIPES_TOGGLE_PROFESSION, payload = { profession = "all" } })
assertEq(next(state.account.ui.recipes.professionFilterByChar[state.session.identity.charKey]), nil,
         "'all' sentinel clears the (per-character) profession set")

-- ===== Per-character profession default ("My Professions" is the default) ====
do
    local A   = HDG.Constants.ACTIONS
    local key = "Tester-TestRealm"
    state.session.identity.charKey = key   -- test setup: pretend we're on this char
    -- Give the char two scanned professions (Enchanting + Engineering).
    HDG.Store:Dispatch({ type = A.CHARACTER_PROFESSION_UPDATED,
        payload = { charKey = key, name = "Tester", realm = "TestRealm", profName = "Enchanting", skillLines = {}, knownRecipes = {} } })
    HDG.Store:Dispatch({ type = A.CHARACTER_PROFESSION_UPDATED,
        payload = { charKey = key, name = "Tester", realm = "TestRealm", profName = "Engineering", skillLines = {}, knownRecipes = {} } })

    -- Pristine (no byChar entry yet) -> the filter DEFAULTS to this char's professions.
    assertEq(state.account.ui.recipes.professionFilterByChar[key], nil, "char starts pristine (no stored filter)")
    local def = Selectors:Call("recipes.professionFilter", state, {})
    assertTrue(def.Enchanting and def.Engineering, "pristine default = this char's professions")
    assertEq(def.Tailoring, nil, "default does NOT include professions the char lacks")

    -- "My Professions" is the FIRST menu row.
    local pmenu = Selectors:Call("recipes.professionMenuItems", state, {})
    assertEq(pmenu[1].value, "mine", "My Professions is the first dropdown item")
    assertEq(pmenu[2].value, "all",  "All Professions is second")

    -- First single-toggle from pristine materializes the default, THEN toggles --
    -- so unchecking Engineering leaves Enchanting (not a lone Engineering).
    HDG.Store:Dispatch({ type = A.RECIPES_TOGGLE_PROFESSION, payload = { profession = "Engineering" } })
    local s = state.account.ui.recipes.professionFilterByChar[key]
    assertEq(s.Enchanting, true, "first-touch materializes the default before the toggle")
    assertEq(s.Engineering, nil, "the single toggle then removed Engineering")

    -- "all" empties; "mine" re-presets to the char's professions.
    HDG.Store:Dispatch({ type = A.RECIPES_TOGGLE_PROFESSION, payload = { profession = "all" } })
    assertEq(next(state.account.ui.recipes.professionFilterByChar[key]), nil, "'all' empties the per-char set")
    HDG.Store:Dispatch({ type = A.RECIPES_TOGGLE_PROFESSION, payload = { profession = "mine" } })
    local m = state.account.ui.recipes.professionFilterByChar[key]
    assertTrue(m.Enchanting and m.Engineering, "'mine' presets to the char's professions")

    -- Reset drops the entry -> reverts to the pristine default.
    HDG.Store:Dispatch({ type = A.UI_FILTER_RESET, payload = { tab = "recipes" } })
    assertEq(state.account.ui.recipes.professionFilterByChar[key], nil, "reset reverts char to pristine default")

    state.session.identity.charKey = ""    -- restore for any later assertions
end

-- ===== PowerCrafter: AggregateByRecipeRaw (Task 1) ==========================
do
    local PC = HDG.PowerCrafter
    assertTrue(type(PC.AggregateByRecipeRaw) == "function",
        "PowerCrafter:AggregateByRecipeRaw exists")
    local rid = 20002  -- Pine Bench: itemID after migration; 2 basic slots, reagents not craftable in fixture
    local queue = { { recipeID = rid, remaining = 1 } }
    local groups = PC:AggregateByRecipeRaw(queue, {})
    assertTrue(groups[1] ~= nil, "byRecipeRaw: group at position 1")
    assertEq(groups[1].recipeID, rid, "byRecipeRaw: group carries recipeID")
    assertTrue(type(groups[1].materials) == "table", "byRecipeRaw: group has materials map")
    local n = 0; for _ in pairs(groups[1].materials) do n = n + 1 end
    assertEq(n, 2, "byRecipeRaw: Pine Bench expands to its 2 basic reagents (no known sub-recipes)")
end

-- ===== Materials controls: grouping x depth routing (Task 3) ================
do
    local A = HDG.Constants.ACTIONS
    HDG.Store:Dispatch({ type = A.CRAFT_QUEUE_CLEAR })
    HDG.Store:Dispatch({ type = A.RECIPES_SELECT_RECIPE, payload = { recipeID = 20001 } }) -- Pine Stool (itemID)
    local function S(n) return Selectors:Call(n, HDG.Store:GetState(), {}) end
    local function current() return S("recipes.materials.current") end

    -- Force totals + direct deterministically (toggle is the only grouping mutator).
    HDG.Store:Dispatch({ type = A.RECIPES_SET_MATERIALS_DEPTH, payload = { value = "direct" } })
    if S("recipes.materialsGrouping") == "byRecipe" then
        HDG.Store:Dispatch({ type = A.RECIPES_TOGGLE_MATERIALS_GROUPING })
    end
    assertEq(S("recipes.materialsGrouping"), "totals", "forced grouping=totals")
    assertEq(current()[1].kind, "matRow", "totals+direct -> flat matRows (no recipe subheader)")

    -- byRecipe + direct -> first element is a per-recipe subheader.
    HDG.Store:Dispatch({ type = A.RECIPES_TOGGLE_MATERIALS_GROUPING })
    assertEq(S("recipes.materialsGrouping"), "byRecipe", "toggled to byRecipe")
    assertEq(current()[1].kind, "matSubHeader", "byRecipe+direct -> leads with recipe subheader")

    -- byRecipe + raw -> still subheader-led (byRecipeRaw projection).
    HDG.Store:Dispatch({ type = A.RECIPES_SET_MATERIALS_DEPTH, payload = { value = "raw" } })
    assertEq(S("recipes.materialsDepth"), "raw", "depth set to raw")
    assertEq(current()[1].kind, "matSubHeader", "byRecipe+raw -> recipe subheader (byRecipeRaw)")

    -- totals + raw -> flat list again.
    HDG.Store:Dispatch({ type = A.RECIPES_TOGGLE_MATERIALS_GROUPING })
    assertEq(S("recipes.materialsGrouping"), "totals", "toggled back to totals")
    assertEq(current()[1].kind, "matRow", "totals+raw -> flat matRows")
end

print("test_recipes_smoke.lua passed")
