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
135 changes: 135 additions & 0 deletions spec/System/TestTradeQueryGenerator_spec.lua
Original file line number Diff line number Diff line change
@@ -1,6 +1,51 @@
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 _, filter in ipairs(group.filters) do
if filter.id == id and (not groupType or group.type == groupType) then
return filter
end
end
end
end

local function finishQueryWithAttributeMinimums(minimums, includeAttributeRequirementFilters)
local queryGen = new("TradeQueryGenerator"):TradeQueryGenerator({ itemsTab = {} })
local queryTable, errMsg
queryGen.modWeights = {
{ tradeModId = "explicit.stat_3299347043", weight = 1, meanStatDiff = 1 },
}
queryGen.tradeTypeIndex = 1
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 },
options = {
statWeights = { { stat = "TotalDPS", weightMult = 1 } },
includeAttributeRequirementFilters = includeAttributeRequirementFilters,
includeMirrored = true,
},
attributeRequirementMinimums = minimums,
}

local previousClosePopup = main.ClosePopup
main.ClosePopup = function() end
local ok, finishError = pcall(queryGen.FinishQuery, queryGen)
main.ClosePopup = previousClosePopup
assert.is_true(ok, finishError)

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
Expand Down Expand Up @@ -204,4 +249,94 @@ describe("TradeQueryGenerator", function()
assert.is_not_nil(query.filters.socket_filters.filters.links)
end)
end)

describe("attribute requirement filters", 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)
calcArgs = args
return {
TotalDPS = 100,
ReqStr = 50, Str = 40,
ReqDex = 30, Dex = 35,
ReqInt = 25, Int = 20,
}, modDB
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.attributeRequirementMinimums)
end)

it("adds needed attribute pseudo filters to the generated query", function()
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)
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 = 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"))
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)
32 changes: 32 additions & 0 deletions spec/System/TestTradeQueryRequests_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
150 changes: 149 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,13 @@ 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.is_not_nil(buildRow1Dropdown(tq))
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 @@ -61,6 +69,145 @@ describe("TradeQuery", function()
assert.are.equal(0, #tooltip.lines)
end)
end)
describe("replacement slot resolution", function()
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 = "Abyssal Socket 1", fullName = "Body Armour Abyssal Socket 1" },
{ slotName = "Jewel Socket", selectedJewelNodeId = 123 },
{ slotName = "Watcher's Eye", unique = true },
}

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("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" }
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",
}
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 type(output) == "function" and output or function() return output end, { Life = 100 }
end,
},
}
return tq
end

for _, mode in ipairs({ "Weight", "StatValue", "StatValuePrice", "Price" }) do
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
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.
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)
end)
end

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
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())
assert.matches("chaos", tq.controls.fullPrice.label, 1, true)

tq.hideResultsFailingAttributeRequirements = true
tq:UpdateControlsWithItems(1)
assert.are.equal(0, dropdown:GetDropCount())
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.not_matches("chaos", tq.controls.fullPrice.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()
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()
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("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("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)
assert.are.equal(1, #sortedItems)
assert.are.equal(1, sortedItems[1].index)
end)

it("does not apply equipment attribute filtering to rows without a replacement slot", function()
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)
end)
end)
describe("GetResultEvaluation", function()
it("uses the first visible ring for a Pearl result without a selected slot", function()
local tq = new("TradeQuery"):TradeQuery({ itemsTab = {} })
Expand Down Expand Up @@ -90,6 +237,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 = {
Expand Down
Loading
Loading