@@ -57,4 +57,186 @@ describe("TradeQueryGenerator", function()
5757 _G .MAX_FILTERS = orig_max
5858 end )
5959 end )
60+
61+ describe (" Catalyst de-augmentation" , function ()
62+ -- The formula used in FinishQuery to strip catalyst quality from mod values before
63+ -- setting required minimums: floor(value / ((100 + quality) / 100) + 0.5)
64+
65+ -- Pass: Correctly reverses a 20% catalyst boost on a round value
66+ -- Fail: Wrong result means required minimums would be too strict (filtered value still includes catalyst bonus)
67+ it (" reverses 20% quality boost on round value" , function ()
68+ -- 60 life boosted by 20% catalyst -> 72; de-augmenting 72 should give 60
69+ local boosted = math.floor (60 * 1.2 ) -- = 72
70+ local deaugmented = math.floor (boosted / ((100 + 20 ) / 100 ) + 0.5 )
71+ assert .are .equal (60 , deaugmented )
72+ end )
73+
74+ -- Pass: Rounds to nearest integer, avoiding over-filtering on non-round base values
75+ -- Fail: Truncation instead of rounding would produce 59 here, filtering out valid items
76+ it (" rounds to nearest integer (not truncates)" , function ()
77+ -- base = 53, boosted by 12% = floor(53 * 1.12) = 59; de-augmenting 59 should give 53
78+ local boosted = math.floor (53 * 1.12 ) -- = 59
79+ local deaugmented = math.floor (boosted / ((100 + 12 ) / 100 ) + 0.5 )
80+ assert .are .equal (53 , deaugmented )
81+ end )
82+
83+ -- Pass: 0% quality is a no-op — de-augmented value equals original
84+ -- Fail: Any deviation would indicate a formula error for non-catalysed items
85+ it (" leaves value unchanged at 0 quality" , function ()
86+ local value = 75
87+ local deaugmented = math.floor (value / ((100 + 0 ) / 100 ) + 0.5 )
88+ assert .are .equal (75 , deaugmented )
89+ end )
90+
91+ -- Pass: Handles the maximum catalyst quality (20%) without overflow or precision loss
92+ -- Fail: Floating-point precision error would cause off-by-one on values near rounding boundary
93+ it (" handles max catalyst quality (20%)" , function ()
94+ -- base = 100, boosted = 120; de-augment should return 100
95+ local boosted = math.floor (100 * 1.2 ) -- = 120
96+ local deaugmented = math.floor (boosted / ((100 + 20 ) / 100 ) + 0.5 )
97+ assert .are .equal (100 , deaugmented )
98+ end )
99+ end )
100+
101+ describe (" Require current mods" , function ()
102+ -- Pass: Crafted mods do not appear in requiredModFilters (users re-craft them)
103+ -- Fail: Crafted mods included would over-constrain the query, hiding items the user could craft onto
104+ it (" skips crafted mod lines" , function ()
105+ local crafted = { line = " +50 to maximum Life" , crafted = true }
106+ local normal = { line = " +50 to maximum Life" , crafted = false }
107+ -- Simulates the 'if not modLine.crafted' guard inside addModLines
108+ local function isCraftedSkipped (modLine )
109+ return modLine .crafted == true
110+ end
111+ assert .is_true (isCraftedSkipped (crafted ))
112+ assert .is_false (isCraftedSkipped (normal ))
113+ end )
114+ end )
115+
116+ -- -------------------------------------------------------------------------
117+ -- TDD tests for crafted-slot filter feature (not yet implemented)
118+ -- These tests define the contract for two new methods:
119+ -- CountCraftedAffixes(prefixes, suffixes, affixes) -> {prefix=N, suffix=M}
120+ -- BuildCraftedSlotFilters(prefixCount, suffixCount) -> array of count-type stat groups
121+ -- -------------------------------------------------------------------------
122+
123+ describe (" CountCraftedAffixes" , function ()
124+ -- Crafted mods in item.affixes have a 'types' table instead of weightKey/weightVal.
125+ -- Regular mods use weightKey/weightVal and have no 'types' field.
126+
127+ -- Pass: No crafted mods means both counts are 0
128+ -- Fail: Any non-zero result means we are incorrectly treating regular mods as crafted,
129+ -- which would add spurious slot-availability filters to the trade query
130+ it (" returns zero counts when no crafted mods are present" , function ()
131+ local prefixes = { { modId = " Strength1" } }
132+ local suffixes = { { modId = " ColdResist1" } }
133+ local affixes = {
134+ Strength1 = { type = " Suffix" , weightKey = { " ring" }, weightVal = { 1000 } },
135+ ColdResist1 = { type = " Suffix" , weightKey = { " ring" }, weightVal = { 1000 } },
136+ }
137+ local result = mock_queryGen :CountCraftedAffixes (prefixes , suffixes , affixes )
138+ assert .are .equal (0 , result .prefix )
139+ assert .are .equal (0 , result .suffix )
140+ end )
141+
142+ -- Pass: 'types' field (not weightKey) marks a crafted prefix; count = 1
143+ -- Fail: Count stays 0 means crafted mods are not identified, so the slot filter is never emitted
144+ it (" counts a crafted prefix correctly" , function ()
145+ local prefixes = { { modId = " CraftedLife1" } }
146+ local suffixes = {}
147+ local affixes = {
148+ CraftedLife1 = { type = " Prefix" , types = { str_armour = true } },
149+ }
150+ local result = mock_queryGen :CountCraftedAffixes (prefixes , suffixes , affixes )
151+ assert .are .equal (1 , result .prefix )
152+ assert .are .equal (0 , result .suffix )
153+ end )
154+
155+ -- Pass: Crafted suffix identified; prefix count unaffected
156+ -- Fail: suffix count 0 means suffix slot filters are never added for crafted suffixes
157+ it (" counts a crafted suffix correctly" , function ()
158+ local prefixes = {}
159+ local suffixes = { { modId = " CraftedMana1" } }
160+ local affixes = {
161+ CraftedMana1 = { type = " Suffix" , types = { str_armour = true } },
162+ }
163+ local result = mock_queryGen :CountCraftedAffixes (prefixes , suffixes , affixes )
164+ assert .are .equal (0 , result .prefix )
165+ assert .are .equal (1 , result .suffix )
166+ end )
167+
168+ -- Pass: Mixed item with crafted prefix + regular suffix → prefix=1, suffix=0
169+ -- Fail: Counting regular mod as crafted would emit a spurious suffix slot filter
170+ it (" ignores regular mods alongside crafted mods" , function ()
171+ local prefixes = { { modId = " CraftedLife1" } }
172+ local suffixes = { { modId = " ColdResist1" } }
173+ local affixes = {
174+ CraftedLife1 = { type = " Prefix" , types = { str_armour = true } },
175+ ColdResist1 = { type = " Suffix" , weightKey = { " ring" }, weightVal = { 1000 } },
176+ }
177+ local result = mock_queryGen :CountCraftedAffixes (prefixes , suffixes , affixes )
178+ assert .are .equal (1 , result .prefix )
179+ assert .are .equal (0 , result .suffix )
180+ end )
181+
182+ -- Pass: "None" and missing affix entries are handled without error
183+ -- Fail: nil access crash when modId = "None" or affixes table has no entry
184+ it (" handles None and missing affix entries without error" , function ()
185+ local prefixes = { { modId = " None" }, { modId = " MissingMod" } }
186+ local suffixes = {}
187+ local affixes = {}
188+ local result = mock_queryGen :CountCraftedAffixes (prefixes , suffixes , affixes )
189+ assert .are .equal (0 , result .prefix )
190+ assert .are .equal (0 , result .suffix )
191+ end )
192+ end )
193+
194+ describe (" BuildCraftedSlotFilters" , function ()
195+ -- Each crafted prefix/suffix requires one "count" stat group in the trade query
196+ -- containing BOTH the empty-slot and crafted-slot pseudo stat IDs.
197+ -- This allows matching items that have either an empty slot OR an existing crafted slot.
198+
199+ -- Pass: No crafted mods → no filters (no slot constraint added to query)
200+ -- Fail: Non-empty result would add unnecessary stat groups, wasting filter slots
201+ it (" returns empty table when both counts are zero" , function ()
202+ local filters = mock_queryGen :BuildCraftedSlotFilters (0 , 0 )
203+ assert .are .equal (0 , # filters )
204+ end )
205+
206+ -- Pass: One crafted prefix → one count group for prefix slot availability
207+ -- Fail: No filter = buyer might not be able to re-craft; wrong type = API rejects query
208+ it (" emits one count-type stat group for one crafted prefix" , function ()
209+ local filters = mock_queryGen :BuildCraftedSlotFilters (1 , 0 )
210+ assert .are .equal (1 , # filters )
211+ assert .are .equal (" count" , filters [1 ].type )
212+ assert .are .equal (1 , filters [1 ].value .min )
213+ -- Group must contain both the empty-prefix pseudo stat and the crafted-prefix pseudo stat
214+ assert .are .equal (2 , # filters [1 ].filters )
215+ end )
216+
217+ -- Pass: One crafted suffix → one count group for suffix slot availability
218+ -- Fail: Wrong stat IDs (prefix instead of suffix) = search returns wrong items
219+ it (" emits one count-type stat group for one crafted suffix" , function ()
220+ local filters = mock_queryGen :BuildCraftedSlotFilters (0 , 1 )
221+ assert .are .equal (1 , # filters )
222+ assert .are .equal (" count" , filters [1 ].type )
223+ assert .are .equal (1 , filters [1 ].value .min )
224+ assert .are .equal (2 , # filters [1 ].filters )
225+ end )
226+
227+ -- Pass: One crafted prefix + one crafted suffix → two separate count groups
228+ -- Fail: Only one group = suffix or prefix slot not required by search
229+ it (" emits two count groups when both prefix and suffix are crafted" , function ()
230+ local filters = mock_queryGen :BuildCraftedSlotFilters (1 , 1 )
231+ assert .are .equal (2 , # filters )
232+ end )
233+
234+ -- Pass: Two crafted prefixes → min = 2 in the prefix count group
235+ -- Fail: min = 1 = buyer might only have 1 slot, missing coverage for 2 crafted prefixes
236+ it (" sets min to the crafted count (not always 1)" , function ()
237+ local filters = mock_queryGen :BuildCraftedSlotFilters (2 , 0 )
238+ assert .are .equal (1 , # filters )
239+ assert .are .equal (2 , filters [1 ].value .min )
240+ end )
241+ end )
60242end )
0 commit comments