From d3230f41c79b409bf2906eba3d4763727eaa19e3 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 17 Apr 2026 00:52:06 +0200 Subject: [PATCH 1/2] feat(trade): filter by attribute requirements in Trader pane Adds an "Include unusable" checkbox (off by default) to the Trader pane that hides search results whose Str/Dex/Int (or Omni) requirements the build cannot meet once equipped. Filtering is applied across all sort modes and cached per result to avoid redundant calcFunc calls. When filtering drops every result, the dropdown and total-price state are cleared and a dedicated notice is shown. Adds a matching "Attributes Requirements" checkbox (on by default) to the TradeQueryGenerator popup. When enabled, the shortfall (build requirement minus build attribute) is inserted as pseudo.pseudo_total_* min filters in the generated query so trade search only returns items that cover the missing attributes. Also hardens UI state transitions around filtered results: the result dropdown selection callback guards against stale indices, the section anchor preserves its base Y when the scrollbar offsets it, and empty sorted results no longer crash UpdateDropdownList / UpdateControlsWithItems. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Classes/TradeQuery.lua | 180 +++++++++++++++++++++++----- src/Classes/TradeQueryGenerator.lua | 39 +++++- 2 files changed, 190 insertions(+), 29 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 9e1308bfb9..7c24f17028 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -34,6 +34,7 @@ local TradeQueryClass = newClass("TradeQuery", function(self, itemsTab) -- default set of trade item sort selection self.slotTables = { } self.pbItemSortSelectionIndex = 1 + self.hideResultsFailingAttributeRequirements = false self.pbCurrencyConversion = { } self.currencyConversionTradeMap = { } self.lastCurrencyConversionRequest = 0 @@ -368,6 +369,20 @@ Highest Weight - Displays the order retrieved from trade]] self.controls.itemSortSelection:SetSel(self.pbItemSortSelectionIndex, true) self.controls.itemSortSelectionLabel = new("LabelControl", {"TOPRIGHT", self.controls.itemSortSelection, "TOPLEFT"}, {-4, 0, 56, 16}, "^7Sort By:") + -- Hide fetched results that would leave unmet attribute requirements unless unchecked. + local hideAttributeRequirementsLabel = "^7Hide results failing attribute requirements" + local hideAttributeRequirementsLabelWidth = DrawStringWidth(row_height - 4, "VAR", hideAttributeRequirementsLabel) + 5 + local hideAttributeRequirementsRect = {24 + hideAttributeRequirementsLabelWidth, 0, row_height, row_height} + self.controls.hideAttributeRequirementsCheck = new("CheckBoxControl", {"LEFT", self.controls.tradeTypeSelection, "RIGHT"}, hideAttributeRequirementsRect, hideAttributeRequirementsLabel, function(state) + self.hideResultsFailingAttributeRequirements = state + for row_idx, _ in pairs(self.resultTbl) do + self:UpdateControlsWithItems(row_idx) + end + end) + self.controls.hideAttributeRequirementsCheck.tooltipText = "Hide fetched results when equipping the item would leave unmet Str/Dex/Int/Omniscience attribute requirements.\nUnchecked: show those results after fetching." + self.hideResultsFailingAttributeRequirements = self.hideResultsFailingAttributeRequirements == true + self.controls.hideAttributeRequirementsCheck.state = self.hideResultsFailingAttributeRequirements + -- Realm selection self.controls.realmLabel = new("LabelControl", {"LEFT", self.controls.setSelect, "RIGHT"}, {18, 0, 20, row_height - 4}, "^7Realm:") self.controls.realm = new("DropDownControl", {"LEFT", self.controls.realmLabel, "RIGHT"}, {6, 0, 150, row_height}, self.realmDropList, function(index, value) @@ -464,7 +479,9 @@ Highest Weight - Displays the order retrieved from trade]] t_insert(slotTables, { slotName = self.itemsTab.sockets[nodeId].label, nodeId = nodeId }) end - self.controls.sectionAnchor = new("LabelControl", {"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, row_vertical_padding + row_height, 0, 0}, "") + -- Base Y offset for sectionAnchor (used to preserve position when scrollbar shifts it) + local sectionAnchorBaseY = row_vertical_padding + row_height + self.controls.sectionAnchor = new("LabelControl", {"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, sectionAnchorBaseY, 0, 0}, "") top_pane_alignment_ref = {"TOPLEFT", self.controls.sectionAnchor, "TOPLEFT"} local scrollBarShown = #slotTables > 21 -- clipping starts beyond this -- dynamically hide rows that are above or below the scrollBar @@ -542,7 +559,7 @@ Highest Weight - Displays the order retrieved from trade]] local function scrollBarFunc() self.controls.scrollBar.height = self.pane_height-100 self.controls.scrollBar:SetContentDimension(self.pane_height-100, self.effective_rows_height) - self.controls.sectionAnchor.y = -self.controls.scrollBar.offset + self.controls.sectionAnchor.y = sectionAnchorBaseY - self.controls.scrollBar.offset end main:OpenPopup(pane_width, self.pane_height, "Trader", self.controls, nil, nil, "close", (scrollBarShown and scrollBarFunc or nil)) end @@ -626,7 +643,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) for row_idx in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) end - end) + end) controls.cancel = new("ButtonControl", { "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() if previousSelectionList and #previousSelectionList > 0 then self.statSortSelectionList = copyTable(previousSelectionList, true) @@ -719,6 +736,25 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end +function TradeQueryClass:GetReplacementSlotName(row_idx) + local slotTbl = self.slotTables[row_idx] + if not slotTbl then + return nil + end + if slotTbl.nodeId then + return "Jewel " .. tostring(slotTbl.nodeId) + end + if slotTbl.replacementSlotName then + return slotTbl.replacementSlotName + end + if slotTbl.fullName then + return slotTbl.fullName + end + if self.itemsTab.slots and self.itemsTab.slots[slotTbl.slotName] then + return slotTbl.slotName + end +end + -- Method to evaluate a result by getting it's output and weight function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, baseOutput) local result = self.resultTbl[row_idx][result_index] @@ -738,7 +774,7 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.onlyWeightedBaseOutput[row_idx][result_index] = onlyWeightedBaseOutput self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end - local slotName = self.slotTables[row_idx].nodeId and "Jewel " .. tostring(self.slotTables[row_idx].nodeId) or self.slotTables[row_idx].slotName + local slotName = self:GetReplacementSlotName(row_idx) or self.slotTables[row_idx].slotName if slotName == "Megalomaniac" then local addedNodes = {} for nodeName in (result.item_string.."\r\n"):gmatch("1 Added Passive Skill is (.-)\r?\n") do @@ -776,18 +812,34 @@ function TradeQueryClass:UpdateDropdownList(row_idx) if not self.resultTbl[row_idx] then return end - for result_index = 1, #self.resultTbl[row_idx] do - - local pb_index = self.sortedResultTbl[row_idx][result_index].index - local result = self.resultTbl[row_idx][pb_index] - local price = string.format(" %s(%d %s)", colorCodes["CURRENCY"], result.amount, result.currency) - local item = new("Item", result.item_string) - table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) + -- Iterate the sorted (and potentially filtered) list so attribute-filtered rows are omitted from the dropdown + for _, sorted in ipairs(self.sortedResultTbl[row_idx] or {}) do + if sorted and sorted.index and self.resultTbl[row_idx][sorted.index] then + local result = self.resultTbl[row_idx][sorted.index] + local price = string.format(" %s(%d %s)", colorCodes["CURRENCY"], result.amount, result.currency) + local item = new("Item", result.item_string) + table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) + end + end + if self.controls["resultDropdown".. row_idx] then + self.controls["resultDropdown".. row_idx].selIndex = 1 + self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end - self.controls["resultDropdown".. row_idx].selIndex = 1 - self.controls["resultDropdown".. row_idx]:SetList(dropdownLabels) end function TradeQueryClass:UpdateControlsWithItems(row_idx) + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + self.sortedResultTbl[row_idx] = {} + if self.controls["resultDropdown".. row_idx] then + self.controls["resultDropdown".. row_idx]:SetList({}) + self.controls["resultDropdown".. row_idx].selIndex = 1 + end + self.itemIndexTbl[row_idx] = nil + self.totalPrice[row_idx] = nil + self.controls.fullPrice.label = "Total Price: " .. self:GetTotalPriceString() + return + end + local sortMode = self.itemSortSelectionList[self.pbItemSortSelectionIndex] local sortedItems, errMsg = self:SortFetchResults(row_idx, sortMode) if errMsg == "MissingConversionRates" then @@ -800,6 +852,18 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) else self:SetNotice(self.controls.pbNotice, "") end + if not sortedItems or #sortedItems == 0 then + self:SetNotice(self.controls.pbNotice, "No usable results (attribute requirements)") + self.sortedResultTbl[row_idx] = {} + if self.controls["resultDropdown".. row_idx] then + self.controls["resultDropdown".. row_idx]:SetList({}) + self.controls["resultDropdown".. row_idx].selIndex = 1 + end + self.itemIndexTbl[row_idx] = nil + self.totalPrice[row_idx] = nil + self.controls.fullPrice.label = "Total Price: " .. self:GetTotalPriceString() + return + end self.sortedResultTbl[row_idx] = sortedItems local pb_index = self.sortedResultTbl[row_idx][1].index @@ -827,6 +891,39 @@ end -- Method to sort the fetched results function TradeQueryClass:SortFetchResults(row_idx, mode) local calcFunc, baseOutput + local attrReqCache = {} + local slotName = self:GetReplacementSlotName(row_idx) + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + return {} + end + + -- Returns true if the candidate item meets its attribute requirements when equipped + local function meetsAttributeRequirements(result_index) + if not self.hideResultsFailingAttributeRequirements or not slotName then + return true + end + if attrReqCache[result_index] ~= nil then + return attrReqCache[result_index] + end + if not calcFunc then + calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() + end + local item = new("Item", self.resultTbl[row_idx][result_index].item_string) + local output = calcFunc({ repSlotName = slotName, repItem = item }) + local ok + if output.ReqOmni then + ok = (output.ReqOmni or 0) <= (output.Omni or 0) + else + local function attrOk(reqKey, attrKey) + return (output[reqKey] or 0) <= (output[attrKey] or 0) + end + ok = attrOk("ReqStr", "Str") and attrOk("ReqDex", "Dex") and attrOk("ReqInt", "Int") + end + attrReqCache[result_index] = ok + return ok + end + local function getResultWeight(result_index) if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() @@ -854,13 +951,17 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) local newTbl = {} if mode == self.sortModes.Weight then for index, _ in pairs(self.resultTbl[row_idx]) do - t_insert(newTbl, { outputAttr = index, index = index }) + if meetsAttributeRequirements(index) then + t_insert(newTbl, { outputAttr = index, index = index }) + end end return newTbl elseif mode == self.sortModes.StatValue then for result_index = 1, #self.resultTbl[row_idx] do --ConPrintf("%.3f", getResultWeight(result_index)) - t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.StatValuePrice then @@ -880,9 +981,11 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) -- scaling factor for price local k = 0.03 - t_insert(newTbl, - { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = - result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, + { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = + result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr > b.outputAttr end) elseif mode == self.sortModes.Price then @@ -891,7 +994,9 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) return nil, "MissingConversionRates" end for result_index, price in pairs(priceTable) do - t_insert(newTbl, { outputAttr = price, index = result_index }) + if meetsAttributeRequirements(result_index) then + t_insert(newTbl, { outputAttr = price, index = result_index }) + end end table.sort(newTbl, function(a,b) return a.outputAttr < b.outputAttr end) else @@ -945,6 +1050,7 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro slotTbl.slotName and (self.itemsTab.slots[slotTbl.slotName] or slotTbl.slotName == "Watcher's Eye" and self:findValidSlotForWatchersEye() or slotTbl.fullName and self.itemsTab.slots[slotTbl.fullName]) -- fullName for Abyssal Sockets + slotTbl.replacementSlotName = activeSlot and activeSlot.slotName or slotTbl.fullName or nil local nameColor = slotTbl.unique and colorCodes.UNIQUE or "^7" controls["name"..row_idx] = new("LabelControl", top_pane_alignment_ref, {0, row_idx*(row_height + row_vertical_padding), 100, row_height - 4}, nameColor..slotTbl.slotName) controls["bestButton"..row_idx] = new("ButtonControl", { "LEFT", controls["name"..row_idx], "LEFT"}, {100 + 8, 0, 80, row_height}, "Find best", function() @@ -1076,8 +1182,10 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end) controls["changeButton"..row_idx].shown = function() return self.resultTbl[row_idx] end controls["resultDropdown"..row_idx] = new("DropDownControl", { "TOPLEFT", controls["changeButton"..row_idx], "TOPRIGHT"}, {8, 0, 325, row_height}, {}, function(index) - self.itemIndexTbl[row_idx] = self.sortedResultTbl[row_idx][index].index - self:SetFetchResultReturn(row_idx, self.itemIndexTbl[row_idx]) + if self.sortedResultTbl[row_idx] and self.sortedResultTbl[row_idx][index] then + self.itemIndexTbl[row_idx] = self.sortedResultTbl[row_idx][index].index + self:SetFetchResultReturn(row_idx, self.itemIndexTbl[row_idx]) + end end) self:UpdateDropdownList(row_idx) local function addMegalomaniacCompareToTooltipIfApplicable(tooltip, result_index) @@ -1117,8 +1225,17 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro tooltip:AddSeparator(10) tooltip:AddLine(16, string.format("^7Price: %s %s", result.amount, result.currency)) end + local function getSelectedResult() + local selected_result_index = self.itemIndexTbl[row_idx] + local rowResults = self.resultTbl[row_idx] + return selected_result_index and rowResults and rowResults[selected_result_index], selected_result_index + end controls["importButton"..row_idx] = new("ButtonControl", { "TOPLEFT", controls["resultDropdown"..row_idx], "TOPRIGHT"}, {8, 0, 100, row_height}, "Import Item", function() - self.itemsTab:CreateDisplayItemFromRaw(self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string) + local itemResult = getSelectedResult() + if not itemResult or not itemResult.item_string then + return + end + self.itemsTab:CreateDisplayItemFromRaw(itemResult.item_string) local item = self.itemsTab.displayItem -- pass "true" to not auto equip it as we will have our own logic self.itemsTab:AddDisplayItem(true) @@ -1133,8 +1250,8 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end) controls["importButton"..row_idx].tooltipFunc = function(tooltip) tooltip:Clear() - local selected_result_index = self.itemIndexTbl[row_idx] - local item_string = self.resultTbl[row_idx][selected_result_index].item_string + local itemResult, selected_result_index = getSelectedResult() + local item_string = itemResult and itemResult.item_string if selected_result_index and item_string then -- TODO: item parsing bug caught here. -- item.baseName is nil and throws error in the following AddItemTooltip func @@ -1150,12 +1267,13 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end end controls["importButton"..row_idx].enabled = function() - return self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]].item_string ~= nil + local itemResult = getSelectedResult() + return itemResult and itemResult.item_string ~= nil end -- Whisper so we can copy to clipboard controls["whisperButton" .. row_idx] = new("ButtonControl", { "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 170, row_height }, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + local itemResult = getSelectedResult() if not itemResult then return "" end @@ -1169,7 +1287,10 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro end end, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + local itemResult = getSelectedResult() + if not itemResult then + return + end if itemResult.whisper then Copy(itemResult.whisper) else @@ -1199,7 +1320,10 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro controls["whisperButton" .. row_idx].tooltipFunc = function(tooltip) tooltip:Clear() tooltip.center = true - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + local itemResult = getSelectedResult() + if not itemResult then + return + end local text = itemResult.whisper and "Copies the item purchase whisper to the clipboard" or "Opens the search page to show the item" tooltip:AddLine(16, text) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index eeb2fdeaab..7be583a5b1 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -799,6 +799,16 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) -- Calculate base output with a blank item local calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() local baseItemOutput = slot and calcFunc({ repSlotName = slot.slotName, repItem = testItem }) or baseOutput + -- Determine attribute shortfall when replacing the current item with a blank base + local attrReqShortfall = { Str = 0, Dex = 0, Int = 0 } + if slot and (not slot.slotName:find("Flask")) then + local needStr = math.max(0, (baseItemOutput.ReqStr or 0) - (baseItemOutput.Str or 0)) + local needDex = math.max(0, (baseItemOutput.ReqDex or 0) - (baseItemOutput.Dex or 0)) + local needInt = math.max(0, (baseItemOutput.ReqInt or 0) - (baseItemOutput.Int or 0)) + attrReqShortfall.Str = needStr + attrReqShortfall.Dex = needDex + attrReqShortfall.Int = needInt + end -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -816,6 +826,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) calcFunc = calcFunc, options = options, slot = slot, + attrReqShortfall = attrReqShortfall, } -- OnFrame will pick this up and begin the work @@ -1028,6 +1039,23 @@ function TradeQueryGeneratorClass:FinishQuery() filters = filters + 1 end + -- If enabled, require the new item to provide enough attributes to meet build requirements + if options.includeAttrReqs and self.calcContext and self.calcContext.attrReqShortfall then + local need = self.calcContext.attrReqShortfall + if need.Str and need.Str > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) + filters = filters + 1 + end + if need.Dex and need.Dex > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_dexterity", value = { min = need.Dex } }) + filters = filters + 1 + end + if need.Int and need.Int > 0 then + t_insert(andFilters.filters, { id = "pseudo.pseudo_total_intelligence", value = { min = need.Int } }) + filters = filters + 1 + end + end + if #andFilters.filters > 0 then t_insert(queryTable.query.stats, andFilters) end @@ -1261,6 +1289,12 @@ Remove: %s will be removed from the search results.]], term, term, term) controls.maxLevelLabel = new("LabelControl", {"RIGHT",controls.maxLevel,"LEFT"}, {-5, 0, 0, 16}, "Max Level:") updateLastAnchor(controls.maxLevel) + -- When enabled, the generated query asks for enough attributes on the new item + controls.includeAttrReqs = new("CheckBoxControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Attribute requirements:", function(state) end) + controls.includeAttrReqs.state = (self.lastIncludeAttrReqs == nil or self.lastIncludeAttrReqs == true) + controls.includeAttrReqs.tooltipText = "Add Str/Dex/Int pseudo filters when the current build is short on attributes.\nThis narrows the generated trade query before fetching results." + updateLastAnchor(controls.includeAttrReqs) + -- basic filtering by slot for sockets and links, Megalomaniac does not have slot and Sockets use "Jewel nodeId" if slot and not isJewelSlot and not isAbyssalJewelSlot and not slot.slotName:find("Flask") then controls.sockets = new("EditControl", {"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D") @@ -1356,6 +1390,9 @@ Remove: %s will be removed from the search results.]], term, term, term) options.maxLevel = tonumber(controls.maxLevel.buf) self.lastMaxLevel = options.maxLevel end + if controls.includeAttrReqs then + self.lastIncludeAttrReqs, options.includeAttrReqs = controls.includeAttrReqs.state, controls.includeAttrReqs.state + end if controls.sockets and controls.sockets.buf then options.sockets = tonumber(controls.sockets.buf) self.lastSockets = options.sockets @@ -1374,4 +1411,4 @@ Remove: %s will be removed from the search results.]], term, term, term) main:ClosePopup() end) main:OpenPopup(400, popupHeight, "Query Options", controls) -end \ No newline at end of file +end From 587c8d21f83bf4a8037feb9b0d32c637bf0bcbfd Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Apr 2026 21:26:35 +0200 Subject: [PATCH 2/2] test(trade): cover attribute requirement trade filters --- spec/System/TestTradeQueryGenerator_spec.lua | 73 +++++++++++++++++ spec/System/TestTradeQuery_spec.lua | 85 ++++++++++++++++++++ 2 files changed, 158 insertions(+) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index befb96a657..8999d72607 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -1,6 +1,58 @@ +local dkjson = require "dkjson" + describe("TradeQueryGenerator", function() local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + local function findStatFilter(queryTable, id) + for _, group in ipairs(queryTable.query.stats) do + for _, filter in ipairs(group.filters or {}) do + if filter.id == id then + return filter + end + end + end + end + + local function finishQueryWithAttributeShortfall(shortfall, includeAttrReqs) + local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + local queryTable + local errMsg + queryGen.modWeights = { + { tradeModId = "explicit.stat_3299347043", weight = 1, meanStatDiff = 1 }, + } + queryGen.tradeTypeIndex = 1 + queryGen.requesterContext = {} + queryGen.requesterCallback = function(_, queryJson, queryErrMsg) + queryTable = dkjson.decode(queryJson) + errMsg = queryErrMsg + end + queryGen.calcContext = { + itemCategoryQueryStr = "ring", + special = {}, + testItem = { + BuildAndParseRaw = function() end, + }, + baseOutput = { TotalDPS = 100 }, + baseStatValue = 0, + options = { + statWeights = { { stat = "TotalDPS", weightMult = 1 } }, + includeAllWEMods = false, + includeAttrReqs = includeAttrReqs, + includeMirrored = true, + influence1 = 1, + influence2 = 1, + }, + attrReqShortfall = shortfall, + } + + local previousClosePopup = main.ClosePopup + main.ClosePopup = function() end + queryGen:FinishQuery() + main.ClosePopup = previousClosePopup + + return queryTable, errMsg + end + describe("ProcessMod", function() -- Pass: Mod line maps correctly to trade stat entry without error -- Fail: Mapping fails (e.g., no match found), indicating incomplete stat parsing for curse mods, potentially missing curse-enabling items in queries @@ -57,4 +109,25 @@ describe("TradeQueryGenerator", function() _G.MAX_FILTERS = orig_max end) end) + + describe("attribute requirement filters", function() + it("adds needed attribute pseudo filters to the generated query", function() + local queryTable, errMsg = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, true) + assert.is_nil(errMsg) + assert.are.equal(12, findStatFilter(queryTable, "pseudo.pseudo_total_strength").value.min) + assert.are.equal(34, findStatFilter(queryTable, "pseudo.pseudo_total_dexterity").value.min) + assert.are.equal(56, findStatFilter(queryTable, "pseudo.pseudo_total_intelligence").value.min) + end) + + it("omits attribute pseudo filters when disabled or no shortfall exists", function() + local disabledQuery = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, false) + local zeroQuery = finishQueryWithAttributeShortfall({ Str = 0, Dex = 0, Int = 0 }, true) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_strength")) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_dexterity")) + assert.is_nil(findStatFilter(disabledQuery, "pseudo.pseudo_total_intelligence")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_strength")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_dexterity")) + assert.is_nil(findStatFilter(zeroQuery, "pseudo.pseudo_total_intelligence")) + end) + end) end) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 332374a839..053143bc83 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -53,5 +53,90 @@ describe("TradeQuery", function() end) assert.are.equal(0, #tooltip.lines) end) + + it("returns early from action button tooltips when filtering clears the selected result", function() + local tq = newTradeQuery({ + resultTbl = { [1] = { [1] = { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" } } }, + sortedResultTbl = { [1] = {} }, + }) + buildRow1Dropdown(tq) + local tooltip = new("Tooltip") + + assert.has_no.errors(function() + tq.controls.importButton1.tooltipFunc(tooltip) + tq.controls.whisperButton1.tooltipFunc(tooltip) + end) + assert.are.equal(0, #tooltip.lines) + end) + end) + + describe("attribute requirement result filtering", function() + local function newTradeQueryWithOutput(output, slotTbl) + local calcCalls = 0 + local tq = new("TradeQuery", { itemsTab = {} }) + tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } + tq.resultTbl = { + [1] = { + [1] = { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" }, + }, + } + tq.sortModes = { + Weight = "(Highest) Weighted Sum", + } + tq.itemsTab.build = { + calcsTab = { + GetMiscCalculator = function() + return function() + calcCalls = calcCalls + 1 + return output + end, {} + end, + }, + } + tq.itemsTab.slots = { + ["Ring 1"] = {}, + } + return tq, function() + return calcCalls + end + end + + it("filters fetched results that do not meet attribute requirements", function() + local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(0, #sortedItems) + end) + + it("keeps fetched results that meet attribute requirements", function() + local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + end) + + it("filters fetched results that do not meet Omniscience requirements", function() + local tq = newTradeQueryWithOutput({ ReqOmni = 100, Omni = 80 }) + tq.hideResultsFailingAttributeRequirements = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(0, #sortedItems) + end) + + it("keeps fetched results without recalculating by default", function() + local tq, calcCalls = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }) + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + assert.are.equal(0, calcCalls()) + end) + + it("does not apply equipment attribute filtering to rows without a replacement slot", function() + local tq, calcCalls = newTradeQueryWithOutput({ ReqStr = 50, Str = 40, ReqDex = 0, Dex = 0, ReqInt = 0, Int = 0 }, { slotName = "Megalomaniac", unique = true }) + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(1, sortedItems[1].index) + assert.are.equal(0, calcCalls()) + end) end) end)