Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions spec/System/TestTradeQueryGenerator_spec.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,94 @@
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, includeAttributeRequirementFilters)
local queryGen = new("TradeQueryGenerator"):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 = "accessory.ring",
special = {},
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)
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
Expand Down Expand Up @@ -192,4 +280,40 @@ describe("TradeQueryGenerator", function()
assert.is_not_nil(query.filters.socket_filters.filters.links)
end)
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)
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)
121 changes: 120 additions & 1 deletion spec/System/TestTradeQuery_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ 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.
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
Expand All @@ -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({})
Expand Down Expand Up @@ -60,6 +70,115 @@ 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 = {} })
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 = {
[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(calcArgs)
calcCalls = calcCalls + 1
lastCalcArgs = calcArgs
return output
end, {}
end,
},
}
tq.itemsTab.slots = {
["Ring 1"] = {},
}
return tq, function() return calcCalls end, function() return lastCalcArgs 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)

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("ReduceOutput", function()
it("preserves lower-is-better values for weighted result comparison", function()
Expand Down
27 changes: 26 additions & 1 deletion src/Classes/TradeHelpers.lua
Original file line number Diff line number Diff line change
@@ -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
local statDescData = require("Data.StatDescriptions.stat_descriptions")

-- precalculate patterns used for matching stat lines
Expand Down Expand Up @@ -36,6 +37,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)
Expand Down
Loading
Loading