diff --git a/.luacheckrc b/.luacheckrc index e82e937..3ff82d0 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -47,7 +47,7 @@ files["DragonLoot/"] = { read_globals = { -- WoW API - General - "UnitName", "UnitClass", "IsInInstance", + "UnitName", "UnitClass", "IsInInstance", "GetInstanceInfo", "GetCursorPosition", "GetItemInfoInstant", "GetItemQualityColor", "C_Item", "C_Container", "C_AddOns", "IsAddOnLoaded", "LoadAddOn", "CreateColor", "PlaySoundFile", @@ -58,6 +58,11 @@ files["DragonLoot/"] = { "IsModifiedClick", "GameTooltip_ShowCompareItem", "GetCVarBool", + -- WoW API - UIDropDownMenu (legacy) + "UIDropDownMenu_Initialize", "UIDropDownMenu_CreateInfo", + "UIDropDownMenu_AddButton", "UIDropDownMenu_SetWidth", + "UIDropDownMenu_SetText", + -- WoW API - Loot "GetNumLootItems", "GetLootSlotInfo", "GetLootSlotLink", "GetLootSlotType", "LootSlot", "CloseLoot", "IsFishingLoot", "C_Loot", @@ -72,6 +77,9 @@ files["DragonLoot/"] = { -- WoW API - Loot History "C_LootHistory", + -- WoW API - Encounter Journal + "EJ_GetEncounterInfo", + -- WoW Frames - Loot "LootFrame", "GroupLootFrame1", "GroupLootFrame2", "GroupLootFrame3", "GroupLootFrame4", @@ -116,6 +124,7 @@ files["DragonLoot/Locales/*.lua"] = { files["spec/"] = { globals = { "UISpecialFrames", + "C_LootHistory", }, } diff --git a/AGENTS.md b/AGENTS.md index 3d00d86..e85c9f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,18 +16,20 @@ The repository is structured as a multi-addon project separating core logic, con - **`DragonLoot/`** (Core Addon) - `Core/`: Core bootstrap (`Init.lua`), addon lifecycle (`Lifecycle.lua`), AceDB database configurations, slash commands, and the minimap icon. - - `Display/`: Custom loot frames (`LootFrame.lua`), roll frames (`RollFrame.lua`), history window (`HistoryFrame.lua`), and associated UI/layout animations. - - `Listeners/`: Event listeners handling version-specific API differences (e.g., `LootListener_Retail.lua` vs. `LootListener_Classic.lua`). Uses packager directives (`#@retail@` / `#@non-retail@`) to load correct listeners. + - `Display/`: Custom loot frames (`LootFrame.lua`), roll frames (`RollFrame.lua`), history window (`HistoryFrame.lua`) with encounter/search filtering, and associated UI/layout animations. + - `Listeners/`: Event listeners handling version-specific API differences (e.g., `LootListener_Retail.lua` vs. `LootListener_Classic.lua`, `EncounterListener_Classic.lua`). Uses packager directives (`#@retail@` / `#@non-retail@`) to load correct listeners. - `Locales/`: Localized strings for translation support. - `Libs/`: Embedded external libraries (Ace3, LibSharedMedia-3.0, LibAnimate, etc.). - **`DragonLoot_Options/`** (Load-on-Demand Configuration) - A separate companion addon loaded on-demand when the user opens the options interface. - - Contains individual tabs for configuring appearance, animations, roll frame, and history settings, and embeds the custom `DragonWidgets` library. + - Contains individual tabs for configuring appearance, animations, roll frame, and history settings including the filter bar toggle, and embeds the custom `DragonWidgets` library. - **`spec/`** (Testing Suite) - - Contains the busted unit test suite (`Config_spec.lua`, `Lifecycle_spec.lua`, `MasterLoot_spec.lua`) and a comprehensive WoW API mock harness (`wow_mock.lua`). + - Contains 71 busted tests, including `HistoryFilter_spec.lua` and `EncounterListener_Classic_spec.lua`, and a comprehensive WoW API mock harness (`wow_mock.lua`). ## Config Schema Reference +Current profile schema version: 5. + ### Appearance (`db.profile.appearance`) | Key | Type | Default | @@ -66,29 +68,33 @@ The repository is structured as a multi-addon project separating core logic, con ### History (`db.profile.history`) -| Key | Type | Default | -| ---------------- | ------- | ------- | -| enabled | boolean | true | -| maxEntries | number | 50 | -| autoShow | boolean | false | -| lock | boolean | false | -| trackDirectLoot | boolean | true | -| minQuality | number | 2 | -| rowHeightPadding | number | 6 | +| Key | Type | Default | +| ------------------ | ---------- | ------- | +| enabled | boolean | true | +| maxEntries | number | 100 | +| autoShow | boolean | false | +| lock | boolean | false | +| trackDirectLoot | boolean | true | +| minQuality | number | 2 | +| rowHeightPadding | number | 6 | +| filter.encounterID | number/nil | nil | +| filter.search | string | "" | +| filter.barVisible | boolean | true | ## Version-Specific API Differences -| Aspect | Retail | Classic (TBC/MoP) | -| --------------------------- | ----------------------------- | ----------------------- | -| GetLootSlotInfo returns | 10 | 6 | -| GetLootRollItemInfo returns | 13 (incl canTransmog) | 12 | -| C_LootHistory | Encounter-based | Roll-item indexed | -| CANCEL_ALL_LOOT_ROLLS | Yes | No | -| LOOT_READY event | Yes (fires after LOOT_OPENED) | No | -| C_Loot.GetLootRollDuration | Yes | No | -| Loot listener | LootListener_Retail | LootListener_Classic | -| Roll listener | RollListener_Retail | RollListener_Classic | -| History listener | HistoryListener_Retail | HistoryListener_Classic | +| Aspect | Retail | Classic (TBC/MoP) | +| --------------------------- | ----------------------------- | ------------------------------------------- | +| GetLootSlotInfo returns | 10 | 6 | +| GetLootRollItemInfo returns | 13 (incl canTransmog) | 12 | +| C_LootHistory | Encounter-based | Roll-item indexed | +| CANCEL_ALL_LOOT_ROLLS | Yes | No | +| LOOT_READY event | Yes (fires after LOOT_OPENED) | No | +| C_Loot.GetLootRollDuration | Yes | No | +| Loot listener | LootListener_Retail | LootListener_Classic | +| Roll listener | RollListener_Retail | RollListener_Classic | +| Encounter listener | N/A | EncounterListener_Classic (`#@non-retail@`) | +| History listener | HistoryListener_Retail | HistoryListener_Classic | ## DragonToast Integration diff --git a/DragonLoot/Core/Config.lua b/DragonLoot/Core/Config.lua index 7fd2a7f..1351538 100644 --- a/DragonLoot/Core/Config.lua +++ b/DragonLoot/Core/Config.lua @@ -72,6 +72,14 @@ local defaults = { contentPadding = 6, rowHeightPadding = 6, showRollDetails = true, + -- filter.encounterID is intentionally absent from this literal (nil in a table + -- constructor is a no-op). The field is part of the documented shape and gets + -- populated at runtime when the user selects an encounter in the history UI. + filter = { + -- encounterID = nil, + search = "", + barVisible = true, + }, }, appearance = { @@ -140,7 +148,7 @@ local defaults = { -- Profile Migration ------------------------------------------------------------------------------- -local CURRENT_SCHEMA = 4 +local CURRENT_SCHEMA = 5 local function DeepCopyValue(value) if type(value) ~= "table" then @@ -184,18 +192,15 @@ local function FillMissingDefaults(profile) -- Validate existing values: reset if type doesn't match or is invalid elseif type(currentValue) ~= type(defaultValue) then profile[section][key] = DeepCopyValue(defaultValue) - -- Handle nested tables (like color values {r,g,b}) + -- Handle nested tables (like color values {r,g,b} or history.filter): + -- merge missing/wrong-type keys from defaults without discarding valid + -- existing scalars or extra user fields (e.g. filter.encounterID). elseif type(defaultValue) == "table" then - local isValid = true for k, v in pairs(defaultValue) do - if type(currentValue[k]) ~= type(v) then - isValid = false - break + if currentValue[k] == nil or type(currentValue[k]) ~= type(v) then + currentValue[k] = DeepCopyValue(v) end end - if not isValid then - profile[section][key] = DeepCopyValue(defaultValue) - end end end end @@ -246,6 +251,12 @@ local function MigrateProfile(db) -- handling when InitializeDB passes the updated defaults table to AceDB:New. The schema -- bump is recorded by the unconditional assignment to profile.schemaVersion below. + -- v4 -> v5: introduce db.profile.history.filter for the loot history encounter/search + -- filter UI (issue #89). No transformation of existing data is required - FillMissingDefaults + -- (run above when version < CURRENT_SCHEMA) seeds the new filter sub-table from defaults. + -- Existing db.char.history.entries are deliberately left untouched; pre-feature entries + -- without encounterID surface in the "Unknown encounter" bucket at filter time. + profile.schemaVersion = CURRENT_SCHEMA end diff --git a/DragonLoot/Core/Init.lua b/DragonLoot/Core/Init.lua index 8b19af5..ae04d4c 100755 --- a/DragonLoot/Core/Init.lua +++ b/DragonLoot/Core/Init.lua @@ -302,6 +302,7 @@ local MODULES = { { name = "LootFrame" }, { name = "RollManager" }, { name = "HistoryFrame" }, + { name = "EncounterListener_Classic", passAddon = true }, { name = "HistoryListener", passAddon = true }, { name = "LootHistoryChat", passAddon = true }, { name = "Listeners", passAddon = true }, diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index 77187f5..e11cc4f 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -19,9 +19,18 @@ local time = time local RAID_CLASS_COLORS = RAID_CLASS_COLORS local HandleModifiedItemClick = HandleModifiedItemClick local IsShiftKeyDown = IsShiftKeyDown +local UIDropDownMenu_Initialize = UIDropDownMenu_Initialize +local UIDropDownMenu_CreateInfo = UIDropDownMenu_CreateInfo +local UIDropDownMenu_AddButton = UIDropDownMenu_AddButton +local UIDropDownMenu_SetWidth = UIDropDownMenu_SetWidth +local UIDropDownMenu_SetText = UIDropDownMenu_SetText +local EJ_GetEncounterInfo = EJ_GetEncounterInfo local math_floor = math.floor local math_abs = math.abs local string_format = string.format +local string_lower = string.lower +local string_match = string.match +local string_find = string.find local table_sort = table.sort local tostring = tostring @@ -35,6 +44,7 @@ local DU = ns.DisplayUtils local FRAME_WIDTH = 350 local FRAME_HEIGHT = 400 local TITLE_BAR_HEIGHT = 24 +local FILTER_BAR_HEIGHT = 26 local SCROLL_STEP = 3 local SCROLLBAR_WIDTH = 14 local SCROLLBAR_GAP = 2 @@ -83,6 +93,134 @@ local detailRowPool = {} ns.historyData = {} +------------------------------------------------------------------------------- +-- Filter state (Phase 3: widgets only; Phase 4 will read this in the filter +-- pipeline; Phase 6 will sync with db.profile.history.filter) +------------------------------------------------------------------------------- + +-- Sentinel for filterState.encounterID meaning "only entries with nil +-- encounterID" (i.e. loot recorded outside a tracked encounter). Any non-nil +-- value that can't collide with a real encounterID works; -1 is conventional +-- and reads clearly at callsites. Immutable after this assignment. +local UNKNOWN_ENCOUNTER = -1 + +-- [encounterID] = resolved display name. Populated lazily on Retail via +-- EJ_GetEncounterInfo when the dropdown is opened; Classic entries carry +-- entry.encounterName captured at ENCOUNTER_START and bypass this cache. +local encounterNameCache = {} + +local filterState = { + encounterID = nil, -- nil means "All Encounters" + search = "", +} + +-- Pure: depends only on its two arguments, cached Lua stdlib, and the +-- module-immutable UNKNOWN_ENCOUNTER upvalue (a constant, so the function +-- remains a function of its inputs). Returns true if `entry` should be +-- visible under `state`. See spec/HistoryFilter_spec.lua. +local function MatchesFilter(entry, state) + if not entry or not state then + return true + end + + if state.encounterID == UNKNOWN_ENCOUNTER then + if entry.encounterID ~= nil then + return false + end + elseif state.encounterID ~= nil and entry.encounterID ~= state.encounterID then + return false + end + + local search = state.search + if not search or search == "" then + return true + end + + local needle = string_lower(search) + + local itemLink = entry.itemLink + if itemLink then + local itemName = string_match(itemLink, "%[(.-)%]") + if itemName and string_find(string_lower(itemName), needle, 1, true) then + return true + end + end + + local winner = entry.winner + if winner and winner ~= "" then + if string_find(string_lower(winner), needle, 1, true) then + return true + end + end + + return false +end + +local function GetVisibleEntries() + local out = {} + for i = 1, #ns.historyData do + local e = ns.historyData[i] + if MatchesFilter(e, filterState) then + out[#out + 1] = e + end + end + return out +end + +-- Mirror the in-memory filterState into db.profile.history.filter so the +-- selection survives /reload and session boundaries. Called AFTER every +-- mutation and BEFORE Refresh, so a Refresh failure cannot leave the +-- persisted state stale relative to filterState. +local function PersistFilter() + local db = ns.Addon and ns.Addon.db + if not db or not db.profile or not db.profile.history or not db.profile.history.filter then + return + end + local persisted = db.profile.history.filter + persisted.encounterID = filterState.encounterID + persisted.search = filterState.search +end + +-- Pull persisted filter selection back into filterState. Silent on missing +-- db so it is safe to call from early init paths. +local function RestoreFilter() + local db = ns.Addon and ns.Addon.db + if not db or not db.profile or not db.profile.history or not db.profile.history.filter then + return + end + local persisted = db.profile.history.filter + + local persistedEncounterID = persisted.encounterID + if persistedEncounterID ~= nil and type(persistedEncounterID) ~= "number" then + persistedEncounterID = nil + end + filterState.encounterID = persistedEncounterID + + local persistedSearch = persisted.search + if type(persistedSearch) ~= "string" then + persistedSearch = "" + end + filterState.search = persistedSearch +end + +-- Expose pure filter for unit tests; not part of the public API. +ns.HistoryFrame._MatchesFilter = MatchesFilter +ns.HistoryFrame._UNKNOWN_ENCOUNTER = UNKNOWN_ENCOUNTER +ns.HistoryFrame._PersistFilter = PersistFilter +ns.HistoryFrame._RestoreFilter = RestoreFilter + +local function ShouldShowFilterBar() + local db = ns.Addon and ns.Addon.db and ns.Addon.db.profile + if not db or not db.history or not db.history.filter then + return true + end + return db.history.filter.barVisible ~= false +end + +local function GetTopBarOffset() + return TITLE_BAR_HEIGHT + (ShouldShowFilterBar() and FILTER_BAR_HEIGHT or 0) +end + -- Forward declaration for RefreshHistory (used by OnEntryClick) local RefreshHistory @@ -188,15 +326,10 @@ local function ApplyLayoutOffsets(frame) titleBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -borderSize, -borderSize) -- Scroll frame and scrollbar insets + local topBarOffset = GetTopBarOffset() if scrollFrame then scrollFrame:ClearAllPoints() - scrollFrame:SetPoint( - "TOPLEFT", - frame, - "TOPLEFT", - padding + borderSize, - -(TITLE_BAR_HEIGHT + padding + borderSize) - ) + scrollFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", padding + borderSize, -(topBarOffset + padding + borderSize)) scrollFrame:SetPoint( "BOTTOMRIGHT", frame, @@ -212,7 +345,7 @@ local function ApplyLayoutOffsets(frame) frame, "TOPRIGHT", -(padding + borderSize), - -(TITLE_BAR_HEIGHT + padding + borderSize) + -(topBarOffset + padding + borderSize) ) scrollBar:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -(padding + borderSize), padding + borderSize) end @@ -801,8 +934,9 @@ RefreshHistory = function() local db = ns.Addon.db.profile local entryHeight = GetEntryHeight() local entrySpacing = GetEntrySpacing() + local visible = GetVisibleEntries() local yOffset = 0 - for i, data in ipairs(ns.historyData) do + for i, data in ipairs(visible) do local entry = AcquireEntry() PopulateEntry(entry, data) entry:ClearAllPoints() @@ -827,6 +961,10 @@ RefreshHistory = function() end scrollChild:SetHeight(totalHeight) + if containerFrame.filterBar and containerFrame.filterBar.countText then + containerFrame.filterBar.countText:SetText(string_format("%d/%d", #visible, #ns.historyData)) + end + UpdateScrollBar() end @@ -926,6 +1064,182 @@ local function CreateTitleBar(parent) return titleBar end +------------------------------------------------------------------------------- +-- Filter bar creation (encounter dropdown + search box) +------------------------------------------------------------------------------- + +-- Resolve a display name for an encounterID. Priority: +-- 1. nil ID -> localized "Unknown encounter". +-- 2. entry.encounterName captured at ENCOUNTER_START (Classic listener). +-- 3. Cached lookup from a previous EJ_GetEncounterInfo call. +-- 4. EJ_GetEncounterInfo when available (Retail; may be absent on Classic). +-- 5. tostring(id) fallback so the user can always filter. +local function ResolveEncounterName(encounterID, entry) + if encounterID == nil then + return L["Unknown encounter"] + end + if entry and entry.encounterName and entry.encounterName ~= "" then + return entry.encounterName + end + local cached = encounterNameCache[encounterID] + if cached then + return cached + end + if EJ_GetEncounterInfo then + local name = EJ_GetEncounterInfo(encounterID) + if name and name ~= "" then + encounterNameCache[encounterID] = name + return name + end + end + local fallback = tostring(encounterID) + encounterNameCache[encounterID] = fallback + return fallback +end + +-- Fires on every dropdown open, so the option list always reflects current +-- ns.historyData -- no manual invalidation needed when entries arrive while +-- the dropdown is closed. +local function InitEncounterDropdown(self, level, _menuList) + if level ~= 1 then + return + end + + -- Walk current history once: collect distinct encounterIDs (keeping the + -- first entry seen for each, used as the encounterName source for Classic) + -- and remember whether any entry lacks an encounterID at all. + local seen = {} + local hasUnknown = false + for i = 1, #ns.historyData do + local e = ns.historyData[i] + local id = e.encounterID + if id == nil then + hasUnknown = true + elseif not seen[id] then + seen[id] = e + end + end + + local options = {} + for id, entry in pairs(seen) do + options[#options + 1] = { id = id, name = ResolveEncounterName(id, entry) } + end + table_sort(options, function(a, b) + if a.name == b.name then + return a.id < b.id + end + return a.name < b.name + end) + + local info = UIDropDownMenu_CreateInfo() + info.text = L["All Encounters"] + info.checked = (filterState.encounterID == nil) + info.func = function() + filterState.encounterID = nil + PersistFilter() + UIDropDownMenu_SetText(self, L["All Encounters"]) + ns.HistoryFrame.Refresh() + end + UIDropDownMenu_AddButton(info, level) + + for _, opt in ipairs(options) do + info = UIDropDownMenu_CreateInfo() + info.text = opt.name + info.checked = (filterState.encounterID == opt.id) + info.func = function() + filterState.encounterID = opt.id + PersistFilter() + UIDropDownMenu_SetText(self, opt.name) + ns.HistoryFrame.Refresh() + end + UIDropDownMenu_AddButton(info, level) + end + + if hasUnknown then + info = UIDropDownMenu_CreateInfo() + info.text = L["Unknown encounter"] + info.checked = (filterState.encounterID == UNKNOWN_ENCOUNTER) + info.func = function() + filterState.encounterID = UNKNOWN_ENCOUNTER + PersistFilter() + UIDropDownMenu_SetText(self, L["Unknown encounter"]) + ns.HistoryFrame.Refresh() + end + UIDropDownMenu_AddButton(info, level) + end +end + +local function CreateFilterBar(parent) + local bar = CreateFrame("Frame", nil, parent) + bar:SetHeight(FILTER_BAR_HEIGHT) + bar:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, -TITLE_BAR_HEIGHT) + bar:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, -TITLE_BAR_HEIGHT) + + -- Encounter dropdown (legacy UIDropDownMenu API). + -- Parented to the main container so popup anchor math works correctly. + local encounterDropdown = + CreateFrame("Frame", "DragonLootHistoryEncounterDropdown", parent, "UIDropDownMenuTemplate") + encounterDropdown:SetPoint("LEFT", bar, "LEFT", 4, 0) + encounterDropdown.displayMode = "MENU" + UIDropDownMenu_Initialize(encounterDropdown, InitEncounterDropdown) + UIDropDownMenu_SetWidth(encounterDropdown, 150) + UIDropDownMenu_SetText(encounterDropdown, L["All Encounters"]) + + -- Search box (SearchBoxTemplate gives clear-X + placeholder for free). + local searchBox = CreateFrame("EditBox", nil, bar, "SearchBoxTemplate") + searchBox:SetSize(150, 20) + searchBox:SetPoint("LEFT", encounterDropdown, "RIGHT", 6, 2) + searchBox:SetAutoFocus(false) + searchBox:HookScript("OnTextChanged", function(eb) + filterState.search = eb:GetText() or "" + PersistFilter() + ns.HistoryFrame.Refresh() + end) + + -- Visible-count placeholder (wired in Phase 4). + local countText = bar:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + countText:SetPoint("RIGHT", bar, "RIGHT", -8, 0) + countText:SetText("") + + bar.encounterDropdown = encounterDropdown + bar.searchBox = searchBox + bar.countText = countText + + if ShouldShowFilterBar() then + bar:Show() + else + bar:Hide() + end + + return bar +end + +------------------------------------------------------------------------------- +-- Apply restored filterState to the live widgets at Initialize-time. +-- Module-local: only the Initialize-time restore path needs this today. +------------------------------------------------------------------------------- + +local function ApplyFilterToWidgets() + if not containerFrame or not containerFrame.filterBar then + return + end + local bar = containerFrame.filterBar + local label + if filterState.encounterID == nil then + label = L["All Encounters"] + elseif filterState.encounterID == UNKNOWN_ENCOUNTER then + label = L["Unknown encounter"] + else + label = ResolveEncounterName(filterState.encounterID, nil) + end + UIDropDownMenu_SetText(bar.encounterDropdown, label) + + -- SetText triggers SearchBoxTemplate's OnTextChanged, which our HookScript + -- mirrors back to filterState.search. The value already matches, so the + -- hook is a no-op; PersistFilter writes the same value it just read. + bar.searchBox:SetText(filterState.search or "") +end + ------------------------------------------------------------------------------- -- Scroll frame creation ------------------------------------------------------------------------------- @@ -938,10 +1252,11 @@ end local function CreateScrollComponents(parent) local padding = GetContentPadding() + local topBarOffset = GetTopBarOffset() -- Scroll frame (clip region) local sf = CreateFrame("ScrollFrame", "DragonLootHistoryScroll", parent) - sf:SetPoint("TOPLEFT", parent, "TOPLEFT", padding, -(TITLE_BAR_HEIGHT + padding)) + sf:SetPoint("TOPLEFT", parent, "TOPLEFT", padding, -(topBarOffset + padding)) sf:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -(padding + SCROLLBAR_WIDTH + SCROLLBAR_GAP), padding) -- Scroll child @@ -953,7 +1268,7 @@ local function CreateScrollComponents(parent) -- Scroll bar local bar = CreateFrame("Slider", "DragonLootHistoryScrollBar", parent, "BackdropTemplate") bar:SetWidth(SCROLLBAR_WIDTH) - bar:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -padding, -(TITLE_BAR_HEIGHT + padding)) + bar:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -padding, -(topBarOffset + padding)) bar:SetPoint("BOTTOMRIGHT", parent, "BOTTOMRIGHT", -padding, padding) bar:SetOrientation("VERTICAL") bar:SetMinMaxValues(0, 0) @@ -1007,6 +1322,9 @@ local function CreateContainerFrame() -- Title bar frame.titleBar = CreateTitleBar(frame) + -- Filter bar (encounter dropdown + search) + frame.filterBar = CreateFilterBar(frame) + -- Dragging frame:EnableMouse(true) frame:SetMovable(true) @@ -1071,6 +1389,8 @@ function ns.HistoryFrame.Initialize() ns.HistoryFrame.ApplySettings() RestoreFramePosition() LoadPersistedEntries() + RestoreFilter() + ApplyFilterToWidgets() ns.DebugPrint("HistoryFrame initialized") end @@ -1128,6 +1448,11 @@ function ns.HistoryFrame.ApplySettings() -- Update backdrop ApplyBackdrop(containerFrame) + -- Honor filter bar visibility toggle live + if containerFrame.filterBar then + containerFrame.filterBar:SetShown(ShouldShowFilterBar()) + end + -- Update layout offsets for border thickness ApplyLayoutOffsets(containerFrame) diff --git a/DragonLoot/DragonLoot.toc b/DragonLoot/DragonLoot.toc index 4cdc95c..93421a6 100644 --- a/DragonLoot/DragonLoot.toc +++ b/DragonLoot/DragonLoot.toc @@ -59,6 +59,7 @@ Listeners\HistoryListener_Retail.lua #@non-retail@ Listeners\LootListener_Classic.lua Listeners\RollListener_Classic.lua +Listeners\EncounterListener_Classic.lua Listeners\HistoryListener_Classic.lua Listeners\MasterLootListener_Classic.lua #@end-non-retail@ diff --git a/DragonLoot/Listeners/EncounterListener_Classic.lua b/DragonLoot/Listeners/EncounterListener_Classic.lua new file mode 100644 index 0000000..95fd409 --- /dev/null +++ b/DragonLoot/Listeners/EncounterListener_Classic.lua @@ -0,0 +1,72 @@ +------------------------------------------------------------------------------- +-- EncounterListener_Classic.lua +-- Captures ENCOUNTER_START context for tagging Classic loot history entries +-- +-- Supported versions: MoP Classic, TBC Anniversary, Cata, Classic +------------------------------------------------------------------------------- + +local _, ns = ... + +------------------------------------------------------------------------------- +-- Version guard: Retail uses C_LootHistory encounter info directly; skip. +------------------------------------------------------------------------------- + +if WOW_PROJECT_ID == WOW_PROJECT_MAINLINE then + return +end + +------------------------------------------------------------------------------- +-- Cached WoW API +------------------------------------------------------------------------------- + +local CreateFrame = CreateFrame +local GetTime = GetTime +local GetInstanceInfo = GetInstanceInfo + +------------------------------------------------------------------------------- +-- ns.currentEncounter contract +-- +-- Single-writer convention: this listener is the only writer of +-- ns.currentEncounter. HistoryListener_Classic (and any future readers) read +-- the cache; they MUST NOT mutate it. The cache is sticky - never explicitly +-- cleared - and staleness is bounded at read time by comparing the cached +-- instanceID against GetInstanceInfo()'s current instanceID (see ADR-0004). +-- +-- A private unnamed frame is used instead of addon:RegisterEvent to avoid the +-- shared AceEvent30Frame taint vector documented in workspace AGENTS.md. +------------------------------------------------------------------------------- + +local frame = CreateFrame("Frame") +frame:SetScript("OnEvent", function(_, event, encounterID, encounterName, difficultyID, groupSize) + if event ~= "ENCOUNTER_START" then + return + end + local _, _, _, _, _, _, _, instanceID = GetInstanceInfo() + ns.currentEncounter = { + id = encounterID, + name = encounterName, + difficulty = difficultyID, + groupSize = groupSize, + instanceID = instanceID, + startTime = GetTime(), + } +end) + +------------------------------------------------------------------------------- +-- Public Interface: ns.EncounterListener_Classic +------------------------------------------------------------------------------- + +local function Initialize(_) + frame:RegisterEvent("ENCOUNTER_START") +end + +local function Shutdown() + frame:UnregisterEvent("ENCOUNTER_START") +end + +-- Minimal handle for tests; production code should read ns.currentEncounter. +ns.EncounterListener_Classic = { + _frame = frame, + Initialize = Initialize, + Shutdown = Shutdown, +} diff --git a/DragonLoot/Listeners/HistoryListener_Classic.lua b/DragonLoot/Listeners/HistoryListener_Classic.lua index 8defc01..d3ccf2d 100644 --- a/DragonLoot/Listeners/HistoryListener_Classic.lua +++ b/DragonLoot/Listeners/HistoryListener_Classic.lua @@ -21,6 +21,7 @@ end local C_LootHistory = C_LootHistory local GetItemInfo = GetItemInfo +local GetInstanceInfo = GetInstanceInfo local GetTime = GetTime local time = time local table_sort = table.sort @@ -86,7 +87,7 @@ local function RefreshFromAPI() quality = q or 1 end - entries[#entries + 1] = { + local entry = { itemLink = itemLink, itemTexture = GetItemTexture(itemLink), quality = quality, @@ -99,6 +100,21 @@ local function RefreshFromAPI() isComplete = isDone, rollResults = rollResults, } + + -- Tag with encounter context from EncounterListener_Classic's cache, + -- but only if the player is still in the same instance the encounter + -- started in (ADR-0004). The cache is sticky across reloads/zones, so + -- the instanceID comparison is the staleness guard. + local enc = ns.currentEncounter + if enc then + local _, _, _, _, _, _, _, curInstanceID = GetInstanceInfo() + if enc.instanceID == curInstanceID then + entry.encounterID = enc.id + entry.encounterName = enc.name + end + end + + entries[#entries + 1] = entry end -- Carry forward persisted wallTime for drops we already saw on a prior day. diff --git a/DragonLoot/Locales/enUS.lua b/DragonLoot/Locales/enUS.lua index 91b385a..7aa85a6 100755 --- a/DragonLoot/Locales/enUS.lua +++ b/DragonLoot/Locales/enUS.lua @@ -133,12 +133,16 @@ L["%dh ago"] = true L["%dm ago"] = true L["%ds ago"] = true L["(waiting on rolls)"] = true +L["All Encounters"] = true L["Clear History"] = true L["DragonLoot - Loot History"] = true +L["Encounter"] = true L["Highest: %s (%d)"] = true L["Looted"] = true L["Rolls:"] = true +L["Search history"] = true L["Unknown Item"] = true +L["Unknown encounter"] = true ------------------------------------------------------------------------------- -- DragonLoot_Options @@ -269,6 +273,8 @@ L["Track items you pick up directly (not from a loot window)"] = true L["Roll Details"] = true L["Show Roll Details"] = true L["Click history entries to expand and see all player rolls"] = true +L["Show filter bar"] = true +L["Show the encounter and search filter bar in the history window"] = true L["Permanently delete all stored loot history entries"] = true L["Are you sure you want to clear all loot history? This cannot be undone."] = true diff --git a/DragonLoot_Options/Tabs/HistoryTab.lua b/DragonLoot_Options/Tabs/HistoryTab.lua index c2311d1..7baf81e 100644 --- a/DragonLoot_Options/Tabs/HistoryTab.lua +++ b/DragonLoot_Options/Tabs/HistoryTab.lua @@ -191,6 +191,21 @@ local function CreateDisplaySection(parent, db, yOffset) }) innerY = LC.AnchorWidget(rollDetailsToggle, content, innerY) - LC.SPACING_BETWEEN_WIDGETS + -- Show Filter Bar toggle + local filterBarToggle = W.CreateToggle(content, { + label = L["Show filter bar"], + tooltip = L["Show the encounter and search filter bar in the history window"], + get = function() + return db.profile.history.filter and db.profile.history.filter.barVisible ~= false + end, + set = function(value) + db.profile.history.filter = db.profile.history.filter or {} + db.profile.history.filter.barVisible = value + ApplyHistorySettings() + end, + }) + innerY = LC.AnchorWidget(filterBarToggle, content, innerY) - LC.SPACING_BETWEEN_WIDGETS + -- Slider: Max Entries local maxEntriesSlider = W.CreateSlider(content, { label = L["Max Entries"], diff --git a/spec/Config_spec.lua b/spec/Config_spec.lua index 7b65330..ca3b328 100644 --- a/spec/Config_spec.lua +++ b/spec/Config_spec.lua @@ -14,8 +14,9 @@ describe("Config", function() end) -- Helper: load Config.lua and run InitializeDB on a mock addon - local function initWithSeed(currentNs, profileSeed) + local function initWithSeed(currentNs, profileSeed, charSeed) mock._profileSeed = profileSeed + mock._charSeed = charSeed mock.LoadConfig(currentNs) local addon = { db = nil } currentNs.InitializeDB(addon) @@ -41,7 +42,7 @@ describe("Config", function() local db = initWithSeed(ns, nil) -- Keep in sync with CURRENT_SCHEMA in DragonLoot/Core/Config.lua - assert.are.equal(4, db.profile.schemaVersion) + assert.are.equal(5, db.profile.schemaVersion) end) it("has lootIconSize in a fresh profile", function() @@ -148,7 +149,7 @@ describe("Config", function() -- Seed at current schema so FillMissingDefaults is skipped and the -- (unconditional) iconSize-split migration can propagate iconSize=48. -- Keep in sync with CURRENT_SCHEMA in DragonLoot/Core/Config.lua. - schemaVersion = 4, + schemaVersion = 5, appearance = { iconSize = 48, -- lootIconSize intentionally absent to test migration propagation @@ -172,7 +173,125 @@ describe("Config", function() }) -- Keep in sync with CURRENT_SCHEMA in DragonLoot/Core/Config.lua - assert.are.equal(4, db.profile.schemaVersion) + assert.are.equal(5, db.profile.schemaVersion) + end) + end) + + --------------------------------------------------------------------------- + -- history.filter schema migration v4 -> v5 + --------------------------------------------------------------------------- + + describe("history.filter schema migration v4 -> v5", function() + it("adds default filter sub-table when migrating from v4", function() + local db = initWithSeed(ns, { + schemaVersion = 4, + history = { + enabled = true, + maxEntries = 100, + autoShow = false, + -- filter intentionally absent (v4 shape) + }, + }, { + history = { + schemaVersion = 4, + entries = {}, + }, + }) + + assert.is_table(db.profile.history.filter) + assert.are.equal("", db.profile.history.filter.search) + assert.is_true(db.profile.history.filter.barVisible) + assert.is_nil(db.profile.history.filter.encounterID) + end) + + it("preserves existing history entries during migration", function() + local seededEntries = { + { itemLink = "|cffa335ee|Hitem:123::::::::1::::::|h[Item One]|h|r", winner = "Alice", wallTime = 100 }, + { itemLink = "|cffa335ee|Hitem:456::::::::1::::::|h[Item Two]|h|r", winner = "Bob", wallTime = 200 }, + { + itemLink = "|cffa335ee|Hitem:789::::::::1::::::|h[Item Three]|h|r", + winner = "Carol", + wallTime = 300, + }, + } + + local db = initWithSeed(ns, { + schemaVersion = 4, + history = { + enabled = true, + maxEntries = 100, + }, + }, { + history = { + schemaVersion = 4, + entries = seededEntries, + }, + }) + + assert.is_table(db.char) + assert.is_table(db.char.history) + assert.is_table(db.char.history.entries) + assert.are.equal(3, #db.char.history.entries) + assert.are.equal("Alice", db.char.history.entries[1].winner) + assert.are.equal(100, db.char.history.entries[1].wallTime) + assert.are.equal("Bob", db.char.history.entries[2].winner) + assert.are.equal("Carol", db.char.history.entries[3].winner) + assert.are.equal("|cffa335ee|Hitem:123::::::::1::::::|h[Item One]|h|r", db.char.history.entries[1].itemLink) + end) + + it("sets schemaVersion to 5 after migration", function() + local db = initWithSeed(ns, { + schemaVersion = 4, + history = { + enabled = true, + maxEntries = 100, + }, + }) + + assert.are.equal(5, db.profile.schemaVersion) + end) + + it("is a no-op for fresh installs at v5", function() + local db = initWithSeed(ns, nil) + + assert.is_table(db.profile.history.filter) + assert.is_true(db.profile.history.filter.barVisible) + assert.are.equal("", db.profile.history.filter.search) + assert.are.equal(5, db.profile.schemaVersion) + end) + + it("preserves existing filter fields when adding missing defaults", function() + local db = initWithSeed(ns, { + schemaVersion = 4, + history = { + enabled = true, + maxEntries = 100, + filter = { + search = "ony", + encounterID = 123, + -- barVisible intentionally absent + }, + }, + }) + + assert.are.equal("ony", db.profile.history.filter.search) + assert.are.equal(123, db.profile.history.filter.encounterID) + assert.is_true(db.profile.history.filter.barVisible) + end) + + it("replaces a corrupt non-table filter with defaults", function() + local db = initWithSeed(ns, { + schemaVersion = 4, + history = { + enabled = true, + maxEntries = 100, + filter = "garbage string", + }, + }) + + assert.is_table(db.profile.history.filter) + assert.is_true(db.profile.history.filter.barVisible) + assert.are.equal("", db.profile.history.filter.search) end) end) end) diff --git a/spec/EncounterListener_Classic_spec.lua b/spec/EncounterListener_Classic_spec.lua new file mode 100644 index 0000000..a921296 --- /dev/null +++ b/spec/EncounterListener_Classic_spec.lua @@ -0,0 +1,154 @@ +------------------------------------------------------------------------------- +-- EncounterListener_Classic_spec.lua +-- Tests for ENCOUNTER_START capture and HistoryListener_Classic tag-time guard +------------------------------------------------------------------------------- + +local mock = require("spec.wow_mock") + +-- Force the Classic runtime guard (WOW_PROJECT_ID != WOW_PROJECT_MAINLINE) for +-- the duration of this spec. Restore on teardown so other specs are unaffected. +local savedProjectID +local CLASSIC_PROJECT_ID = 5 + +local function NewClassicNamespace() + local ns = mock.CreateNamespace() + ns.IsRetail = false + ns.IsClassic = true + ns.ListenerShared = { + GetItemTexture = function() + return 0 + end, + } + return ns +end + +local function StubLootHistorySingleItem(itemLink) + rawset(_G, "C_LootHistory", { + GetNumItems = function() + return 1 + end, + GetItem = function() + -- returns: rollID, itemLink, numPlayers, isDone, winnerIdx + return 1, itemLink, 1, true, 1 + end, + GetPlayerInfo = function() + return "Winner", "WARRIOR", 1, 50 + end, + }) +end + +local function MakeAddonStub() + local addon = {} + function addon:RegisterEvent() end + function addon:UnregisterEvent() end + return addon +end + +describe("EncounterListener_Classic", function() + before_each(function() + mock.Reset() + savedProjectID = _G.WOW_PROJECT_ID + rawset(_G, "WOW_PROJECT_ID", CLASSIC_PROJECT_ID) + end) + + after_each(function() + rawset(_G, "WOW_PROJECT_ID", savedProjectID) + end) + + it("sets ns.currentEncounter on ENCOUNTER_START", function() + local ns = NewClassicNamespace() + mock.SetInstance(409) -- Molten Core + mock.SetTime(123) + + mock.LoadFile(ns, "DragonLoot/Listeners/EncounterListener_Classic.lua") + ns.EncounterListener_Classic.Initialize(MakeAddonStub()) + + assert.is_nil(ns.currentEncounter) + + mock.FireEvent("ENCOUNTER_START", 663, "Lucifron", 9, 40) + + assert.is_not_nil(ns.currentEncounter) + assert.are.equal(663, ns.currentEncounter.id) + assert.are.equal("Lucifron", ns.currentEncounter.name) + assert.are.equal(9, ns.currentEncounter.difficulty) + assert.are.equal(40, ns.currentEncounter.groupSize) + assert.are.equal(409, ns.currentEncounter.instanceID) + assert.are.equal(123, ns.currentEncounter.startTime) + end) + + it("tags a Classic history entry when instanceID matches", function() + local ns = NewClassicNamespace() + local captured + ns.HistoryFrame = { + SetEntries = function(entries) + captured = entries + end, + } + + mock.SetInstance(409) + mock.LoadFile(ns, "DragonLoot/Listeners/EncounterListener_Classic.lua") + ns.EncounterListener_Classic.Initialize(MakeAddonStub()) + mock.FireEvent("ENCOUNTER_START", 663, "Lucifron", 9, 40) + + StubLootHistorySingleItem("|cffa335ee|Hitem:18814::::::::60:::::|h[Choker]|h|r") + mock.LoadFile(ns, "DragonLoot/Listeners/HistoryListener_Classic.lua") + ns.HistoryListener.Initialize(MakeAddonStub()) + + assert.is_not_nil(captured) + assert.are.equal(1, #captured) + assert.are.equal(663, captured[1].encounterID) + assert.are.equal("Lucifron", captured[1].encounterName) + end) + + it("drops the tag when player has left the instance (instanceID differs)", function() + local ns = NewClassicNamespace() + local captured + ns.HistoryFrame = { + SetEntries = function(entries) + captured = entries + end, + } + + mock.SetInstance(409) + mock.LoadFile(ns, "DragonLoot/Listeners/EncounterListener_Classic.lua") + ns.EncounterListener_Classic.Initialize(MakeAddonStub()) + mock.FireEvent("ENCOUNTER_START", 663, "Lucifron", 9, 40) + + -- Player leaves the instance before loot arrives. + mock.SetInstance(0) + + StubLootHistorySingleItem("|cffa335ee|Hitem:18814::::::::60:::::|h[Choker]|h|r") + mock.LoadFile(ns, "DragonLoot/Listeners/HistoryListener_Classic.lua") + ns.HistoryListener.Initialize(MakeAddonStub()) + + assert.is_not_nil(captured) + assert.are.equal(1, #captured) + assert.is_nil(captured[1].encounterID) + assert.is_nil(captured[1].encounterName) + end) + + it("writes no encounter fields when no ENCOUNTER_START ever fired", function() + local ns = NewClassicNamespace() + local captured + ns.HistoryFrame = { + SetEntries = function(entries) + captured = entries + end, + } + + mock.SetInstance(409) + mock.LoadFile(ns, "DragonLoot/Listeners/EncounterListener_Classic.lua") + ns.EncounterListener_Classic.Initialize(MakeAddonStub()) + -- No ENCOUNTER_START fired. + + StubLootHistorySingleItem("|cffa335ee|Hitem:18814::::::::60:::::|h[Choker]|h|r") + mock.LoadFile(ns, "DragonLoot/Listeners/HistoryListener_Classic.lua") + ns.HistoryListener.Initialize(MakeAddonStub()) + + assert.is_nil(ns.currentEncounter) + assert.is_not_nil(captured) + assert.are.equal(1, #captured) + assert.is_nil(captured[1].encounterID) + assert.is_nil(captured[1].encounterName) + end) +end) diff --git a/spec/HistoryFilter_spec.lua b/spec/HistoryFilter_spec.lua new file mode 100644 index 0000000..11c4d9a --- /dev/null +++ b/spec/HistoryFilter_spec.lua @@ -0,0 +1,184 @@ +------------------------------------------------------------------------------- +-- HistoryFilter_spec.lua +-- Tests for the pure MatchesFilter predicate used by the history filter bar +------------------------------------------------------------------------------- + +local mock = require("spec.wow_mock") + +local function MakeEntry(overrides) + local entry = { + itemLink = "|cffffffff|Hitem:1234::::::::40:0:0:0:0|h[Onyxia's Tooth]|h|r", + winner = "Thrall", + encounterID = 1084, + encounterName = "Onyxia", + } + if overrides then + for k, v in pairs(overrides) do + entry[k] = v + end + end + return entry +end + +describe("MatchesFilter", function() + local MatchesFilter + local UNKNOWN_ENCOUNTER + + before_each(function() + mock.Reset() + local ns = mock.CreateNamespace() + ns.historyData = {} + mock.LoadFile(ns, "DragonLoot/Display/HistoryFrame.lua") + MatchesFilter = ns.HistoryFrame._MatchesFilter + UNKNOWN_ENCOUNTER = ns.HistoryFrame._UNKNOWN_ENCOUNTER + assert.is_function(MatchesFilter) + assert.is_not_nil(UNKNOWN_ENCOUNTER) + end) + + it("returns true for any entry when filter is empty", function() + local entry = { itemLink = nil, winner = nil, encounterID = nil } + assert.is_true(MatchesFilter(entry, { encounterID = nil, search = "" })) + + local rich = MakeEntry() + assert.is_true(MatchesFilter(rich, { encounterID = nil, search = "" })) + end) + + it("matches when entry encounterID equals filter encounterID", function() + local entry = MakeEntry({ encounterID = 1084 }) + assert.is_true(MatchesFilter(entry, { encounterID = 1084, search = "" })) + end) + + it("rejects entry with nil encounterID when a specific encounter is filtered", function() + local entry = MakeEntry() + entry.encounterID = nil + assert.is_false(MatchesFilter(entry, { encounterID = 1084, search = "" })) + end) + + it("rejects entry with different encounterID", function() + local entry = MakeEntry({ encounterID = 663 }) + assert.is_false(MatchesFilter(entry, { encounterID = 1084, search = "" })) + end) + + it("matches item name case-insensitively", function() + local entry = MakeEntry() + assert.is_true(MatchesFilter(entry, { encounterID = nil, search = "ONY" })) + assert.is_true(MatchesFilter(entry, { encounterID = nil, search = "tooth" })) + end) + + it("matches winner name case-insensitively", function() + local entry = MakeEntry({ winner = "Jaina" }) + assert.is_true(MatchesFilter(entry, { encounterID = nil, search = "jai" })) + assert.is_true(MatchesFilter(entry, { encounterID = nil, search = "JAINA" })) + end) + + it("returns false when neither item nor winner contains the search", function() + local entry = MakeEntry({ winner = "Thrall" }) + assert.is_false(MatchesFilter(entry, { encounterID = nil, search = "zzz" })) + end) + + it("returns false when both itemLink and winner are nil and search is non-empty", function() + local entry = { itemLink = nil, winner = nil, encounterID = nil } + assert.is_false(MatchesFilter(entry, { encounterID = nil, search = "any" })) + end) + + it("combined filter: encounter matches AND search matches -> pass", function() + local entry = MakeEntry({ encounterID = 1084, winner = "Thrall" }) + assert.is_true(MatchesFilter(entry, { encounterID = 1084, search = "thrall" })) + end) + + it("combined filter: encounter matches but search fails -> fail", function() + local entry = MakeEntry({ encounterID = 1084, winner = "Thrall" }) + assert.is_false(MatchesFilter(entry, { encounterID = 1084, search = "zzz" })) + end) + + it("combined filter: encounter fails even if search would match -> fail", function() + local entry = MakeEntry({ encounterID = 663, winner = "Thrall" }) + assert.is_false(MatchesFilter(entry, { encounterID = 1084, search = "thrall" })) + end) + + it("malformed item link (no bracketed name) falls back to winner-only search", function() + local entry = MakeEntry({ itemLink = "|cffffffff|Hitem:1234::|h|r", winner = "Thrall" }) + -- "thrall" hits winner + assert.is_true(MatchesFilter(entry, { encounterID = nil, search = "thrall" })) + -- "ony" would have hit item name but link has no [...], and winner has no "ony" + assert.is_false(MatchesFilter(entry, { encounterID = nil, search = "ony" })) + end) + + it("UNKNOWN_ENCOUNTER state matches entry with nil encounterID", function() + local entry = MakeEntry() + entry.encounterID = nil + entry.encounterName = nil + assert.is_true(MatchesFilter(entry, { encounterID = UNKNOWN_ENCOUNTER, search = "" })) + end) + + it("UNKNOWN_ENCOUNTER state rejects entry with a real encounterID", function() + local entry = MakeEntry({ encounterID = 1084 }) + assert.is_false(MatchesFilter(entry, { encounterID = UNKNOWN_ENCOUNTER, search = "" })) + end) +end) + +describe("Filter persistence", function() + local function LoadFrame(profileSeed) + mock.Reset() + mock._profileSeed = profileSeed + local ns = mock.CreateNamespace() + ns.historyData = {} + mock.LoadFile(ns, "DragonLoot/Display/HistoryFrame.lua") + -- Mimic the addon init wiring so ns.Addon.db exists for the helpers. + ns.Addon.db = LibStub("AceDB-3.0"):New("DragonLootDB", { profile = {} }, true) + return ns + end + + it("RestoreFilter copies persisted encounterID and search into filterState", function() + local ns = LoadFrame({ + history = { filter = { encounterID = 1084, search = "thrall", barVisible = true } }, + }) + + ns.HistoryFrame._RestoreFilter() + + -- PersistFilter then mirrors filterState back; round-trip must preserve values. + ns.HistoryFrame._PersistFilter() + local stored = ns.Addon.db.profile.history.filter + assert.equals(1084, stored.encounterID) + assert.equals("thrall", stored.search) + end) + + it("RestoreFilter defaults search to empty string when persisted value is nil", function() + local ns = LoadFrame({ + history = { filter = { barVisible = true } }, + }) + + ns.HistoryFrame._RestoreFilter() + ns.HistoryFrame._PersistFilter() + local stored = ns.Addon.db.profile.history.filter + assert.is_nil(stored.encounterID) + assert.equals("", stored.search) + end) + + it("PersistFilter writes UNKNOWN_ENCOUNTER sentinel through unchanged", function() + local UNKNOWN = -1 + local ns = LoadFrame({ + history = { filter = { encounterID = UNKNOWN, search = "" } }, + }) + assert.equals(UNKNOWN, ns.HistoryFrame._UNKNOWN_ENCOUNTER) + + ns.HistoryFrame._RestoreFilter() + ns.HistoryFrame._PersistFilter() + assert.equals(UNKNOWN, ns.Addon.db.profile.history.filter.encounterID) + end) + + it("RestoreFilter is a no-op when ns.Addon.db is unset", function() + mock.Reset() + local ns = mock.CreateNamespace() + ns.historyData = {} + mock.LoadFile(ns, "DragonLoot/Display/HistoryFrame.lua") + ns.Addon.db = nil + + assert.has_no.errors(function() + ns.HistoryFrame._RestoreFilter() + end) + assert.has_no.errors(function() + ns.HistoryFrame._PersistFilter() + end) + end) +end) diff --git a/spec/wow_mock.lua b/spec/wow_mock.lua index e1e10a9..ac5e3a7 100644 --- a/spec/wow_mock.lua +++ b/spec/wow_mock.lua @@ -29,6 +29,17 @@ function GetTime() return mockTime end +function time() + return mockTime +end + +function GetItemInfo(link) + if link == nil then + return nil + end + return link, link, 4 +end + function InCombatLockdown() return M._inCombat or false end @@ -41,6 +52,49 @@ function IsInInstance() return false, "none" end +------------------------------------------------------------------------------- +-- Instance info (settable per test). Tests call M.SetInstance(id) or +-- M.SetInstanceInfo({...}) to override; GetInstanceInfo returns the standard +-- 8-tuple (name, instanceType, difficultyID, difficultyName, maxPlayers, +-- dynamicDifficulty, isDynamic, instanceID). +------------------------------------------------------------------------------- + +M._instanceInfo = { + name = "", + instanceType = "none", + difficultyID = 0, + difficultyName = "", + maxPlayers = 0, + dynamicDifficulty = 0, + isDynamic = false, + instanceID = 0, +} + +function M.SetInstance(id) + M._instanceInfo.instanceID = id or 0 +end + +function M.SetInstanceInfo(info) + if type(info) ~= "table" then + return + end + for k, v in pairs(info) do + M._instanceInfo[k] = v + end +end + +function GetInstanceInfo() + local i = M._instanceInfo + return i.name, + i.instanceType, + i.difficultyID, + i.difficultyName, + i.maxPlayers, + i.dynamicDifficulty, + i.isDynamic, + i.instanceID +end + function PlaySound() end function PlaySoundFile() end function hooksecurefunc() end @@ -301,8 +355,15 @@ local aceDBMock = { else profile = DeepCopyTable(defaultsArg and defaultsArg.profile or {}) end + local char + if M._charSeed then + char = DeepCopyTable(M._charSeed) + else + char = DeepCopyTable(defaultsArg and defaultsArg.char or {}) + end local db = { profile = profile, + char = char, RegisterCallback = function() end, CancelCallback = function() end, } @@ -463,6 +524,7 @@ function M.Reset() mockTime = 0 M._inCombat = false M._profileSeed = nil + M._charSeed = nil M._masterLoot.candidates = {} M._masterLoot.given = {} @@ -472,6 +534,15 @@ function M.Reset() M._group.isMasterLooter = true M._group.units = {} + M._instanceInfo.name = "" + M._instanceInfo.instanceType = "none" + M._instanceInfo.difficultyID = 0 + M._instanceInfo.difficultyName = "" + M._instanceInfo.maxPlayers = 0 + M._instanceInfo.dynamicDifficulty = 0 + M._instanceInfo.isDynamic = false + M._instanceInfo.instanceID = 0 + -- Detach event handlers on any frames left over from a prior test. We -- keep the frames themselves around (UIParent and similar singletons -- are created once at module load) but make sure FireEvent cannot