From a7c76a82ac77e32ac6ace510bc7e2b87e9209226 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 17 Apr 2026 00:52:06 +0200 Subject: [PATCH 01/14] 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 | 208 ++++++++++++++++++++++------ src/Classes/TradeQueryGenerator.lua | 41 +++++- 2 files changed, 206 insertions(+), 43 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 1d0efbbeb8..54a02ca08a 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -41,6 +41,7 @@ function TradeQueryClass:TradeQuery(itemsTab) ---@type TradeQuerySlotTable[] self.slotTables = { } self.pbItemSortSelectionIndex = 1 + self.hideResultsFailingAttributeRequirements = false -- for each realm and league, a table of values of each currency in div --- @type table>> self.pbCurrencyConversion = {} @@ -460,6 +461,20 @@ Highest Weight - Displays the order retrieved from trade]] self.controls.itemSortSelection:SetSel(self.pbItemSortSelectionIndex, true) self.controls.itemSortSelectionLabel = new("LabelControl"):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"):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"):LabelControl({"LEFT", self.controls.setSelect, "RIGHT"}, {18, 0, 20, row_height - 4}, "^7Realm:") self.controls.realm = new("DropDownControl"):DropDownControl({"LEFT", self.controls.realmLabel, "RIGHT"}, {6, 0, 150, row_height}, self.realmDropList, function(index, value) @@ -571,7 +586,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"):LabelControl({"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, row_vertical_padding, 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"):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 @@ -656,7 +673,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 local function onRateLimit(backoff) @@ -772,7 +789,7 @@ function TradeQueryClass:SetStatWeights(previousSelectionList) for row_idx in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) end - end) + end) controls.cancel = new("ButtonControl"):ButtonControl({ "BOTTOM", nil, "BOTTOM" }, { 0, -10, 80, 20 }, "Cancel", function() if previousSelectionList and #previousSelectionList > 0 then self.statSortSelectionList = copyTable(previousSelectionList, true) @@ -818,6 +835,26 @@ 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 + local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId + if jewelNodeId then + return "Jewel " .. tostring(jewelNodeId) + 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] @@ -838,8 +875,17 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end local slotTbl = self.slotTables[row_idx] - local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId - if slotTbl.slotName == "Megalomaniac" then + if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then + for index = 1, 3 do + local ringSlot = self.itemsTab.slots["Ring " .. index] + if ringSlot and ringSlot.shown() then + slotTbl.selectedSlotName = ringSlot.slotName + break + end + end + end + local slotName = self:GetReplacementSlotName(row_idx) or slotTbl.selectedSlotName or slotTbl.slotName + if slotName == "Megalomaniac" then local addedNodes = {} for nodeName in (result.item_string.."\r\n"):gmatch("1 Added Passive Skill is (.-)\r?\n") do t_insert(addedNodes, self.itemsTab.build.spec.tree.clusterNodeMap[nodeName]) @@ -861,16 +907,6 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba } table.sort(result.evaluation, function(a, b) return a.weight > b.weight end) else - if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then - for index = 1, 3 do - local ringSlot = self.itemsTab.slots["Ring " .. index] - if ringSlot and ringSlot.shown() then - slotTbl.selectedSlotName = ringSlot.slotName - break - end - end - end - local slotName = jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.selectedSlotName or slotTbl.slotName local item = new("Item"):Item(result.item_string) local output = self:ReduceOutput(calcFunc({ repSlotName = slotName, repItem = item })) @@ -886,16 +922,19 @@ 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"):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"):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:ResetResultRow(rowIdx) self.itemIndexTbl[rowIdx] = nil @@ -906,6 +945,19 @@ function TradeQueryClass:ResetResultRow(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() 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 @@ -917,6 +969,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 if not sortedItems[1] then @@ -949,6 +1013,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"):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() @@ -977,12 +1074,16 @@ 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 - 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 @@ -1002,9 +1103,11 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) -- scaling factor for price local k = 0.1 - 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 @@ -1013,7 +1116,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 @@ -1044,12 +1149,14 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro local nodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId local activeSlot = nodeId and self.itemsTab.sockets[nodeId] or slotTbl.slotName and (self.itemsTab.slots[slotTbl.slotName] or + slotTbl.slotName == "Watcher's Eye" and self:findValidSlotForWatchersEye() or -- fullName for Abyssal Sockets slotTbl.fullName and self.itemsTab.slots[slotTbl.fullName]) local function getSelectedSlot() local selectedNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId return selectedNodeId and self.itemsTab.sockets[selectedNodeId] or activeSlot end + 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"):LabelControl(top_pane_alignment_ref, { 0, row_idx * (row_height + row_vertical_padding), 135, row_height - 4 }, nameColor .. slotTbl.slotName) controls["bestButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "LEFT", controls["name" .. row_idx], "LEFT" }, { 135 + 8, 0, 80, row_height }, "Find best", function() @@ -1218,8 +1325,10 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end) controls["changeButton"..row_idx].shown = function() return self.resultTbl[row_idx] end controls["resultDropdown" .. row_idx] = new("DropDownControl"):DropDownControl({ "TOPLEFT", controls["changeButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 351, 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) @@ -1254,8 +1363,17 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite 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"):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) @@ -1271,8 +1389,8 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite 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 local item = new("Item"):Item(item_string) local tooltipSlot = slotTbl.selectedJewelNodeId and self.itemsTab.sockets[slotTbl.selectedJewelNodeId] or activeSlot @@ -1281,11 +1399,13 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite 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"):ButtonControl({ "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] + controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl( + { "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() + local itemResult = getSelectedResult() if not itemResult then return "" end @@ -1301,8 +1421,11 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite end end, function() - local itemResult = self.itemIndexTbl[row_idx] and self.resultTbl[row_idx][self.itemIndexTbl[row_idx]] - if itemResult.whisper and (itemResult.priceType ~= "~b/o") then + local itemResult = getSelectedResult() + if not itemResult then + return + end + if itemResult.whisper and (itemResult.priceType ~= "~b/o") then Copy(itemResult.whisper) else local exactQuery = dkjson.decode(self.lastQueries[row_idx]) @@ -1330,7 +1453,10 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite 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 097a2e7f94..6831f53198 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -766,6 +766,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 @@ -784,7 +794,8 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) options = options, slot = slot, requiredMods = options.requiredMods, - blockedMods = options.blockedMods + blockedMods = options.blockedMods, + attrReqShortfall = attrReqShortfall, } -- OnFrame will pick this up and begin the work @@ -1057,6 +1068,24 @@ function TradeQueryGeneratorClass:FinishQuery() queryTable[k] = v end + local options = self.calcContext.options + -- If enabled, require the new item to provide enough attributes to meet build requirements. + if options.includeAttrReqs and self.calcContext.attrReqShortfall then + local need = self.calcContext.attrReqShortfall + if need.Str and need.Str > 0 then + complexityBudget = complexityBudget - 4 + t_insert(andGroup.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) + end + if need.Dex and need.Dex > 0 then + complexityBudget = complexityBudget - 4 + t_insert(andGroup.filters, { id = "pseudo.pseudo_total_dexterity", value = { min = need.Dex } }) + end + if need.Int and need.Int > 0 then + complexityBudget = complexityBudget - 4 + t_insert(andGroup.filters, { id = "pseudo.pseudo_total_intelligence", value = { min = need.Int } }) + end + end + -- and filters specified by the user for _, entry in ipairs(requiredMods) do complexityBudget = complexityBudget - 4 @@ -1066,7 +1095,6 @@ function TradeQueryGeneratorClass:FinishQuery() complexityBudget = complexityBudget - 4 t_insert(notGroup.filters, { id = entry.tradeId, value = { min = entry.value } }) end - local options = self.calcContext.options if not options.includeMirrored then complexityBudget = complexityBudget - 3 queryTable.filters.misc_filters = { @@ -1320,6 +1348,12 @@ Remove: %s will be removed from the search results.]], term, term, term) controls.maxLevelLabel = new("LabelControl"):LabelControl({ "RIGHT", controls.maxLevel, "LEFT" }, { -5, 0, 0, 16 }, "^7Max Level:") updateLastAnchor(controls.maxLevel) + -- When enabled, the generated query asks for enough attributes on the new item + controls.includeAttrReqs = new("CheckBoxControl"):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"):EditControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 70, 18}, nil, nil, "%D") @@ -1403,6 +1437,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 From 89686d7beba4cc55f12787b745599e332858227b Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Tue, 28 Apr 2026 21:26:35 +0200 Subject: [PATCH 02/14] test(trade): cover attribute requirement trade filters --- spec/System/TestTradeQueryGenerator_spec.lua | 73 +++++++++++++++++ spec/System/TestTradeQuery_spec.lua | 86 ++++++++++++++++++++ 2 files changed, 159 insertions(+) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 4f3c035b5e..dbc9fcd80e 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"):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 @@ -215,4 +267,25 @@ describe("TradeQueryGenerator", function() assert.is_nil(query.filters.socket_filters.filters.sockets) 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 9546edac43..908c86fad7 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -60,6 +60,92 @@ 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 }) + tq.hideResultsFailingAttributeRequirements = 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) describe("trade search links", function() local dkjson = require "dkjson" From 91de57ff0be94ee2b200f97543d13ac04030ea61 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 15 Aug 2026 17:11:31 +0200 Subject: [PATCH 03/14] Adapt trade requirement tests to current class syntax --- spec/System/TestTradeQueryGenerator_spec.lua | 2 +- spec/System/TestTradeQuery_spec.lua | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index dbc9fcd80e..6b1d023e50 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -14,7 +14,7 @@ describe("TradeQueryGenerator", function() end local function finishQueryWithAttributeShortfall(shortfall, includeAttrReqs) - local queryGen = new("TradeQueryGenerator", { itemsTab = {} }) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) local queryTable local errMsg queryGen.modWeights = { diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 908c86fad7..1efa72dc00 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -67,7 +67,7 @@ describe("TradeQuery", function() sortedResultTbl = { [1] = {} }, }) buildRow1Dropdown(tq) - local tooltip = new("Tooltip") + local tooltip = new("Tooltip"):Tooltip() assert.has_no.errors(function() tq.controls.importButton1.tooltipFunc(tooltip) @@ -80,7 +80,7 @@ describe("TradeQuery", function() describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 - local tq = new("TradeQuery", { itemsTab = {} }) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { [1] = { From f992ece3f89089d0e390d853ca44ca888b114a99 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 22 Aug 2026 12:50:45 +0200 Subject: [PATCH 04/14] Fix and simplify trade attribute requirement filtering Remove the obsolete Watcher's Eye fallback, resolve replacement slots from live item state, and share requirement semantics across query generation and result validation. --- spec/System/TestTradeQueryGenerator_spec.lua | 61 +++++++++- spec/System/TestTradeQuery_spec.lua | 46 +++++++- src/Classes/TradeHelpers.lua | 27 ++++- src/Classes/TradeQuery.lua | 110 ++++++++----------- src/Classes/TradeQueryGenerator.lua | 30 +++-- 5 files changed, 181 insertions(+), 93 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 6b1d023e50..9afe908158 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -13,7 +13,7 @@ describe("TradeQueryGenerator", function() end end - local function finishQueryWithAttributeShortfall(shortfall, includeAttrReqs) + local function finishQueryWithAttributeShortfall(shortfall, includeAttributeRequirementFilters) local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) local queryTable local errMsg @@ -27,7 +27,7 @@ describe("TradeQueryGenerator", function() errMsg = queryErrMsg end queryGen.calcContext = { - itemCategoryQueryStr = "ring", + itemCategoryQueryStr = "accessory.ring", special = {}, testItem = { BuildAndParseRaw = function() end, @@ -37,22 +37,58 @@ describe("TradeQueryGenerator", function() options = { statWeights = { { stat = "TotalDPS", weightMult = 1 } }, includeAllWEMods = false, - includeAttrReqs = includeAttrReqs, + includeAttributeRequirementFilters = includeAttributeRequirementFilters, includeMirrored = true, influence1 = 1, influence2 = 1, }, - attrReqShortfall = shortfall, + attributeRequirementShortfall = shortfall, } local previousClosePopup = main.ClosePopup main.ClosePopup = function() end - queryGen:FinishQuery() + local ok, finishError = pcall(function() + queryGen:FinishQuery() + end) main.ClosePopup = previousClosePopup + assert.is_true(ok, finishError) return queryTable, errMsg end + local function startQueryWithReplacementOutput(replacementOutput) + local calcArgs + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ + itemsTab = { + items = { + [1] = { baseName = "Gold Ring", type = "Ring", base = { type = "Ring" } }, + }, + build = { + calcsTab = { + GetMiscCalculator = function() + return function(args) + calcArgs = args + return replacementOutput + end, { TotalDPS = 100 } + end, + }, + }, + }, + }) + local previousOpenPopup = main.OpenPopup + main.OpenPopup = function() return {} end + local ok, startError = pcall(function() + queryGen:StartQuery({ slotName = "Ring 1", selItemId = 1 }, { + influence1 = 1, + influence2 = 1, + statWeights = { { stat = "TotalDPS", weightMult = 1 } }, + }) + end) + main.OpenPopup = previousOpenPopup + assert.is_true(ok, startError) + return queryGen, calcArgs + 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 @@ -269,6 +305,21 @@ describe("TradeQueryGenerator", function() end) describe("attribute requirement filters", function() + it("calculates the shortfall from the blank replacement output", function() + local queryGen, calcArgs = startQueryWithReplacementOutput({ + TotalDPS = 100, + ReqStr = 50, + Str = 40, + ReqDex = 30, + Dex = 35, + ReqInt = 25, + Int = 20, + }) + assert.are.equal("Ring 1", calcArgs.repSlotName) + assert.are.equal("Gold Ring", calcArgs.repItem.baseName) + assert.same({ Str = 10, Dex = 0, Int = 5 }, queryGen.calcContext.attributeRequirementShortfall) + end) + 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) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 1efa72dc00..5ac8679339 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -9,7 +9,7 @@ describe("TradeQuery", function() describe("result dropdown tooltipFunc", function() -- Builds a TradeQuery with the strict minimum needed for -- PriceItemRowDisplay to construct row 1 without exploding. Only the - -- two itemsTab subtables read by the slot lookup at the top of + -- three itemsTab fields read by the slot lookup at the top of -- PriceItemRowDisplay need to be created here; everything else either -- lives behind a callback we never trigger, or is already initialized -- by the TradeQuery constructor. @@ -17,6 +17,7 @@ describe("TradeQuery", function() local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.itemsTab.activeItemSet = {} tq.itemsTab.slots = {} + tq.itemsTab.sockets = {} tq.slotTables[1] = { slotName = "Ring 1" } if state.resultTbl then tq.resultTbl = state.resultTbl end if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end @@ -30,6 +31,15 @@ describe("TradeQuery", function() return tq.controls.resultDropdown1 end + it("constructs the Watcher's Eye row without an active jewel socket", function() + local tq = newTradeQuery({}) + tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true } + + assert.has_no.errors(function() + buildRow1Dropdown(tq) + end) + end) + it("returns early when sortedResultTbl[row_idx] is missing", function() -- No sorted results at all -> first guard must short-circuit. local tq = newTradeQuery({}) @@ -76,10 +86,32 @@ describe("TradeQuery", function() assert.are.equal(0, #tooltip.lines) end) end) + describe("replacement slot resolution", function() + it("resolves normal, Abyssal, and selected jewel slots without stored row state", function() + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tq.itemsTab.slots = { + ["Ring 1"] = {}, + ["Body Armour Abyssal Socket 1"] = {}, + } + tq.itemsTab.sockets = { [123] = {} } + tq.slotTables = { + { slotName = "Ring 1" }, + { slotName = "Abyssal Socket 1", fullName = "Body Armour Abyssal Socket 1" }, + { slotName = "Jewel Socket", selectedJewelNodeId = 123 }, + { slotName = "Watcher's Eye", unique = true }, + } + + assert.are.equal("Ring 1", tq:GetReplacementSlotName(1)) + assert.are.equal("Body Armour Abyssal Socket 1", tq:GetReplacementSlotName(2)) + assert.are.equal("Jewel 123", tq:GetReplacementSlotName(3)) + assert.is_nil(tq:GetReplacementSlotName(4)) + end) + end) describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 + local lastCalcArgs local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { @@ -93,8 +125,9 @@ describe("TradeQuery", function() tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() - return function() + return function(calcArgs) calcCalls = calcCalls + 1 + lastCalcArgs = calcArgs return output end, {} end, @@ -103,16 +136,16 @@ describe("TradeQuery", function() tq.itemsTab.slots = { ["Ring 1"] = {}, } - return tq, function() - return calcCalls - end + return tq, function() return calcCalls end, function() return lastCalcArgs 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 }) + local tq, _, calcArgs = 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) + assert.are.equal("Ring 1", calcArgs().repSlotName) + assert.are.equal("Behemoth Hold, Gold Ring", calcArgs().repItem.name) end) it("keeps fetched results that meet attribute requirements", function() @@ -260,6 +293,7 @@ describe("TradeQuery", function() slotName = "Megalomaniac", unique = true, alreadyCorrupted = true, selectedJewelNodeId = 12345, } local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tq.itemsTab.sockets = { [12345] = {} } tq.statSortSelectionList = {} tq.tradeQueryGenerator = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) tq.itemsTab.build = { diff --git a/src/Classes/TradeHelpers.lua b/src/Classes/TradeHelpers.lua index 9f5580ce70..3f93c35ce0 100644 --- a/src/Classes/TradeHelpers.lua +++ b/src/Classes/TradeHelpers.lua @@ -1,9 +1,10 @@ -- Path of Building -- -- Module: Compare Trade Helpers --- Stateless trade mod lookup/matching and item display helper functions +-- Stateless trade matching, attribute requirement, and item display helpers -- local m_floor = math.floor +local m_max = math.max -- The stat description data is big and is only needed once a trade lookup actually runs, -- so it and its precalculated patterns are built lazily @@ -66,6 +67,30 @@ end local M = {} +local attributeOutputKeys = { "Str", "Dex", "Int" } + +-- Shared by pre-fetch query constraints and post-fetch candidate validation. +-- Omniscience replaces the individual attribute requirements when it is active. +function M.getAttributeRequirementShortfall(output) + if (output.ReqOmni or 0) > 0 then + return { Omni = m_max(0, output.ReqOmni - (output.Omni or 0)) } + end + local shortfall = {} + for _, attributeKey in ipairs(attributeOutputKeys) do + shortfall[attributeKey] = m_max(0, (output["Req" .. attributeKey] or 0) - (output[attributeKey] or 0)) + end + return shortfall +end + +function M.meetsAttributeRequirements(output) + for _, missingAmount in pairs(M.getAttributeRequirementShortfall(output)) do + if missingAmount > 0 then + return false + end + end + return true +end + -- Helper: get rarity color code for an item --- @param item table function M.getRarityColor(item) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 54a02ca08a..3c2770f001 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -461,19 +461,19 @@ Highest Weight - Displays the order retrieved from trade]] self.controls.itemSortSelection:SetSel(self.pbItemSortSelectionIndex, true) self.controls.itemSortSelectionLabel = new("LabelControl"):LabelControl({"TOPRIGHT", self.controls.itemSortSelection, "TOPLEFT"}, {-4, 0, 56, 16}, "^7Sort By:") - -- Hide fetched results that would leave unmet attribute requirements unless unchecked. + -- Optional post-fetch validation complements the query filters with the fully equipped output. 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"):CheckBoxControl({"LEFT", self.controls.tradeTypeSelection, "RIGHT"}, hideAttributeRequirementsRect, hideAttributeRequirementsLabel, function(state) + self.controls.hideResultsFailingAttributeRequirementsCheck = new("CheckBoxControl"):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.controls.hideResultsFailingAttributeRequirementsCheck.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 + self.controls.hideResultsFailingAttributeRequirementsCheck.state = self.hideResultsFailingAttributeRequirements -- Realm selection self.controls.realmLabel = new("LabelControl"):LabelControl({"LEFT", self.controls.setSelect, "RIGHT"}, {18, 0, 20, row_height - 4}, "^7Realm:") @@ -835,24 +835,41 @@ function TradeQueryClass:ReduceOutput(output) return smallOutput end -function TradeQueryClass:GetReplacementSlotName(row_idx) +-- Resolve from current ItemsTab state so result filtering does not depend on row-control construction. +function TradeQueryClass:GetReplacementSlot(row_idx) local slotTbl = self.slotTables[row_idx] if not slotTbl then return nil end local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId if jewelNodeId then - return "Jewel " .. tostring(jewelNodeId) + return self.itemsTab.sockets and self.itemsTab.sockets[jewelNodeId] + end + if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then + for index = 1, 3 do + local ringSlot = self.itemsTab.slots and self.itemsTab.slots["Ring " .. index] + if ringSlot and ringSlot.shown() then + slotTbl.selectedSlotName = ringSlot.slotName + break + end + end end - if slotTbl.replacementSlotName then - return slotTbl.replacementSlotName + if slotTbl.selectedSlotName then + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.selectedSlotName] end if slotTbl.fullName then - return slotTbl.fullName + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.fullName] end - if self.itemsTab.slots and self.itemsTab.slots[slotTbl.slotName] then - return slotTbl.slotName + return self.itemsTab.slots and self.itemsTab.slots[slotTbl.slotName] +end + +function TradeQueryClass:GetReplacementSlotName(row_idx) + local slotTbl = self.slotTables[row_idx] + if not slotTbl or not self:GetReplacementSlot(row_idx) then + return nil end + local jewelNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId + return jewelNodeId and "Jewel " .. tostring(jewelNodeId) or slotTbl.selectedSlotName or slotTbl.fullName or slotTbl.slotName end -- Method to evaluate a result by getting it's output and weight @@ -875,17 +892,8 @@ function TradeQueryClass:GetResultEvaluation(row_idx, result_index, calcFunc, ba self.lastComparedWeightList[row_idx][result_index] = self.statSortSelectionList end local slotTbl = self.slotTables[row_idx] - if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then - for index = 1, 3 do - local ringSlot = self.itemsTab.slots["Ring " .. index] - if ringSlot and ringSlot.shown() then - slotTbl.selectedSlotName = ringSlot.slotName - break - end - end - end - local slotName = self:GetReplacementSlotName(row_idx) or slotTbl.selectedSlotName or slotTbl.slotName - if slotName == "Megalomaniac" then + local slotName = self:GetReplacementSlotName(row_idx) or slotTbl.slotName + if slotTbl.slotName == "Megalomaniac" then local addedNodes = {} for nodeName in (result.item_string.."\r\n"):gmatch("1 Added Passive Skill is (.-)\r?\n") do t_insert(addedNodes, self.itemsTab.build.spec.tree.clusterNodeMap[nodeName]) @@ -945,16 +953,20 @@ function TradeQueryClass:ResetResultRow(rowIdx) self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() end function TradeQueryClass:UpdateControlsWithItems(row_idx) - local results = self.resultTbl[row_idx] - if not results or #results == 0 then + local function clearVisibleResults() self.sortedResultTbl[row_idx] = {} + self.itemIndexTbl[row_idx] = nil + self.totalPrice[row_idx] = nil 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() + self.controls.fullPrice.label = "^7Total Price: " .. self:GetTotalPriceString() + end + + local results = self.resultTbl[row_idx] + if not results or #results == 0 then + clearVisibleResults() return end @@ -970,24 +982,13 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) 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() + clearVisibleResults() + self:SetNotice(self.controls.pbNotice, self.hideResultsFailingAttributeRequirements and + "No usable results (attribute requirements)" or "^4No compatible items found for this slot.") return end self.sortedResultTbl[row_idx] = sortedItems - if not sortedItems[1] then - self:ResetResultRow(row_idx) - self:SetNotice(self.controls.pbNotice, "^4No compatible items found for this slot.") - return - end local pb_index = sortedItems[1].index self.itemIndexTbl[row_idx] = pb_index self.controls["priceButton".. row_idx].tooltipText = "Sorted by " .. self.itemSortSelectionList[self.pbItemSortSelectionIndex] @@ -1013,37 +1014,24 @@ 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 + -- Post-fetch validation recalculates the equipped candidate so Omniscience and + -- other requirement transformations are applied before filtering the result. 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"):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 + return tradeHelpers.meetsAttributeRequirements(output) end local function getResultWeight(result_index) @@ -1146,17 +1134,11 @@ function TradeQueryClass:PriceItemRowDisplay(row_idx, top_pane_alignment_ref, ro local controls = self.controls local slotTbl = self.slotTables[row_idx] local activeSlotRef = slotTbl.nodeId and self.itemsTab.activeItemSet[slotTbl.nodeId] or self.itemsTab.activeItemSet[slotTbl.slotName] - local nodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId - local activeSlot = nodeId and self.itemsTab.sockets[nodeId] or - slotTbl.slotName and (self.itemsTab.slots[slotTbl.slotName] or - slotTbl.slotName == "Watcher's Eye" and self:findValidSlotForWatchersEye() or - -- fullName for Abyssal Sockets - slotTbl.fullName and self.itemsTab.slots[slotTbl.fullName]) + local activeSlot = self:GetReplacementSlot(row_idx) local function getSelectedSlot() local selectedNodeId = slotTbl.nodeId or slotTbl.selectedJewelNodeId return selectedNodeId and self.itemsTab.sockets[selectedNodeId] or activeSlot end - 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"):LabelControl(top_pane_alignment_ref, { 0, row_idx * (row_height + row_vertical_padding), 135, row_height - 4 }, nameColor .. slotTbl.slotName) controls["bestButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "LEFT", controls["name" .. row_idx], "LEFT" }, { 135 + 8, 0, 80, row_height }, "Find best", function() diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 6831f53198..8e47dcece3 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -766,15 +766,10 @@ 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 } + -- Pre-fetch constraint: require the replacement to restore attributes lost with the current item. + local attributeRequirementShortfall = { 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 + attributeRequirementShortfall = tradeHelpers.getAttributeRequirementShortfall(baseItemOutput) end -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -795,7 +790,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) slot = slot, requiredMods = options.requiredMods, blockedMods = options.blockedMods, - attrReqShortfall = attrReqShortfall, + attributeRequirementShortfall = attributeRequirementShortfall, } -- OnFrame will pick this up and begin the work @@ -1070,8 +1065,8 @@ function TradeQueryGeneratorClass:FinishQuery() local options = self.calcContext.options -- If enabled, require the new item to provide enough attributes to meet build requirements. - if options.includeAttrReqs and self.calcContext.attrReqShortfall then - local need = self.calcContext.attrReqShortfall + if options.includeAttributeRequirementFilters and self.calcContext.attributeRequirementShortfall then + local need = self.calcContext.attributeRequirementShortfall if need.Str and need.Str > 0 then complexityBudget = complexityBudget - 4 t_insert(andGroup.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) @@ -1349,10 +1344,10 @@ Remove: %s will be removed from the search results.]], term, term, term) updateLastAnchor(controls.maxLevel) -- When enabled, the generated query asks for enough attributes on the new item - controls.includeAttrReqs = new("CheckBoxControl"):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) + controls.includeAttributeRequirementFilters = new("CheckBoxControl"):CheckBoxControl({"TOPLEFT",lastItemAnchor,"BOTTOMLEFT"}, {0, 5, 18}, "Attribute requirements:", function(state) end) + controls.includeAttributeRequirementFilters.state = self.lastIncludeAttributeRequirementFilters == nil or self.lastIncludeAttributeRequirementFilters == true + controls.includeAttributeRequirementFilters.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.includeAttributeRequirementFilters) -- 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 @@ -1437,8 +1432,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 + if controls.includeAttributeRequirementFilters then + self.lastIncludeAttributeRequirementFilters = controls.includeAttributeRequirementFilters.state + options.includeAttributeRequirementFilters = controls.includeAttributeRequirementFilters.state end if controls.sockets and controls.sockets.buf then options.sockets = tonumber(controls.sockets.buf) From b6a295fb3ba82137db6150d6384876637277a857 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 9 Oct 2026 12:53:19 +0200 Subject: [PATCH 05/14] Test trade requirement filtering across sort modes Check mixed eligible and ineligible results in every sort mode while preserving the fetched result index. Construct the empty filtered row through UpdateControlsWithItems and verify its controls and total price. --- spec/System/TestTradeQuery_spec.lua | 135 +++++++++++++++------------- 1 file changed, 75 insertions(+), 60 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 5ac8679339..4289c6ed35 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -2,35 +2,24 @@ describe("TradeQuery", function() local mock_tradeQuery local mock_queryGen + local function newTradeQuery(state) + local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) + tq.slotTables[1] = { slotName = "Ring 1" } + tq.resultTbl = state.resultTbl or {} + tq.sortedResultTbl = state.sortedResultTbl or {} + return tq + end + + local function buildRow1Dropdown(tq) + tq:PriceItemRowDisplay(1, nil, 0, 20) + return tq.controls.resultDropdown1 + end + before_each(function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) describe("result dropdown tooltipFunc", function() - -- Builds a TradeQuery with the strict minimum needed for - -- PriceItemRowDisplay to construct row 1 without exploding. Only the - -- three itemsTab fields read by the slot lookup at the top of - -- PriceItemRowDisplay need to be created here; everything else either - -- lives behind a callback we never trigger, or is already initialized - -- by the TradeQuery constructor. - local function newTradeQuery(state) - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) - tq.itemsTab.activeItemSet = {} - tq.itemsTab.slots = {} - tq.itemsTab.sockets = {} - tq.slotTables[1] = { slotName = "Ring 1" } - if state.resultTbl then tq.resultTbl = state.resultTbl end - if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end - return tq - end - - -- Builds row 1 of the trader UI and returns the dropdown that owns the - -- tooltipFunc we want to exercise. - local function buildRow1Dropdown(tq) - tq:PriceItemRowDisplay(1, nil, 0, 20) - return tq.controls.resultDropdown1 - end - it("constructs the Watcher's Eye row without an active jewel socket", function() local tq = newTradeQuery({}) tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true } @@ -70,25 +59,10 @@ 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"):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("replacement slot resolution", function() it("resolves normal, Abyssal, and selected jewel slots without stored row state", function() - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + local tq = newTradeQuery({}) tq.itemsTab.slots = { ["Ring 1"] = {}, ["Body Armour Abyssal Socket 1"] = {}, @@ -111,8 +85,7 @@ describe("TradeQuery", function() describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 - local lastCalcArgs - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + local tq = newTradeQuery({}) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { [1] = { @@ -120,40 +93,82 @@ describe("TradeQuery", function() }, } tq.sortModes = { - Weight = "(Highest) Weighted Sum", + Weight = "Weight", StatValue = "StatValue", StatValuePrice = "StatValuePrice", Price = "Price", } + tq.itemSortSelectionList = { tq.sortModes.Weight } + tq.statSortSelectionList = { { stat = "Life", weightMult = 1 } } + tq.tradeQueryGenerator = mock_queryGen + tq.pbLeague = "Test League" + tq.pbCurrencyConversion = { [tq.pbRealm] = { [tq.pbLeague] = { chaos = 1 } } } tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() return function(calcArgs) calcCalls = calcCalls + 1 - lastCalcArgs = calcArgs - return output - end, {} + return type(output) == "function" and output(calcArgs) or output + end, { Life = 100 } end, }, } tq.itemsTab.slots = { ["Ring 1"] = {}, } - return tq, function() return calcCalls end, function() return lastCalcArgs end + return tq, function() return calcCalls end end - it("filters fetched results that do not meet attribute requirements", function() - local tq, _, calcArgs = 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) - assert.are.equal("Ring 1", calcArgs().repSlotName) - assert.are.equal("Behemoth Hold, Gold Ring", calcArgs().repItem.name) - end) + for _, mode in ipairs({ "Weight", "StatValue", "StatValuePrice", "Price" }) do + it("postfilters mixed fetched results in " .. mode .. " mode", function() + local tq = newTradeQueryWithOutput(function(calcArgs) + assert.are.equal("Ring 1", calcArgs.repSlotName) + if calcArgs.repItem.name == "Behemoth Hold, Gold Ring" then + return { ReqStr = 50, Str = 40, Life = 400 } + end + assert.are.equal("Survivor Hold, Gold Ring", calcArgs.repItem.name) + return { ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25, Life = 120 } + end) + -- The rejected result arrives first, costs less, and has more Life. + local survivor = { item_string = "Rarity: RARE\nSurvivor Hold\nGold Ring", amount = 7, currency = "chaos" } + tq.resultTbl[1][2] = survivor + tq.hideResultsFailingAttributeRequirements = true + local sortedItems, err = tq:SortFetchResults(1, tq.sortModes[mode]) + assert.is_nil(err) + assert.are.equal(1, #sortedItems) + assert.are.equal(2, sortedItems[1].index) + assert.are.equal(survivor, tq.resultTbl[1][sortedItems[1].index]) + end) + end + + it("clears the visible selection and price when postfiltering removes every result", function() + local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40 }) + local dropdown = buildRow1Dropdown(tq) + tq.controls.fullPrice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") + tq.controls.pbNotice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") + -- Populate the row through the same path first, so stale state can be detected. + tq:UpdateControlsWithItems(1) + assert.is_not_nil(dropdown:GetSelValue()) + assert.are.equal(1, tq.itemIndexTbl[1]) + assert.are.equal("1 chaos", tq:GetTotalPriceString()) + assert.is_true(tq.controls.importButton1:IsEnabled()) + local populatedPriceLabel = tq.controls.fullPrice.label - 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) + tq:UpdateControlsWithItems(1) + assert.are.equal(0, dropdown:GetDropCount()) + assert.is_nil(dropdown:GetSelValue()) + assert.are.equal(0, #tq.sortedResultTbl[1]) + assert.is_nil(tq.itemIndexTbl[1]) + assert.is_nil(tq.totalPrice[1]) + assert.are.equal("", tq:GetTotalPriceString()) + assert.are_not.equal(populatedPriceLabel, tq.controls.fullPrice.label) + assert.not_matches("chaos", tq.controls.fullPrice.label, 1, true) + assert.matches("attribute requirements", tq.controls.pbNotice.label, 1, true) + assert.is_falsy(tq.controls.importButton1:IsEnabled()) + assert.are.equal("", tq.controls.whisperButton1:GetProperty("label")) + local tooltip = new("Tooltip"):Tooltip() + for _, control in ipairs({ dropdown, tq.controls.importButton1, tq.controls.whisperButton1 }) do + control.tooltipFunc(tooltip, "DROP", 1, nil) + assert.are.equal(0, #tooltip.lines) + end end) it("filters fetched results that do not meet Omniscience requirements", function() From c3771721c474d6f2b35e58b41a7a858cbbc4347f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Fri, 9 Oct 2026 15:04:46 +0200 Subject: [PATCH 06/14] Keep trade-query tooltip fixtures in their original scope Construct the filtering row directly instead of sharing helpers across test groups. Retain the sort and empty-row assertions while keeping the cumulative test diff smaller. --- spec/System/TestTradeQuery_spec.lua | 44 ++++++++++++++++++----------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 4289c6ed35..3d16643021 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -2,24 +2,35 @@ describe("TradeQuery", function() local mock_tradeQuery local mock_queryGen - local function newTradeQuery(state) - local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) - tq.slotTables[1] = { slotName = "Ring 1" } - tq.resultTbl = state.resultTbl or {} - tq.sortedResultTbl = state.sortedResultTbl or {} - return tq - end - - local function buildRow1Dropdown(tq) - tq:PriceItemRowDisplay(1, nil, 0, 20) - return tq.controls.resultDropdown1 - end - before_each(function() mock_tradeQuery = new("TradeQuery"):TradeQuery({ itemsTab = {} }) mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) end) describe("result dropdown tooltipFunc", function() + -- Builds a TradeQuery with the strict minimum needed for + -- PriceItemRowDisplay to construct row 1 without exploding. Only the + -- three itemsTab fields read by the slot lookup at the top of + -- PriceItemRowDisplay need to be created here; everything else either + -- lives behind a callback we never trigger, or is already initialized + -- by the TradeQuery constructor. + local function newTradeQuery(state) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) + tq.itemsTab.activeItemSet = {} + tq.itemsTab.slots = {} + tq.itemsTab.sockets = {} + tq.slotTables[1] = { slotName = "Ring 1" } + if state.resultTbl then tq.resultTbl = state.resultTbl end + if state.sortedResultTbl then tq.sortedResultTbl = state.sortedResultTbl end + return tq + end + + -- Builds row 1 of the trader UI and returns the dropdown that owns the + -- tooltipFunc we want to exercise. + local function buildRow1Dropdown(tq) + tq:PriceItemRowDisplay(1, nil, 0, 20) + return tq.controls.resultDropdown1 + end + it("constructs the Watcher's Eye row without an active jewel socket", function() local tq = newTradeQuery({}) tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true } @@ -62,7 +73,7 @@ describe("TradeQuery", function() end) describe("replacement slot resolution", function() it("resolves normal, Abyssal, and selected jewel slots without stored row state", function() - local tq = newTradeQuery({}) + local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) tq.itemsTab.slots = { ["Ring 1"] = {}, ["Body Armour Abyssal Socket 1"] = {}, @@ -85,7 +96,7 @@ describe("TradeQuery", function() describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local calcCalls = 0 - local tq = newTradeQuery({}) + local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } tq.resultTbl = { [1] = { @@ -140,7 +151,8 @@ describe("TradeQuery", function() it("clears the visible selection and price when postfiltering removes every result", function() local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40 }) - local dropdown = buildRow1Dropdown(tq) + tq:PriceItemRowDisplay(1, nil, 0, 20) + local dropdown = tq.controls.resultDropdown1 tq.controls.fullPrice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") tq.controls.pbNotice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") -- Populate the row through the same path first, so stale state can be detected. From a7f2d317b2c66da2a78d35bdf1bcb3fef97557d7 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 10 Oct 2026 11:46:49 +0200 Subject: [PATCH 07/14] Reduce trade-query test setup Keep the sort, empty-row and generated-query checks while removing unused fixture fields, a single-use helper and redundant assertions. --- spec/System/TestTradeQueryGenerator_spec.lua | 87 +++++++------------- spec/System/TestTradeQuery_spec.lua | 61 +++++--------- 2 files changed, 52 insertions(+), 96 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 9afe908158..7ea4e8efac 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -5,7 +5,7 @@ describe("TradeQueryGenerator", function() local function findStatFilter(queryTable, id) for _, group in ipairs(queryTable.query.stats) do - for _, filter in ipairs(group.filters or {}) do + for _, filter in ipairs(group.filters) do if filter.id == id then return filter end @@ -15,13 +15,11 @@ describe("TradeQueryGenerator", function() local function finishQueryWithAttributeShortfall(shortfall, includeAttributeRequirementFilters) local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) - local queryTable - local errMsg + local queryTable, 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 @@ -29,66 +27,25 @@ describe("TradeQueryGenerator", function() queryGen.calcContext = { itemCategoryQueryStr = "accessory.ring", special = {}, - testItem = { - BuildAndParseRaw = function() end, - }, + testItem = { BuildAndParseRaw = function() end }, baseOutput = { TotalDPS = 100 }, - baseStatValue = 0, options = { statWeights = { { stat = "TotalDPS", weightMult = 1 } }, - includeAllWEMods = false, includeAttributeRequirementFilters = includeAttributeRequirementFilters, includeMirrored = true, - influence1 = 1, - influence2 = 1, }, attributeRequirementShortfall = shortfall, } local previousClosePopup = main.ClosePopup main.ClosePopup = function() end - local ok, finishError = pcall(function() - queryGen:FinishQuery() - end) + local ok, finishError = pcall(queryGen.FinishQuery, queryGen) main.ClosePopup = previousClosePopup assert.is_true(ok, finishError) return queryTable, errMsg end - local function startQueryWithReplacementOutput(replacementOutput) - local calcArgs - local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ - itemsTab = { - items = { - [1] = { baseName = "Gold Ring", type = "Ring", base = { type = "Ring" } }, - }, - build = { - calcsTab = { - GetMiscCalculator = function() - return function(args) - calcArgs = args - return replacementOutput - end, { TotalDPS = 100 } - end, - }, - }, - }, - }) - local previousOpenPopup = main.OpenPopup - main.OpenPopup = function() return {} end - local ok, startError = pcall(function() - queryGen:StartQuery({ slotName = "Ring 1", selItemId = 1 }, { - influence1 = 1, - influence2 = 1, - statWeights = { { stat = "TotalDPS", weightMult = 1 } }, - }) - end) - main.OpenPopup = previousOpenPopup - assert.is_true(ok, startError) - return queryGen, calcArgs - 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 @@ -306,15 +263,35 @@ describe("TradeQueryGenerator", function() describe("attribute requirement filters", function() it("calculates the shortfall from the blank replacement output", function() - local queryGen, calcArgs = startQueryWithReplacementOutput({ - TotalDPS = 100, - ReqStr = 50, - Str = 40, - ReqDex = 30, - Dex = 35, - ReqInt = 25, - Int = 20, + local calcArgs + local calcsTab = { + GetMiscCalculator = function() + return function(args) + calcArgs = args + return { + TotalDPS = 100, + ReqStr = 50, Str = 40, + ReqDex = 30, Dex = 35, + ReqInt = 25, Int = 20, + } + end, { TotalDPS = 100 } + end, + } + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ + itemsTab = { + items = { + [1] = { baseName = "Gold Ring" }, + }, + build = { calcsTab = calcsTab }, + }, + }) + local previousOpenPopup = main.OpenPopup + main.OpenPopup = function() return {} end + local ok, startError = pcall(queryGen.StartQuery, queryGen, { slotName = "Ring 1", selItemId = 1 }, { + statWeights = { { stat = "TotalDPS", weightMult = 1 } }, }) + main.OpenPopup = previousOpenPopup + assert.is_true(ok, startError) assert.are.equal("Ring 1", calcArgs.repSlotName) assert.are.equal("Gold Ring", calcArgs.repItem.baseName) assert.same({ Str = 10, Dex = 0, Int = 5 }, queryGen.calcContext.attributeRequirementShortfall) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index 3d16643021..cf087affc9 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -35,9 +35,7 @@ describe("TradeQuery", function() local tq = newTradeQuery({}) tq.slotTables[1] = { slotName = "Watcher's Eye", unique = true } - assert.has_no.errors(function() - buildRow1Dropdown(tq) - end) + assert.is_not_nil(buildRow1Dropdown(tq)) end) it("returns early when sortedResultTbl[row_idx] is missing", function() @@ -72,36 +70,29 @@ describe("TradeQuery", function() end) end) describe("replacement slot resolution", function() - it("resolves normal, Abyssal, and selected jewel slots without stored row state", function() - local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} }) - tq.itemsTab.slots = { - ["Ring 1"] = {}, - ["Body Armour Abyssal Socket 1"] = {}, - } - tq.itemsTab.sockets = { [123] = {} } + it("resolves Abyssal and selected jewel slots without stored row state", function() + local tq = new("TradeQuery"):TradeQuery({ + slots = { ["Body Armour Abyssal Socket 1"] = {} }, + sockets = { [123] = {} }, + }) tq.slotTables = { - { slotName = "Ring 1" }, { slotName = "Abyssal Socket 1", fullName = "Body Armour Abyssal Socket 1" }, { slotName = "Jewel Socket", selectedJewelNodeId = 123 }, { slotName = "Watcher's Eye", unique = true }, } - assert.are.equal("Ring 1", tq:GetReplacementSlotName(1)) - assert.are.equal("Body Armour Abyssal Socket 1", tq:GetReplacementSlotName(2)) - assert.are.equal("Jewel 123", tq:GetReplacementSlotName(3)) - assert.is_nil(tq:GetReplacementSlotName(4)) + assert.are.equal("Body Armour Abyssal Socket 1", tq:GetReplacementSlotName(1)) + assert.are.equal("Jewel 123", tq:GetReplacementSlotName(2)) + assert.is_nil(tq:GetReplacementSlotName(3)) end) end) describe("attribute requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) - local calcCalls = 0 - local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = {}, sockets = {} }) + local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = { ["Ring 1"] = {} }, sockets = {} }) 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.resultTbl[1] = { + { item_string = "Rarity: RARE\nBehemoth Hold\nGold Ring", amount = 1, currency = "chaos" }, } tq.sortModes = { Weight = "Weight", StatValue = "StatValue", StatValuePrice = "StatValuePrice", Price = "Price", @@ -114,21 +105,15 @@ describe("TradeQuery", function() tq.itemsTab.build = { calcsTab = { GetMiscCalculator = function() - return function(calcArgs) - calcCalls = calcCalls + 1 - return type(output) == "function" and output(calcArgs) or output - end, { Life = 100 } + return type(output) == "function" and output or function() return output end, { Life = 100 } end, }, } - tq.itemsTab.slots = { - ["Ring 1"] = {}, - } - return tq, function() return calcCalls end + return tq end for _, mode in ipairs({ "Weight", "StatValue", "StatValuePrice", "Price" }) do - it("postfilters mixed fetched results in " .. mode .. " mode", function() + it("filters mixed fetched results in " .. mode .. " mode", function() local tq = newTradeQueryWithOutput(function(calcArgs) assert.are.equal("Ring 1", calcArgs.repSlotName) if calcArgs.repItem.name == "Behemoth Hold, Gold Ring" then @@ -138,18 +123,16 @@ describe("TradeQuery", function() return { ReqStr = 50, Str = 60, ReqDex = 30, Dex = 30, ReqInt = 20, Int = 25, Life = 120 } end) -- The rejected result arrives first, costs less, and has more Life. - local survivor = { item_string = "Rarity: RARE\nSurvivor Hold\nGold Ring", amount = 7, currency = "chaos" } - tq.resultTbl[1][2] = survivor + tq.resultTbl[1][2] = { item_string = "Rarity: RARE\nSurvivor Hold\nGold Ring", amount = 7, currency = "chaos" } tq.hideResultsFailingAttributeRequirements = true local sortedItems, err = tq:SortFetchResults(1, tq.sortModes[mode]) assert.is_nil(err) assert.are.equal(1, #sortedItems) assert.are.equal(2, sortedItems[1].index) - assert.are.equal(survivor, tq.resultTbl[1][sortedItems[1].index]) end) end - it("clears the visible selection and price when postfiltering removes every result", function() + it("clears the visible selection and price when filtering removes every result", function() local tq = newTradeQueryWithOutput({ ReqStr = 50, Str = 40 }) tq:PriceItemRowDisplay(1, nil, 0, 20) local dropdown = tq.controls.resultDropdown1 @@ -161,17 +144,15 @@ describe("TradeQuery", function() assert.are.equal(1, tq.itemIndexTbl[1]) assert.are.equal("1 chaos", tq:GetTotalPriceString()) assert.is_true(tq.controls.importButton1:IsEnabled()) - local populatedPriceLabel = tq.controls.fullPrice.label + assert.matches("chaos", tq.controls.fullPrice.label, 1, true) tq.hideResultsFailingAttributeRequirements = true tq:UpdateControlsWithItems(1) assert.are.equal(0, dropdown:GetDropCount()) - assert.is_nil(dropdown:GetSelValue()) assert.are.equal(0, #tq.sortedResultTbl[1]) assert.is_nil(tq.itemIndexTbl[1]) assert.is_nil(tq.totalPrice[1]) assert.are.equal("", tq:GetTotalPriceString()) - assert.are_not.equal(populatedPriceLabel, tq.controls.fullPrice.label) assert.not_matches("chaos", tq.controls.fullPrice.label, 1, true) assert.matches("attribute requirements", tq.controls.pbNotice.label, 1, true) assert.is_falsy(tq.controls.importButton1:IsEnabled()) @@ -191,20 +172,18 @@ describe("TradeQuery", function() 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 tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end) 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 tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end, { slotName = "Megalomaniac", unique = true }) tq.hideResultsFailingAttributeRequirements = 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) describe("trade search links", function() From 59a02114ab2f0725db7f6b52187338ceca48e361 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 10 Oct 2026 18:20:32 +0200 Subject: [PATCH 08/14] Account for attribute scaling in trade query filters Convert final attribute deficits to raw item minimums using the native modifier database and rounding. Cap the result at the existing deficit because a replacement can increase attribute scaling itself. Cover amplified attributes and candidate scaling with two native cases sharing the same query fixture. --- spec/System/TestTradeQueryGenerator_spec.lua | 54 ++++++++++++++++---- src/Classes/TradeQueryGenerator.lua | 30 ++++++++--- src/Modules/Calcs.lua | 4 +- 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 7ea4e8efac..89cc447cfe 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -3,17 +3,17 @@ local dkjson = require "dkjson" describe("TradeQueryGenerator", function() local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) - local function findStatFilter(queryTable, id) + local function findStatFilter(queryTable, id, groupType) for _, group in ipairs(queryTable.query.stats) do for _, filter in ipairs(group.filters) do - if filter.id == id then + if filter.id == id and (not groupType or group.type == groupType) then return filter end end end end - local function finishQueryWithAttributeShortfall(shortfall, includeAttributeRequirementFilters) + local function finishQueryWithAttributeMinimums(minimums, includeAttributeRequirementFilters) local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) local queryTable, errMsg queryGen.modWeights = { @@ -34,7 +34,7 @@ describe("TradeQueryGenerator", function() includeAttributeRequirementFilters = includeAttributeRequirementFilters, includeMirrored = true, }, - attributeRequirementShortfall = shortfall, + attributeRequirementMinimums = minimums, } local previousClosePopup = main.ClosePopup @@ -262,8 +262,42 @@ describe("TradeQueryGenerator", function() end) describe("attribute requirement filters", function() - it("calculates the shortfall from the blank replacement output", function() + for _, case in ipairs({ + { name = "admits an equipped candidate whose Strength is amplified", customMods = "+50 to Strength\n100% increased Strength", originalStrength = 30, candidateMods = "+26 to Strength", strength = 192, minimum = 26 }, + { name = "does not tighten the filter when a candidate increases reduced Strength", customMods = "+150 to Strength\n10% reduced Strength", originalStrength = 50, candidateMods = "+38 to Strength\n6% increased Strength", strength = 200, minimum = 38 }, + }) do + it(case.name, function() + newBuild() + build.characterLevel, build.characterLevelAutoMode = 100, false + build.configTab.input.customMods = case.customMods + build.configTab:BuildModList() + build.itemsTab:AddItem(new("Item"):Item("Rarity: RARE\nRequirement Anchor\nGlorious Plate\nImplicits: 0")) + build.itemsTab:AddItem(new("Item"):Item("Rarity: RARE\nOriginal Ring\nGold Ring\nImplicits: 0\n+" .. case.originalStrength .. " to Strength")) + build.modFlag, build.buildFlag = true, true + runCallback("OnFrame") + local candidate = new("Item"):Item("Rarity: RARE\nCandidate Ring\nGold Ring\nImplicits: 0\n" .. case.candidateMods) + local calc = build.calcsTab:GetMiscCalculator() + local equipped = calc({ repSlotName = "Ring 1", repItem = candidate }) + assert.are.equal(case.strength, equipped.Str) + assert.are.equal(191, equipped.ReqStr) + local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = build.itemsTab }) + local query + queryGen.requesterCallback = function(_, queryJSON) query = dkjson.decode(queryJSON) end + queryGen:StartQuery(build.itemsTab.slots["Ring 1"], { + statWeights = { { stat = "Life", weightMult = 1 } }, + includeAttributeRequirementFilters = true, includeMirrored = true, + }) + while queryGen.calcContext.co do queryGen:OnFrame() end + assert.are.equal(case.minimum, findStatFilter(query, "pseudo.pseudo_total_strength", "and").value.min) + end) + end + + it("calculates attribute minimums from the blank replacement output", function() local calcArgs + local modDB = new("ModDB"):ModDB() + for attribute, value in pairs({ Str = 40, Dex = 35, Int = 20 }) do + modDB:NewMod(attribute, "BASE", value, "Fixture") + end local calcsTab = { GetMiscCalculator = function() return function(args) @@ -273,7 +307,7 @@ describe("TradeQueryGenerator", function() ReqStr = 50, Str = 40, ReqDex = 30, Dex = 35, ReqInt = 25, Int = 20, - } + }, modDB end, { TotalDPS = 100 } end, } @@ -294,11 +328,11 @@ describe("TradeQueryGenerator", function() assert.is_true(ok, startError) assert.are.equal("Ring 1", calcArgs.repSlotName) assert.are.equal("Gold Ring", calcArgs.repItem.baseName) - assert.same({ Str = 10, Dex = 0, Int = 5 }, queryGen.calcContext.attributeRequirementShortfall) + assert.same({ Str = 10, Dex = 0, Int = 5 }, queryGen.calcContext.attributeRequirementMinimums) end) it("adds needed attribute pseudo filters to the generated query", function() - local queryTable, errMsg = finishQueryWithAttributeShortfall({ Str = 12, Dex = 34, Int = 56 }, true) + local queryTable, errMsg = finishQueryWithAttributeMinimums({ 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) @@ -306,8 +340,8 @@ describe("TradeQueryGenerator", function() 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) + local disabledQuery = finishQueryWithAttributeMinimums({ Str = 12, Dex = 34, Int = 56 }, false) + local zeroQuery = finishQueryWithAttributeMinimums({ 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")) diff --git a/src/Classes/TradeQueryGenerator.lua b/src/Classes/TradeQueryGenerator.lua index 8e47dcece3..44c4711cf6 100644 --- a/src/Classes/TradeQueryGenerator.lua +++ b/src/Classes/TradeQueryGenerator.lua @@ -7,6 +7,7 @@ local dkjson = require "dkjson" local curl = require("lcurl.safe") local m_max = math.max +local m_min = math.min local s_format = string.format local t_insert = table.insert local tradeHelpers = require("Classes.TradeHelpers") @@ -765,11 +766,24 @@ 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 - -- Pre-fetch constraint: require the replacement to restore attributes lost with the current item. - local attributeRequirementShortfall = { Str = 0, Dex = 0, Int = 0 } + local baseItemOutput, baseItemModDB = baseOutput + if slot then + baseItemOutput, baseItemModDB = calcFunc({ repSlotName = slot.slotName, repItem = testItem }) + end + -- Pre-fetch constraint: conservatively narrow the replacement's raw attributes. + local attributeRequirementMinimums = { Str = 0, Dex = 0, Int = 0 } if slot and (not slot.slotName:find("Flask")) then - attributeRequirementShortfall = tradeHelpers.getAttributeRequirementShortfall(baseItemOutput) + attributeRequirementMinimums = tradeHelpers.getAttributeRequirementShortfall(baseItemOutput) + for _, attribute in ipairs({ "Str", "Dex", "Int" }) do + if (attributeRequirementMinimums[attribute] or 0) > 0 then + local multiplier = calcLib.mod(baseItemModDB, nil, attribute) + -- Trade counts raw item attributes; PoB rounds the amplified build total. + -- Cap at the final shortfall: the candidate can increase attribute scaling. + attributeRequirementMinimums[attribute] = multiplier > 0 and m_min(attributeRequirementMinimums[attribute], m_max(0, + math.ceil((baseItemOutput["Req" .. attribute] - 0.5) / multiplier + - baseItemModDB:Sum("BASE", nil, attribute)))) or 0 + end + end end -- make weights more human readable local compStatValue = TradeQueryGeneratorClass.WeightedRatioOutputs(baseOutput, baseItemOutput, options.statWeights) * 1000 @@ -790,7 +804,7 @@ function TradeQueryGeneratorClass:StartQuery(slot, options) slot = slot, requiredMods = options.requiredMods, blockedMods = options.blockedMods, - attributeRequirementShortfall = attributeRequirementShortfall, + attributeRequirementMinimums = attributeRequirementMinimums, } -- OnFrame will pick this up and begin the work @@ -1064,9 +1078,9 @@ function TradeQueryGeneratorClass:FinishQuery() end local options = self.calcContext.options - -- If enabled, require the new item to provide enough attributes to meet build requirements. - if options.includeAttributeRequirementFilters and self.calcContext.attributeRequirementShortfall then - local need = self.calcContext.attributeRequirementShortfall + -- Add the conservative raw-attribute minimums when enabled. + if options.includeAttributeRequirementFilters and self.calcContext.attributeRequirementMinimums then + local need = self.calcContext.attributeRequirementMinimums if need.Str and need.Str > 0 then complexityBudget = complexityBudget - 4 t_insert(andGroup.filters, { id = "pseudo.pseudo_total_strength", value = { min = need.Str } }) diff --git a/src/Modules/Calcs.lua b/src/Modules/Calcs.lua index 9134e1080f..c9fbdc5802 100644 --- a/src/Modules/Calcs.lua +++ b/src/Modules/Calcs.lua @@ -84,7 +84,7 @@ end -- Get calculator for other changes (adding/removing nodes, items, gems, etc) ---@param build Build ----@return fun(override?: CalcOverride, useFullDPS?: boolean): Output calcFunc +---@return fun(override?: CalcOverride, useFullDPS?: boolean): Output, ModDB calcFunc ---@return Output output function calcs.getMiscCalculator(build) -- Run base calculation pass @@ -116,7 +116,7 @@ function calcs.getMiscCalculator(build) env.player.output.FullDPS = fullDPS.combinedDPS env.player.output.FullDotDPS = fullDPS.TotalDotDPS end - return env.player.output + return env.player.output, env.modDB end, env.player.output end From 23cfd80d39075fed9ad52813354ebffef7adab8d Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 24 Oct 2026 10:50:32 +0200 Subject: [PATCH 09/14] Restore the Trader row anchor to the dev offset The rows started one row lower than the pane height allows, so with many slots the last visible row covered the Total Price label. Use the same anchor and scroll offset as dev. --- src/Classes/TradeQuery.lua | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 3c2770f001..09018db41a 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -586,9 +586,7 @@ Highest Weight - Displays the order retrieved from trade]] t_insert(slotTables, { slotName = self.itemsTab.sockets[nodeId].label, nodeId = nodeId }) end - -- 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"):LabelControl({"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, sectionAnchorBaseY, 0, 0}, "") + self.controls.sectionAnchor = new("LabelControl"):LabelControl({"LEFT", self.controls.tradeTypeSelection, "LEFT"}, {0, row_vertical_padding, 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 @@ -673,7 +671,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 = sectionAnchorBaseY - self.controls.scrollBar.offset + self.controls.sectionAnchor.y = -self.controls.scrollBar.offset end local function onRateLimit(backoff) From c45597348c385df67edf6eb97a123305abd04d46 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 24 Oct 2026 10:51:04 +0200 Subject: [PATCH 10/14] Filter Trader results by level and attribute requirements Replace the attribute checkbox with the requirement list of the Items tab unique list (Any requirements, Current level, Current attributes, Current useable), placed under Sort By. Current level hides results whose level requirement is above the character level; Current attributes keeps the equipped attribute check. Trade results now keep the listing's level requirement, which includes its mods, so the level check does not fall back to the base level. --- spec/System/TestTradeQueryRequests_spec.lua | 32 +++++++++++++++ spec/System/TestTradeQuery_spec.lua | 15 ++++++- src/Classes/TradeQuery.lua | 44 +++++++++++++-------- src/Classes/TradeQueryRequests.lua | 9 +++++ 4 files changed, 81 insertions(+), 19 deletions(-) diff --git a/spec/System/TestTradeQueryRequests_spec.lua b/spec/System/TestTradeQueryRequests_spec.lua index ccb4fc6c51..95310d27e5 100644 --- a/spec/System/TestTradeQueryRequests_spec.lua +++ b/spec/System/TestTradeQueryRequests_spec.lua @@ -237,6 +237,38 @@ Strict-Transport-Security: max-age=63115200; includeSubDomains; preload]] end end) + it("keeps the listing level requirement in reconstructed item strings", function() + local response = dkjson.encode({ + result = { + { + id = "levelled", + listing = { + price = { amount = 1, currency = "chaos", type = "~price" }, + whisper = "hi", + account = { name = "seller" }, + }, + item = { + rarity = "Rare", + name = "Test Subject", + typeLine = "Astral Plate", + -- Astral Plate alone requires level 62 + requirements = { { name = "Level", values = { { "70", 0 } } } }, + }, + }, + }, + }) + local fetchedItems + requests.requestQueue.fetch = { } + requests:FetchResultBlock("test", function(items) + fetchedItems = items + end) + + local request = table.remove(requests.requestQueue.fetch, 1) + request.callback(response) + + assert.are.equal(70, new("Item"):Item(fetchedItems[1].item_string).requirements.level) + end) + it("reads weighted sums from current and legacy pseudo mods", function() local function makeTradeEntry(id, pseudoMods) return { diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index cf087affc9..e7ed38d337 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -87,7 +87,7 @@ describe("TradeQuery", function() end) end) - describe("attribute requirement result filtering", function() + describe("requirement result filtering", function() local function newTradeQueryWithOutput(output, slotTbl) local tq = new("TradeQuery"):TradeQuery({ activeItemSet = {}, slots = { ["Ring 1"] = {} }, sockets = {} }) tq.slotTables[1] = slotTbl or { slotName = "Ring 1" } @@ -154,7 +154,7 @@ describe("TradeQuery", function() assert.is_nil(tq.totalPrice[1]) assert.are.equal("", tq:GetTotalPriceString()) assert.not_matches("chaos", tq.controls.fullPrice.label, 1, true) - assert.matches("attribute requirements", tq.controls.pbNotice.label, 1, true) + assert.matches("requirement filter", tq.controls.pbNotice.label, 1, true) assert.is_falsy(tq.controls.importButton1:IsEnabled()) assert.are.equal("", tq.controls.whisperButton1:GetProperty("label")) local tooltip = new("Tooltip"):Tooltip() @@ -171,6 +171,17 @@ describe("TradeQuery", function() assert.are.equal(0, #sortedItems) end) + it("filters fetched results above the character level without recalculating", function() + local tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end) + -- Gold Ring requires level 20, Iron Ring has no level requirement. + tq.resultTbl[1][2] = { item_string = "Rarity: RARE\nSurvivor Hold\nIron Ring", amount = 1, currency = "chaos" } + tq.itemsTab.build.characterLevel = 19 + tq.hideResultsAboveCharacterLevel = true + local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) + assert.are.equal(1, #sortedItems) + assert.are.equal(2, sortedItems[1].index) + end) + it("keeps fetched results without recalculating by default", function() local tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end) local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 09018db41a..e799f1e3d2 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -41,6 +41,7 @@ function TradeQueryClass:TradeQuery(itemsTab) ---@type TradeQuerySlotTable[] self.slotTables = { } self.pbItemSortSelectionIndex = 1 + self.hideResultsAboveCharacterLevel = false self.hideResultsFailingAttributeRequirements = false -- for each realm and league, a table of values of each currency in div --- @type table>> @@ -462,18 +463,20 @@ Highest Weight - Displays the order retrieved from trade]] self.controls.itemSortSelectionLabel = new("LabelControl"):LabelControl({"TOPRIGHT", self.controls.itemSortSelection, "TOPLEFT"}, {-4, 0, 56, 16}, "^7Sort By:") -- Optional post-fetch validation complements the query filters with the fully equipped output. - 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.hideResultsFailingAttributeRequirementsCheck = new("CheckBoxControl"):CheckBoxControl({"LEFT", self.controls.tradeTypeSelection, "RIGHT"}, hideAttributeRequirementsRect, hideAttributeRequirementsLabel, function(state) - self.hideResultsFailingAttributeRequirements = state + -- Same choices and placement as the requirement filter under the sort list of the Items tab unique list. + self.controls.itemRequirementSelection = new("DropDownControl"):DropDownControl({"TOPRIGHT", self.controls.itemSortSelection, "BOTTOMRIGHT"}, {0, row_vertical_padding, 170, row_height}, { "Any requirements", "Current level", "Current attributes", "Current useable" }, function(index, value) + self.hideResultsAboveCharacterLevel = index == 2 or index == 4 + self.hideResultsFailingAttributeRequirements = index > 2 for row_idx, _ in pairs(self.resultTbl) do self:UpdateControlsWithItems(row_idx) end end) - self.controls.hideResultsFailingAttributeRequirementsCheck.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.hideResultsFailingAttributeRequirementsCheck.state = self.hideResultsFailingAttributeRequirements + self.controls.itemRequirementSelection.tooltipText = +[[Hides fetched results the build could not use: +Current level - Level requirement above the character level +Current attributes - Unmet Str/Dex/Int/Omniscience requirements once equipped +Current useable - Either of the above]] + self.controls.itemRequirementSelection:SetSel((self.hideResultsAboveCharacterLevel and 1 or 0) + (self.hideResultsFailingAttributeRequirements and 2 or 0) + 1, true) -- Realm selection self.controls.realmLabel = new("LabelControl"):LabelControl({"LEFT", self.controls.setSelect, "RIGHT"}, {18, 0, 20, row_height - 4}, "^7Realm:") @@ -981,8 +984,8 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) end if not sortedItems or #sortedItems == 0 then clearVisibleResults() - self:SetNotice(self.controls.pbNotice, self.hideResultsFailingAttributeRequirements and - "No usable results (attribute requirements)" or "^4No compatible items found for this slot.") + self:SetNotice(self.controls.pbNotice, (self.hideResultsAboveCharacterLevel or self.hideResultsFailingAttributeRequirements) and + "No usable results (requirement filter)" or "^4No compatible items found for this slot.") return end @@ -1018,16 +1021,23 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) return {} end + local checkAttributes = self.hideResultsFailingAttributeRequirements and slotName ~= nil -- Post-fetch validation recalculates the equipped candidate so Omniscience and -- other requirement transformations are applied before filtering the result. - local function meetsAttributeRequirements(result_index) - if not self.hideResultsFailingAttributeRequirements or not slotName then + local function meetsRequirementFilter(result_index) + if not self.hideResultsAboveCharacterLevel and not checkAttributes then + return true + end + local item = new("Item"):Item(self.resultTbl[row_idx][result_index].item_string) + if self.hideResultsAboveCharacterLevel and item.requirements.level and item.requirements.level > self.itemsTab.build.characterLevel then + return false + end + if not checkAttributes then return true end if not calcFunc then calcFunc, baseOutput = self.itemsTab.build.calcsTab:GetMiscCalculator() end - local item = new("Item"):Item(self.resultTbl[row_idx][result_index].item_string) local output = calcFunc({ repSlotName = slotName, repItem = item }) return tradeHelpers.meetsAttributeRequirements(output) end @@ -1060,14 +1070,14 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) local newTbl = {} if mode == self.sortModes.Weight then for index, _ in pairs(self.resultTbl[row_idx]) do - if meetsAttributeRequirements(index) then + if meetsRequirementFilter(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 - if meetsAttributeRequirements(result_index) then + if meetsRequirementFilter(result_index) then t_insert(newTbl, { outputAttr = getResultWeight(result_index), index = result_index }) end end @@ -1089,7 +1099,7 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) -- scaling factor for price local k = 0.1 - if meetsAttributeRequirements(result_index) then + if meetsRequirementFilter(result_index) then t_insert(newTbl, { outputAttr = getResultWeight(result_index) - k * math.log(priceTable[result_index], 10), index = result_index }) @@ -1102,7 +1112,7 @@ function TradeQueryClass:SortFetchResults(row_idx, mode) return nil, "MissingConversionRates" end for result_index, price in pairs(priceTable) do - if meetsAttributeRequirements(result_index) then + if meetsRequirementFilter(result_index) then t_insert(newTbl, { outputAttr = price, index = result_index }) end end diff --git a/src/Classes/TradeQueryRequests.lua b/src/Classes/TradeQueryRequests.lua index 0c93599196..4bcec70e47 100644 --- a/src/Classes/TradeQueryRequests.lua +++ b/src/Classes/TradeQueryRequests.lua @@ -341,6 +341,15 @@ function TradeQueryRequestsClass:FetchResultBlock(url, callback) if item.ilvl then t_insert(rawLines, "Item Level: " .. item.ilvl) end + -- The listing's level requirement includes its mods, unlike the base level. + -- Socketed gems can override it, as in the character import. + if item.requirements and not (item.socketedItems and item.socketedItems[1]) then + for _, req in ipairs(item.requirements) do + if req.name == "Level" and req.values and req.values[1] then + t_insert(rawLines, "LevelReq: " .. req.values[1][1]) + end + end + end if item.sockets then local socketString = "" local colours = {} From 47bd79688153d674c83fef8bc64899102b0b1fe2 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 24 Oct 2026 11:57:49 +0200 Subject: [PATCH 11/14] Keep the empty search notice in the Trader Since results can be filtered by requirements, a search returning no compatible item kept an empty result row and no notice. Go back to the search view with "No compatible items found for this slot." as before, and word the filtered-out notice like the other Trader notices. --- spec/System/TestTradeQuery_spec.lua | 13 ++++++++++++- src/Classes/TradeQuery.lua | 6 ++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/spec/System/TestTradeQuery_spec.lua b/spec/System/TestTradeQuery_spec.lua index e7ed38d337..ac405bbcce 100644 --- a/spec/System/TestTradeQuery_spec.lua +++ b/spec/System/TestTradeQuery_spec.lua @@ -154,7 +154,7 @@ describe("TradeQuery", function() assert.is_nil(tq.totalPrice[1]) assert.are.equal("", tq:GetTotalPriceString()) assert.not_matches("chaos", tq.controls.fullPrice.label, 1, true) - assert.matches("requirement filter", tq.controls.pbNotice.label, 1, true) + assert.matches("selected requirements", tq.controls.pbNotice.label, 1, true) assert.is_falsy(tq.controls.importButton1:IsEnabled()) assert.are.equal("", tq.controls.whisperButton1:GetProperty("label")) local tooltip = new("Tooltip"):Tooltip() @@ -182,6 +182,17 @@ describe("TradeQuery", function() assert.are.equal(2, sortedItems[1].index) end) + it("reports a search without compatible items even when a requirement filter is selected", function() + local tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end) + tq.controls.fullPrice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") + tq.controls.pbNotice = new("LabelControl"):LabelControl(nil, { 0, 0, 100, 20 }, "") + tq.resultTbl[1] = {} + tq.hideResultsFailingAttributeRequirements = true + tq:UpdateControlsWithItems(1) + assert.matches("No compatible items found", tq.controls.pbNotice.label, 1, true) + assert.is_nil(tq.resultTbl[1]) + end) + it("keeps fetched results without recalculating by default", function() local tq = newTradeQueryWithOutput(function() error("Unexpected attribute recalculation") end) local sortedItems = tq:SortFetchResults(1, tq.sortModes.Weight) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index e799f1e3d2..41d13e7018 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -967,7 +967,9 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) local results = self.resultTbl[row_idx] if not results or #results == 0 then - clearVisibleResults() + -- nothing to filter: go back to the search view, as before requirement filtering + self:ResetResultRow(row_idx) + self:SetNotice(self.controls.pbNotice, "^4No compatible items found for this slot.") return end @@ -985,7 +987,7 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) if not sortedItems or #sortedItems == 0 then clearVisibleResults() self:SetNotice(self.controls.pbNotice, (self.hideResultsAboveCharacterLevel or self.hideResultsFailingAttributeRequirements) and - "No usable results (requirement filter)" or "^4No compatible items found for this slot.") + "^4No results match the selected requirements." or "^4No compatible items found for this slot.") return end From 9960d9510c31de8ef2da656e0551fa2f55d0ba03 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 24 Oct 2026 13:52:03 +0200 Subject: [PATCH 12/14] Tidy the Trader requirement filter code Restore the original formatting of the whisper button constructor, comment the Pearl of Tsoatha slot choice, drop a guard the sorted results never need, and make the dropdown comment cover the level filter. --- src/Classes/TradeQuery.lua | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 41d13e7018..5f8ed6d0d5 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -846,6 +846,7 @@ function TradeQueryClass:GetReplacementSlot(row_idx) if jewelNodeId then return self.itemsTab.sockets and self.itemsTab.sockets[jewelNodeId] end + -- Remember the first visible ring as the Pearl's slot. if slotTbl.slotName == "Pearl of Tsoatha" and not slotTbl.selectedSlotName then for index = 1, 3 do local ringSlot = self.itemsTab.slots and self.itemsTab.slots["Ring " .. index] @@ -931,14 +932,12 @@ function TradeQueryClass:UpdateDropdownList(row_idx) if not self.resultTbl[row_idx] then return end - -- Iterate the sorted (and potentially filtered) list so attribute-filtered rows are omitted from the dropdown + -- Iterate the sorted (and potentially filtered) list so results hidden by the requirement filter 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"):Item(result.item_string) - table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) - end + 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"):Item(result.item_string) + table.insert(dropdownLabels, colorCodes[item.rarity] .. item.name .. price) end if self.controls["resultDropdown".. row_idx] then self.controls["resultDropdown".. row_idx].selIndex = 1 @@ -1395,8 +1394,7 @@ you can add them, copy the link here, and press "Price Item" to evaluate the ite return itemResult and itemResult.item_string ~= nil end -- Whisper so we can copy to clipboard - controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl( - { "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() + controls["whisperButton" .. row_idx] = new("ButtonControl"):ButtonControl({ "TOPLEFT", controls["importButton" .. row_idx], "TOPRIGHT" }, { 8, 0, 155, row_height }, function() local itemResult = getSelectedResult() if not itemResult then return "" end From f5771d943aeaa65186f3bfd8133c078a97d6077f Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Sat, 24 Oct 2026 15:10:45 +0200 Subject: [PATCH 13/14] Simplify the empty search reset comment --- src/Classes/TradeQuery.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Classes/TradeQuery.lua b/src/Classes/TradeQuery.lua index 5f8ed6d0d5..4cb2a4900d 100644 --- a/src/Classes/TradeQuery.lua +++ b/src/Classes/TradeQuery.lua @@ -966,7 +966,7 @@ function TradeQueryClass:UpdateControlsWithItems(row_idx) local results = self.resultTbl[row_idx] if not results or #results == 0 then - -- nothing to filter: go back to the search view, as before requirement filtering + -- nothing to filter: go back to the search view self:ResetResultRow(row_idx) self:SetNotice(self.controls.pbNotice, "^4No compatible items found for this slot.") return From 3493a214d340be85cb652389204976b6c84d4f71 Mon Sep 17 00:00:00 2001 From: Mickael Cagnion Date: Thu, 29 Oct 2026 12:05:51 +0100 Subject: [PATCH 14/14] Adapt trade requirement tests to query tables Consume the generator callback's query table directly so the attribute minimum assertions keep exercising the current trade query contract. --- spec/System/TestTradeQueryGenerator_spec.lua | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/spec/System/TestTradeQueryGenerator_spec.lua b/spec/System/TestTradeQueryGenerator_spec.lua index 89cc447cfe..003ba4063c 100644 --- a/spec/System/TestTradeQueryGenerator_spec.lua +++ b/spec/System/TestTradeQueryGenerator_spec.lua @@ -1,10 +1,8 @@ -local dkjson = require "dkjson" - describe("TradeQueryGenerator", function() local mock_queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} }) local function findStatFilter(queryTable, id, groupType) - for _, group in ipairs(queryTable.query.stats) do + for _, group in ipairs(queryTable.stats) do for _, filter in ipairs(group.filters) do if filter.id == id and (not groupType or group.type == groupType) then return filter @@ -20,8 +18,8 @@ describe("TradeQueryGenerator", function() { tradeModId = "explicit.stat_3299347043", weight = 1, meanStatDiff = 1 }, } queryGen.tradeTypeIndex = 1 - queryGen.requesterCallback = function(_, queryJson, queryErrMsg) - queryTable = dkjson.decode(queryJson) + queryGen.requesterCallback = function(_, generatedQuery, queryErrMsg) + queryTable = generatedQuery errMsg = queryErrMsg end queryGen.calcContext = { @@ -282,7 +280,7 @@ describe("TradeQueryGenerator", function() assert.are.equal(191, equipped.ReqStr) local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = build.itemsTab }) local query - queryGen.requesterCallback = function(_, queryJSON) query = dkjson.decode(queryJSON) end + queryGen.requesterCallback = function(_, generatedQuery) query = generatedQuery end queryGen:StartQuery(build.itemsTab.slots["Ring 1"], { statWeights = { { stat = "Life", weightMult = 1 } }, includeAttributeRequirementFilters = true, includeMirrored = true,