Skip to content

Commit 4418bb2

Browse files
mcagnionclaude
andcommitted
feat(trade-query): add required mod pins for AND filters in trade queries
Port of feature/trade-query-required-mod-pins onto current origin/dev. Adapted to upstream PathOfBuildingCommunity#9691 changes (new popup layout, includeMirrored guarded for uniques, new control anchoring pattern). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e2bb84f commit 4418bb2

2 files changed

Lines changed: 360 additions & 3 deletions

File tree

spec/System/TestTradeQueryGenerator_spec.lua

Lines changed: 260 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
local dkjson = require "dkjson"
2+
13
describe("TradeQueryGenerator", function()
2-
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {} })
4+
local mock_queryGen = new("TradeQueryGenerator", { itemsTab = {}, GetTradeStatusOption = function() return "online" end })
35

46
describe("ProcessMod", function()
57
-- Pass: Mod line maps correctly to trade stat entry without error
@@ -57,4 +59,261 @@ describe("TradeQueryGenerator", function()
5759
_G.MAX_FILTERS = orig_max
5860
end)
5961
end)
62+
63+
describe("Required mod filters", function()
64+
-- Synthetic trade IDs used to isolate tests from the live trade stats lookup.
65+
local CORRUPTED_BLOOD_ID = "corrupted.stat_3299347024"
66+
local MAX_LIFE_ID = "implicit.stat_3299347025"
67+
68+
local function makeQueryGen()
69+
local qg = new("TradeQueryGenerator", { itemsTab = { items = {} } })
70+
-- Inject known trade ID mappings keyed by mod type; avoids dependency on live API data.
71+
qg.modTradeIdByText = {
72+
Corrupted = {
73+
["Corrupted Blood cannot be inflicted on you"] = CORRUPTED_BLOOD_ID,
74+
},
75+
Implicit = {
76+
[" to maximum Life"] = MAX_LIFE_ID, -- normalized form of "+# to maximum Life"
77+
},
78+
}
79+
qg.requesterContext = {}
80+
return qg
81+
end
82+
83+
local function baseCalcContext(pinnedModLines)
84+
return {
85+
slot = { selItemId = 1, slotName = "Jewel 1" },
86+
testItem = new("Item", "Rarity: RARE\nStat Tester\nCrimson Jewel"),
87+
calcFunc = function() return { Score = 0 } end,
88+
baseOutput = { Score = 0 },
89+
baseStatValue = 0,
90+
itemCategoryQueryStr = "jewel",
91+
options = {
92+
includeMirrored = true,
93+
influence1 = 1,
94+
influence2 = 1,
95+
statWeights = {},
96+
pinnedModLines = pinnedModLines or {},
97+
},
98+
special = {},
99+
}
100+
end
101+
102+
local function equippedItem()
103+
return {
104+
explicitModLines = {},
105+
scourgeModLines = {},
106+
implicitModLines = {},
107+
crucibleModLines = {},
108+
}
109+
end
110+
111+
describe("ResolveRequiredModFilters", function()
112+
-- Pass: No mods pinned => empty list, existing query unchanged.
113+
-- Fail: Returns non-empty list, inserting spurious and-filters into the query.
114+
it("returns empty list when pinnedModLines is empty", function()
115+
local qg = makeQueryGen()
116+
local result = qg:ResolveRequiredModFilters({})
117+
assert.are.same({}, result)
118+
end)
119+
120+
-- Pass: nil input treated as no mods pinned.
121+
-- Fail: Crashes on nil, breaking queries that never set pinnedModLines.
122+
it("returns empty list when pinnedModLines is nil", function()
123+
local qg = makeQueryGen()
124+
local result = qg:ResolveRequiredModFilters(nil)
125+
assert.are.same({}, result)
126+
end)
127+
128+
-- Pass: Single mod resolves to the correct trade stat ID.
129+
-- Fail: Wrong ID or empty list, causing the filter to be missing from the query.
130+
it("resolves a single required mod to its trade filter id", function()
131+
local qg = makeQueryGen()
132+
local result = qg:ResolveRequiredModFilters({ "Corrupted Blood cannot be inflicted on you" })
133+
assert.are.equal(1, #result)
134+
assert.are.equal(CORRUPTED_BLOOD_ID, result[1].id)
135+
end)
136+
137+
-- Pass: Corrupted Blood specifically maps to a valid trade stat id.
138+
-- Fail: Returns nil/empty, meaning the immunity would not be enforced in trade queries.
139+
it("handles 'Corrupted Blood cannot be inflicted on you' mod", function()
140+
local qg = makeQueryGen()
141+
local result = qg:ResolveRequiredModFilters({ "Corrupted Blood cannot be inflicted on you" })
142+
assert.are.equal(1, #result)
143+
assert.is_not_nil(result[1].id)
144+
assert.are.equal(CORRUPTED_BLOOD_ID, result[1].id)
145+
end)
146+
147+
-- Pass: Two mods each produce their own filter entry.
148+
-- Fail: Only one entry returned, silently dropping a required constraint.
149+
it("resolves multiple required mods to separate filter entries", function()
150+
local qg = makeQueryGen()
151+
local result = qg:ResolveRequiredModFilters({
152+
"Corrupted Blood cannot be inflicted on you",
153+
"+42 to maximum Life",
154+
})
155+
assert.are.equal(2, #result)
156+
assert.are.equal(CORRUPTED_BLOOD_ID, result[1].id)
157+
assert.are.equal(MAX_LIFE_ID, result[2].id)
158+
end)
159+
160+
-- Pass: Implicit mod resolves to implicit trade ID, not explicit.
161+
-- Fail: Returns explicit ID, causing trade API to search wrong mod category.
162+
it("resolves implicit mod to implicit trade ID, not explicit", function()
163+
local qg = new("TradeQueryGenerator", { itemsTab = { items = {} } })
164+
qg.modTradeIdByText = {
165+
Explicit = {
166+
[" to maximum Life"] = "explicit.stat_3299347025",
167+
},
168+
Implicit = {
169+
[" to maximum Life"] = "implicit.stat_3299347025",
170+
},
171+
}
172+
qg.requesterContext = {}
173+
local result = qg:ResolveRequiredModFilters({ "+42 to maximum Life" })
174+
assert.are.equal(1, #result)
175+
assert.are.equal("implicit.stat_3299347025", result[1].id)
176+
end)
177+
178+
-- Pass: Unknown mod is skipped; function returns empty without crashing.
179+
-- Fail: Crashes or raises an error, breaking the query flow entirely.
180+
it("skips mods with no trade mapping without crashing", function()
181+
local qg = makeQueryGen()
182+
local ok, result = pcall(function()
183+
return qg:ResolveRequiredModFilters({ "This mod does not exist in trade data" })
184+
end)
185+
assert.is_true(ok)
186+
assert.are.same({}, result)
187+
end)
188+
end)
189+
190+
describe("FinishQuery with pinnedModLines", function()
191+
-- Pass: No pinned mods => query has only the weighted stat group, no and-group.
192+
-- Fail: Spurious and-group inserted, altering previously working queries.
193+
it("generates query without required mods: no and-group added", function()
194+
local queryJson
195+
local originalClosePopup = main.ClosePopup
196+
main.ClosePopup = function() end
197+
198+
local qg = makeQueryGen()
199+
qg.requesterCallback = function(_, payload) queryJson = payload end
200+
qg.modWeights = { { tradeModId = "explicit.stat_1", weight = 10, meanStatDiff = 100 } }
201+
qg.calcContext = baseCalcContext(nil)
202+
qg.itemsTab.items[1] = equippedItem()
203+
204+
qg:FinishQuery()
205+
main.ClosePopup = originalClosePopup
206+
207+
local query = dkjson.decode(queryJson)
208+
assert.are.equal(1, #query.query.stats)
209+
assert.are.equal("weight", query.query.stats[1].type)
210+
end)
211+
212+
-- Pass: Pinned mod appears as an and-filter in the generated query.
213+
-- Fail: No and-group in query, meaning the required mod constraint is silently dropped.
214+
it("adds a required mod as an and-filter in the query", function()
215+
local queryJson
216+
local originalClosePopup = main.ClosePopup
217+
main.ClosePopup = function() end
218+
219+
local qg = makeQueryGen()
220+
qg.requesterCallback = function(_, payload) queryJson = payload end
221+
qg.modWeights = { { tradeModId = "explicit.stat_1", weight = 10, meanStatDiff = 100 } }
222+
qg.calcContext = baseCalcContext({ "Corrupted Blood cannot be inflicted on you" })
223+
qg.itemsTab.items[1] = equippedItem()
224+
225+
qg:FinishQuery()
226+
main.ClosePopup = originalClosePopup
227+
228+
local query = dkjson.decode(queryJson)
229+
assert.are.equal(2, #query.query.stats)
230+
local andGroup = query.query.stats[2]
231+
assert.are.equal("and", andGroup.type)
232+
assert.are.equal(1, #andGroup.filters)
233+
assert.are.equal(CORRUPTED_BLOOD_ID, andGroup.filters[1].id)
234+
end)
235+
236+
-- Pass: Corrupted Blood mod is enforced end-to-end in the trade query JSON.
237+
-- Fail: No and-group or wrong id, allowing trade results that lack the immunity.
238+
it("enforces 'Corrupted Blood cannot be inflicted on you' as a required filter", function()
239+
local queryJson
240+
local originalClosePopup = main.ClosePopup
241+
main.ClosePopup = function() end
242+
243+
local qg = makeQueryGen()
244+
qg.requesterCallback = function(_, payload) queryJson = payload end
245+
qg.modWeights = { { tradeModId = "explicit.stat_1", weight = 5, meanStatDiff = 50 } }
246+
qg.calcContext = baseCalcContext({ "Corrupted Blood cannot be inflicted on you" })
247+
qg.itemsTab.items[1] = equippedItem()
248+
249+
qg:FinishQuery()
250+
main.ClosePopup = originalClosePopup
251+
252+
local query = dkjson.decode(queryJson)
253+
local andGroup = nil
254+
for _, g in ipairs(query.query.stats) do
255+
if g.type == "and" then andGroup = g break end
256+
end
257+
assert.is_not_nil(andGroup, "Expected an and-group for the required mod")
258+
assert.are.equal(CORRUPTED_BLOOD_ID, andGroup.filters[1].id)
259+
end)
260+
261+
-- Pass: All pinned mods appear in the and-group.
262+
-- Fail: Only the first mod appears, losing subsequent constraints.
263+
it("adds multiple required mods to the and-group", function()
264+
local queryJson
265+
local originalClosePopup = main.ClosePopup
266+
main.ClosePopup = function() end
267+
268+
local qg = makeQueryGen()
269+
qg.requesterCallback = function(_, payload) queryJson = payload end
270+
qg.modWeights = { { tradeModId = "explicit.stat_1", weight = 10, meanStatDiff = 100 } }
271+
qg.calcContext = baseCalcContext({
272+
"Corrupted Blood cannot be inflicted on you",
273+
"+42 to maximum Life",
274+
})
275+
qg.itemsTab.items[1] = equippedItem()
276+
277+
qg:FinishQuery()
278+
main.ClosePopup = originalClosePopup
279+
280+
local query = dkjson.decode(queryJson)
281+
local andGroup = nil
282+
for _, g in ipairs(query.query.stats) do
283+
if g.type == "and" then andGroup = g break end
284+
end
285+
assert.is_not_nil(andGroup)
286+
assert.are.equal(2, #andGroup.filters)
287+
assert.are.equal(CORRUPTED_BLOOD_ID, andGroup.filters[1].id)
288+
assert.are.equal(MAX_LIFE_ID, andGroup.filters[2].id)
289+
end)
290+
291+
-- Pass: Unmappable mod is silently skipped; query still returns successfully.
292+
-- Fail: FinishQuery crashes or propagates an error, breaking the entire search.
293+
it("skips unmappable required mods without crashing or corrupting the query", function()
294+
local queryJson
295+
local errMsg
296+
local originalClosePopup = main.ClosePopup
297+
main.ClosePopup = function() end
298+
299+
local qg = makeQueryGen()
300+
qg.requesterCallback = function(_, payload, err)
301+
queryJson = payload
302+
errMsg = err
303+
end
304+
qg.modWeights = { { tradeModId = "explicit.stat_1", weight = 10, meanStatDiff = 100 } }
305+
qg.calcContext = baseCalcContext({ "This mod cannot be mapped to any trade stat" })
306+
qg.itemsTab.items[1] = equippedItem()
307+
308+
local ok = pcall(function() qg:FinishQuery() end)
309+
main.ClosePopup = originalClosePopup
310+
311+
assert.is_true(ok, "FinishQuery must not crash when a required mod has no trade mapping")
312+
assert.is_nil(errMsg, "Query should still succeed with the remaining weighted mods")
313+
local query = dkjson.decode(queryJson)
314+
-- No and-group since the only pinned mod was unmappable
315+
assert.are.equal(1, #query.query.stats)
316+
end)
317+
end)
318+
end)
60319
end)

0 commit comments

Comments
 (0)