From 6037fc744a91c12b7c4c3ba9e0749eab9fa92a89 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 10:42:10 +0200 Subject: [PATCH 01/12] feat(config): add history.filter schema (v4->v5 migration) for issue #89 --- DragonLoot/Core/Config.lua | 16 ++++++- spec/Config_spec.lua | 93 ++++++++++++++++++++++++++++++++++++-- spec/wow_mock.lua | 8 ++++ 3 files changed, 112 insertions(+), 5 deletions(-) diff --git a/DragonLoot/Core/Config.lua b/DragonLoot/Core/Config.lua index 7fd2a7f..f78b6e8 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 @@ -246,6 +254,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/spec/Config_spec.lua b/spec/Config_spec.lua index 7b65330..0466278 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,91 @@ 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) end) end) diff --git a/spec/wow_mock.lua b/spec/wow_mock.lua index e1e10a9..a01c15d 100644 --- a/spec/wow_mock.lua +++ b/spec/wow_mock.lua @@ -301,8 +301,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 +470,7 @@ function M.Reset() mockTime = 0 M._inCombat = false M._profileSeed = nil + M._charSeed = nil M._masterLoot.candidates = {} M._masterLoot.given = {} From d8d586cce955189de7557a2df9d8044350f813f1 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 10:49:05 +0200 Subject: [PATCH 02/12] fix(config): merge partial history.filter tables instead of replacing --- DragonLoot/Core/Config.lua | 13 +++++-------- spec/Config_spec.lua | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/DragonLoot/Core/Config.lua b/DragonLoot/Core/Config.lua index f78b6e8..1351538 100644 --- a/DragonLoot/Core/Config.lua +++ b/DragonLoot/Core/Config.lua @@ -192,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 diff --git a/spec/Config_spec.lua b/spec/Config_spec.lua index 0466278..ca3b328 100644 --- a/spec/Config_spec.lua +++ b/spec/Config_spec.lua @@ -259,5 +259,39 @@ describe("Config", function() 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) From 64c396b7a1f667c2d77b57fb525a38abb64c8b96 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 10:57:43 +0200 Subject: [PATCH 03/12] feat(listeners): capture Classic encounter context for history entries (#89) --- .luacheckrc | 3 +- DragonLoot/DragonLoot.toc | 1 + .../Listeners/EncounterListener_Classic.lua | 57 +++++++ .../Listeners/HistoryListener_Classic.lua | 18 ++- spec/EncounterListener_Classic_spec.lua | 150 ++++++++++++++++++ spec/wow_mock.lua | 57 +++++++ 6 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 DragonLoot/Listeners/EncounterListener_Classic.lua create mode 100644 spec/EncounterListener_Classic_spec.lua diff --git a/.luacheckrc b/.luacheckrc index e82e937..9d2acc3 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", @@ -116,6 +116,7 @@ files["DragonLoot/Locales/*.lua"] = { files["spec/"] = { globals = { "UISpecialFrames", + "C_LootHistory", }, } 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..9e19350 --- /dev/null +++ b/DragonLoot/Listeners/EncounterListener_Classic.lua @@ -0,0 +1,57 @@ +------------------------------------------------------------------------------- +-- 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:RegisterEvent("ENCOUNTER_START") +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) + +-- Minimal handle for tests; production code should read ns.currentEncounter. +ns.EncounterListener_Classic = { _frame = frame } 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/spec/EncounterListener_Classic_spec.lua b/spec/EncounterListener_Classic_spec.lua new file mode 100644 index 0000000..ab3b836 --- /dev/null +++ b/spec/EncounterListener_Classic_spec.lua @@ -0,0 +1,150 @@ +------------------------------------------------------------------------------- +-- 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") + + 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") + 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") + 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") + -- 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/wow_mock.lua b/spec/wow_mock.lua index a01c15d..eb94a5d 100644 --- a/spec/wow_mock.lua +++ b/spec/wow_mock.lua @@ -29,6 +29,14 @@ function GetTime() return mockTime end +function time() + return mockTime +end + +function GetItemInfo(link) + return link or "Item", link, 4 +end + function InCombatLockdown() return M._inCombat or false end @@ -41,6 +49,46 @@ 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) + 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 @@ -480,6 +528,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 From e040e4543fe11ad0c7f731ff8e25606ac5929e56 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 11:13:09 +0200 Subject: [PATCH 04/12] feat(history): add filter sub-bar with encounter dropdown and search input (#89) --- .luacheckrc | 5 ++ DragonLoot/Display/HistoryFrame.lua | 103 +++++++++++++++++++++++++++- DragonLoot/Locales/enUS.lua | 4 ++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 9d2acc3..7c32265 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -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", diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index 77187f5..d712149 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -19,6 +19,11 @@ 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 math_floor = math.floor local math_abs = math.abs local string_format = string.format @@ -35,6 +40,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 +89,28 @@ 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) +------------------------------------------------------------------------------- + +local filterState = { + encounterID = nil, -- nil means "All Encounters" + search = "", +} + +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 @@ -926,6 +954,73 @@ local function CreateTitleBar(parent) return titleBar end +------------------------------------------------------------------------------- +-- Filter bar creation (encounter dropdown + search box) +------------------------------------------------------------------------------- + +local function InitEncounterDropdown(self, level, _menuList) + if level ~= 1 then + return + end + local info = UIDropDownMenu_CreateInfo() + info.text = L["All Encounters"] + info.checked = (filterState.encounterID == nil) + info.func = function() + filterState.encounterID = nil + UIDropDownMenu_SetText(self, L["All Encounters"]) + -- Phase 4 will trigger refresh here + end + UIDropDownMenu_AddButton(info, level) + -- Phase 5 will append per-encounter options here. +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", "DragonLootHistorySearchBox", bar, "SearchBoxTemplate") + searchBox:SetSize(150, 20) + searchBox:SetPoint("LEFT", encounterDropdown, "RIGHT", 6, 2) + searchBox:SetAutoFocus(false) + searchBox:SetScript("OnTextChanged", function(eb, userInput) + if not userInput then + return + end + filterState.search = eb:GetText() or "" + -- Phase 4 will trigger refresh here + 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 + ------------------------------------------------------------------------------- -- Scroll frame creation ------------------------------------------------------------------------------- @@ -938,10 +1033,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 +1049,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 +1103,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) diff --git a/DragonLoot/Locales/enUS.lua b/DragonLoot/Locales/enUS.lua index 91b385a..0ecb068 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 From ce3407b22914a372ad7790730dd9913d19b80922 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 11:20:45 +0200 Subject: [PATCH 05/12] fix(history): hook SearchBox OnTextChanged and capture programmatic clears (#89) --- DragonLoot/Display/HistoryFrame.lua | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index d712149..e60fa20 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -995,10 +995,7 @@ local function CreateFilterBar(parent) searchBox:SetSize(150, 20) searchBox:SetPoint("LEFT", encounterDropdown, "RIGHT", 6, 2) searchBox:SetAutoFocus(false) - searchBox:SetScript("OnTextChanged", function(eb, userInput) - if not userInput then - return - end + searchBox:HookScript("OnTextChanged", function(eb) filterState.search = eb:GetText() or "" -- Phase 4 will trigger refresh here end) From 9991bc70de5b003db478860755c12d18e67e304d Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 11:25:47 +0200 Subject: [PATCH 06/12] feat(history): apply encounter and search filters to visible entries (#89) --- DragonLoot/Display/HistoryFrame.lua | 64 ++++++++++++++++- spec/HistoryFilter_spec.lua | 103 ++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 spec/HistoryFilter_spec.lua diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index e60fa20..bb2e37a 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -27,6 +27,9 @@ local UIDropDownMenu_SetText = UIDropDownMenu_SetText 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 @@ -99,6 +102,56 @@ local filterState = { search = "", } +-- Pure: depends only on its two arguments and cached Lua stdlib. 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 ~= 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 + +-- Expose pure filter for unit tests; not part of the public API. +ns.HistoryFrame._MatchesFilter = MatchesFilter + 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 @@ -829,8 +882,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() @@ -855,6 +909,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 @@ -968,7 +1026,7 @@ local function InitEncounterDropdown(self, level, _menuList) info.func = function() filterState.encounterID = nil UIDropDownMenu_SetText(self, L["All Encounters"]) - -- Phase 4 will trigger refresh here + ns.HistoryFrame.Refresh() end UIDropDownMenu_AddButton(info, level) -- Phase 5 will append per-encounter options here. @@ -997,7 +1055,7 @@ local function CreateFilterBar(parent) searchBox:SetAutoFocus(false) searchBox:HookScript("OnTextChanged", function(eb) filterState.search = eb:GetText() or "" - -- Phase 4 will trigger refresh here + ns.HistoryFrame.Refresh() end) -- Visible-count placeholder (wired in Phase 4). diff --git a/spec/HistoryFilter_spec.lua b/spec/HistoryFilter_spec.lua new file mode 100644 index 0000000..f4a6a93 --- /dev/null +++ b/spec/HistoryFilter_spec.lua @@ -0,0 +1,103 @@ +------------------------------------------------------------------------------- +-- 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 + + before_each(function() + mock.Reset() + local ns = mock.CreateNamespace() + ns.historyData = {} + mock.LoadFile(ns, "DragonLoot/Display/HistoryFrame.lua") + MatchesFilter = ns.HistoryFrame._MatchesFilter + assert.is_function(MatchesFilter) + 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) +end) From 1a7b236620cee9e6963feefd4deb321dc054fd32 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 11:31:26 +0200 Subject: [PATCH 07/12] feat(history): populate encounter dropdown with resolved names (#89) --- .luacheckrc | 3 + DragonLoot/Display/HistoryFrame.lua | 109 +++++++++++++++++++++++++++- spec/HistoryFilter_spec.lua | 15 ++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 7c32265..3ff82d0 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -77,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", diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index bb2e37a..aeed0ce 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -24,6 +24,7 @@ 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 @@ -97,19 +98,36 @@ ns.historyData = {} -- 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 and cached Lua stdlib. Returns true --- if `entry` should be visible under `state`. See spec/HistoryFilter_spec.lua. +-- 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 ~= nil and entry.encounterID ~= state.encounterID then + 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 @@ -151,6 +169,7 @@ end -- Expose pure filter for unit tests; not part of the public API. ns.HistoryFrame._MatchesFilter = MatchesFilter +ns.HistoryFrame._UNKNOWN_ENCOUNTER = UNKNOWN_ENCOUNTER local function ShouldShowFilterBar() local db = ns.Addon and ns.Addon.db and ns.Addon.db.profile @@ -1016,10 +1035,69 @@ 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) @@ -1029,7 +1107,30 @@ local function InitEncounterDropdown(self, level, _menuList) ns.HistoryFrame.Refresh() end UIDropDownMenu_AddButton(info, level) - -- Phase 5 will append per-encounter options here. + + 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 + 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 + UIDropDownMenu_SetText(self, L["Unknown encounter"]) + ns.HistoryFrame.Refresh() + end + UIDropDownMenu_AddButton(info, level) + end end local function CreateFilterBar(parent) diff --git a/spec/HistoryFilter_spec.lua b/spec/HistoryFilter_spec.lua index f4a6a93..a4c3621 100644 --- a/spec/HistoryFilter_spec.lua +++ b/spec/HistoryFilter_spec.lua @@ -22,6 +22,7 @@ end describe("MatchesFilter", function() local MatchesFilter + local UNKNOWN_ENCOUNTER before_each(function() mock.Reset() @@ -29,7 +30,9 @@ describe("MatchesFilter", function() 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() @@ -100,4 +103,16 @@ describe("MatchesFilter", function() -- "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) From a08471b6eddcb28fe661dac822d88f923e35bcb5 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 11:50:40 +0200 Subject: [PATCH 08/12] feat(history): persist and restore filter selection across reloads (#89) --- DragonLoot/Display/HistoryFrame.lua | 60 ++++++++++++++++++++++++++ spec/HistoryFilter_spec.lua | 66 +++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index aeed0ce..315cc0d 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -167,9 +167,37 @@ local function GetVisibleEntries() 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 + filterState.encounterID = persisted.encounterID + filterState.search = persisted.search or "" +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 @@ -1103,6 +1131,7 @@ local function InitEncounterDropdown(self, level, _menuList) info.checked = (filterState.encounterID == nil) info.func = function() filterState.encounterID = nil + PersistFilter() UIDropDownMenu_SetText(self, L["All Encounters"]) ns.HistoryFrame.Refresh() end @@ -1114,6 +1143,7 @@ local function InitEncounterDropdown(self, level, _menuList) info.checked = (filterState.encounterID == opt.id) info.func = function() filterState.encounterID = opt.id + PersistFilter() UIDropDownMenu_SetText(self, opt.name) ns.HistoryFrame.Refresh() end @@ -1126,6 +1156,7 @@ local function InitEncounterDropdown(self, level, _menuList) info.checked = (filterState.encounterID == UNKNOWN_ENCOUNTER) info.func = function() filterState.encounterID = UNKNOWN_ENCOUNTER + PersistFilter() UIDropDownMenu_SetText(self, L["Unknown encounter"]) ns.HistoryFrame.Refresh() end @@ -1156,6 +1187,7 @@ local function CreateFilterBar(parent) searchBox:SetAutoFocus(false) searchBox:HookScript("OnTextChanged", function(eb) filterState.search = eb:GetText() or "" + PersistFilter() ns.HistoryFrame.Refresh() end) @@ -1177,6 +1209,32 @@ local function CreateFilterBar(parent) 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 ------------------------------------------------------------------------------- @@ -1326,6 +1384,8 @@ function ns.HistoryFrame.Initialize() ns.HistoryFrame.ApplySettings() RestoreFramePosition() LoadPersistedEntries() + RestoreFilter() + ApplyFilterToWidgets() ns.DebugPrint("HistoryFrame initialized") end diff --git a/spec/HistoryFilter_spec.lua b/spec/HistoryFilter_spec.lua index a4c3621..11c4d9a 100644 --- a/spec/HistoryFilter_spec.lua +++ b/spec/HistoryFilter_spec.lua @@ -116,3 +116,69 @@ describe("MatchesFilter", function() 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) From 5e0f5e75c5fbe92ee302c4fb4c7911fd81780502 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 11:56:36 +0200 Subject: [PATCH 09/12] feat(options): add toggle to show or hide history filter bar (#89) --- DragonLoot/Display/HistoryFrame.lua | 16 ++++++++-------- DragonLoot/Locales/enUS.lua | 2 ++ DragonLoot_Options/Tabs/HistoryTab.lua | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index 315cc0d..05c5049 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -316,15 +316,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, @@ -340,7 +335,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 @@ -1443,6 +1438,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/Locales/enUS.lua b/DragonLoot/Locales/enUS.lua index 0ecb068..7aa85a6 100755 --- a/DragonLoot/Locales/enUS.lua +++ b/DragonLoot/Locales/enUS.lua @@ -273,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"], From 6fd49d316de128d0ba0e7450fe7e5ca95005aca0 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 12:09:03 +0200 Subject: [PATCH 10/12] docs(agents): document encounter filter and schema v5 (#89) --- AGENTS.md | 54 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 24 deletions(-) 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 From f44a88f144ad487fe6baf54f5acd694a0041f22e Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 18:27:51 +0200 Subject: [PATCH 11/12] fix(history): address PR review feedback for encounter filter (#89) - RestoreFilter: type-guard persisted encounterID (number) and search (string) before assignment so a corrupted SavedVariable cannot poison string.lower at filter time. - HistoryFrame: drop named globals on the two #89-introduced frames (encounter dropdown, search box); pre-existing scroll/scrollbar/container names retained. - EncounterListener_Classic: refactor to Initialize/Shutdown lifecycle matching sister listeners; wire into Init.lua MODULES (Retail-safe via nil-guard in loop and file-level WOW_PROJECT_MAINLINE early return). - spec/wow_mock: GetItemInfo(nil) returns nil to mirror real API; SetInstanceInfo guards non-table input. --- DragonLoot/Core/Init.lua | 1 + DragonLoot/Display/HistoryFrame.lua | 19 ++++++++++++++----- .../Listeners/EncounterListener_Classic.lua | 19 +++++++++++++++++-- spec/EncounterListener_Classic_spec.lua | 4 ++++ spec/wow_mock.lua | 8 +++++++- 5 files changed, 43 insertions(+), 8 deletions(-) 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 05c5049..8806c59 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -189,8 +189,18 @@ local function RestoreFilter() return end local persisted = db.profile.history.filter - filterState.encounterID = persisted.encounterID - filterState.search = persisted.search or "" + + 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. @@ -1167,8 +1177,7 @@ local function CreateFilterBar(parent) -- Encounter dropdown (legacy UIDropDownMenu API). -- Parented to the main container so popup anchor math works correctly. - local encounterDropdown = - CreateFrame("Frame", "DragonLootHistoryEncounterDropdown", parent, "UIDropDownMenuTemplate") + local encounterDropdown = CreateFrame("Frame", nil, parent, "UIDropDownMenuTemplate") encounterDropdown:SetPoint("LEFT", bar, "LEFT", 4, 0) encounterDropdown.displayMode = "MENU" UIDropDownMenu_Initialize(encounterDropdown, InitEncounterDropdown) @@ -1176,7 +1185,7 @@ local function CreateFilterBar(parent) UIDropDownMenu_SetText(encounterDropdown, L["All Encounters"]) -- Search box (SearchBoxTemplate gives clear-X + placeholder for free). - local searchBox = CreateFrame("EditBox", "DragonLootHistorySearchBox", bar, "SearchBoxTemplate") + local searchBox = CreateFrame("EditBox", nil, bar, "SearchBoxTemplate") searchBox:SetSize(150, 20) searchBox:SetPoint("LEFT", encounterDropdown, "RIGHT", 6, 2) searchBox:SetAutoFocus(false) diff --git a/DragonLoot/Listeners/EncounterListener_Classic.lua b/DragonLoot/Listeners/EncounterListener_Classic.lua index 9e19350..95fd409 100644 --- a/DragonLoot/Listeners/EncounterListener_Classic.lua +++ b/DragonLoot/Listeners/EncounterListener_Classic.lua @@ -37,7 +37,6 @@ local GetInstanceInfo = GetInstanceInfo ------------------------------------------------------------------------------- local frame = CreateFrame("Frame") -frame:RegisterEvent("ENCOUNTER_START") frame:SetScript("OnEvent", function(_, event, encounterID, encounterName, difficultyID, groupSize) if event ~= "ENCOUNTER_START" then return @@ -53,5 +52,21 @@ frame:SetScript("OnEvent", function(_, event, encounterID, encounterName, diffic } 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 } +ns.EncounterListener_Classic = { + _frame = frame, + Initialize = Initialize, + Shutdown = Shutdown, +} diff --git a/spec/EncounterListener_Classic_spec.lua b/spec/EncounterListener_Classic_spec.lua index ab3b836..a921296 100644 --- a/spec/EncounterListener_Classic_spec.lua +++ b/spec/EncounterListener_Classic_spec.lua @@ -61,6 +61,7 @@ describe("EncounterListener_Classic", function() mock.SetTime(123) mock.LoadFile(ns, "DragonLoot/Listeners/EncounterListener_Classic.lua") + ns.EncounterListener_Classic.Initialize(MakeAddonStub()) assert.is_nil(ns.currentEncounter) @@ -86,6 +87,7 @@ describe("EncounterListener_Classic", function() 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") @@ -109,6 +111,7 @@ describe("EncounterListener_Classic", function() 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. @@ -135,6 +138,7 @@ describe("EncounterListener_Classic", function() 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") diff --git a/spec/wow_mock.lua b/spec/wow_mock.lua index eb94a5d..ac5e3a7 100644 --- a/spec/wow_mock.lua +++ b/spec/wow_mock.lua @@ -34,7 +34,10 @@ function time() end function GetItemInfo(link) - return link or "Item", link, 4 + if link == nil then + return nil + end + return link, link, 4 end function InCombatLockdown() @@ -72,6 +75,9 @@ function M.SetInstance(id) end function M.SetInstanceInfo(info) + if type(info) ~= "table" then + return + end for k, v in pairs(info) do M._instanceInfo[k] = v end From 16cafd3f670eb744e086015b09f01cd7937ca2b5 Mon Sep 17 00:00:00 2001 From: Lasse Nielsen Date: Tue, 26 May 2026 18:31:44 +0200 Subject: [PATCH 12/12] fix(history): restore dropdown frame name to satisfy UIDropDownMenu API (#89) UIDropDownMenu_RefreshDropDownSize requires self:GetName() for child icon lookup (envTable[self:GetName().."Button"..i.."Icon"]) with no member fallback. It runs on the next OnUpdate tick after UIDropDownMenu_Initialize sets shouldRefresh = true, so an anonymous dropdown throws 'attempt to concatenate a nil value' on every flavor (Retail, MoP Classic, TBC Anniversary). Search box (SearchBoxTemplate) is unaffected and stays anonymous. --- DragonLoot/Display/HistoryFrame.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DragonLoot/Display/HistoryFrame.lua b/DragonLoot/Display/HistoryFrame.lua index 8806c59..e11cc4f 100644 --- a/DragonLoot/Display/HistoryFrame.lua +++ b/DragonLoot/Display/HistoryFrame.lua @@ -1177,7 +1177,8 @@ local function CreateFilterBar(parent) -- Encounter dropdown (legacy UIDropDownMenu API). -- Parented to the main container so popup anchor math works correctly. - local encounterDropdown = CreateFrame("Frame", nil, parent, "UIDropDownMenuTemplate") + local encounterDropdown = + CreateFrame("Frame", "DragonLootHistoryEncounterDropdown", parent, "UIDropDownMenuTemplate") encounterDropdown:SetPoint("LEFT", bar, "LEFT", 4, 0) encounterDropdown.displayMode = "MENU" UIDropDownMenu_Initialize(encounterDropdown, InitEncounterDropdown)