Skip to content
262 changes: 262 additions & 0 deletions spec/System/TestRadiusJewelStatDiff_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,35 @@ local function setupAllocatedSocket()
return spec, socketNode
end

local function setupAllocatedSockets(count)
local spec = build.spec
local sockets = { }
local sortedSockets = { }
for _, node in pairs(spec.nodes) do
if node.isJewelSocket then
sortedSockets[#sortedSockets + 1] = node
end
end
table.sort(sortedSockets, function(a, b)
return a.id < b.id
end)
for _, socketNode in ipairs(sortedSockets) do
if allocatePathToNode(spec, socketNode) then
sockets[#sockets + 1] = socketNode
if #sockets >= count then
break
end
end
end
if #sockets < count then
pending("Could not allocate the requested number of jewel sockets for this tree layout")
return spec, sockets
end
spec:BuildAllDependsAndPaths()
runCallback("OnFrame")
return spec, sockets
end

local function rebuildBuild()
build.buildFlag = true
runCallback("OnFrame")
Expand Down Expand Up @@ -172,6 +201,20 @@ local function newPlainJewel()
"Implicits: 0\n")
end

local function newStatJewel(name, modifier)
return new("Item"):Item("Rarity: RARE\n" ..
name .. "\nCrimson Jewel\nImplicits: 0\n" .. modifier .. "\n")
end

local function newSplitPersonality()
return new("Item"):Item("Rarity: UNIQUE\n" ..
"Split Personality\n" ..
"Crimson Jewel\n" ..
"Implicits: 0\n" ..
"+5 to Strength\n" ..
"This Jewel's Socket has 25% increased effect per Allocated Passive Skill between it and your Class' starting location\n")
end

-- Helper: minimal Impossible Escape item. Uses "Radius: Small" and targets
-- a specific keystone. The parser populates both impossibleEscapeKeystone
-- and impossibleEscapeKeystones from the "in Radius of X" mod.
Expand Down Expand Up @@ -269,6 +312,16 @@ local function tooltipContainsNegativeStat(tooltip, label)
return false
end

local function tooltipContainsPositiveStat(tooltip, label)
for _, line in ipairs(tooltip.lines) do
local text = line.text or ""
if text:find(colorCodes.POSITIVE, 1, true) and text:find(label, 1, true) then
return true
end
end
return false
end

local function sortedNodeIds(nodeMap)
local nodeIds = { }
for nodeId in pairs(nodeMap or { }) do
Expand Down Expand Up @@ -310,6 +363,44 @@ local function findLeapOverlapCandidates(spec, radiusIndex)
return candidates
end

-- Busted supplies finally in the test's environment, so pass it from each caller.
local function trackJewelComparisons(slotOnly, finally)
local specClass = getmetatable(build.spec)
local originalMethods = { }
local originalCalculator = build.calcsTab.GetMiscCalculator
local originalSlotOnly = main.slotOnlyTooltips
local calls = { calcCalls = 0 }
finally(function()
for method, original in pairs(originalMethods) do
specClass[method] = original
end
build.calcsTab.GetMiscCalculator = originalCalculator
main.slotOnlyTooltips = originalSlotOnly
end)
main.slotOnlyTooltips = slotOnly
build.itemsTab.jewelComparisonOutputCache = nil
for method, counter in pairs({
BuildAllDependsAndPaths = "rebuilds",
BuildSplitPersonalityPath = "splitPersonalityPathCalls",
}) do
local original = specClass[method]
originalMethods[method] = original
calls[counter] = 0
specClass[method] = function(self, ...)
calls[counter] = calls[counter] + 1
return original(self, ...)
end
end
build.calcsTab.GetMiscCalculator = function(self, ...)
local calcFunc, calcBase = originalCalculator(self, ...)
return function(...)
calls.calcCalls = calls.calcCalls + 1
return calcFunc(...)
end, calcBase
end
return calls
end

describe("TestRadiusJewelStatDiff", function()
before_each(function()
newBuild()
Expand Down Expand Up @@ -600,4 +691,175 @@ describe("TestRadiusJewelStatDiff", function()
"tooltip should contain a 'Removing this item' comparison header")
end)

it("AddItemTooltip avoids rebuilding unused limited-unique socket comparisons without a target slot", function()
local spec, sockets = setupAllocatedSockets(2)

local item = newThreadOfHope()
item.limit = 1
equipJewelInSocket(item, sockets[1])
spec:BuildAllDependsAndPaths()
runCallback("OnFrame")

local calls = trackJewelComparisons(main.slotOnlyTooltips, finally)
local tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, item)

assert.are.equals(1, calls.rebuilds,
"limited unique radius jewels should rebuild only the same-unique slot that will be displayed")
end)

it("AddItemTooltip caches only stored slot-only radius jewel outputs until output changes", function()
local spec, socketNode = setupAllocatedSocket()

local item = newCustomLeapJewel("Cached Leap")
local slot = equipJewelInSocket(item, socketNode)
local lifeJewel = newStatJewel("Life Spark", "+37 to maximum Life")
local manaJewel = newStatJewel("Mana Spark", "+83 to maximum Mana")
build.itemsTab:AddItem(lifeJewel, true)
build.itemsTab:AddItem(manaJewel, true)
spec:BuildAllDependsAndPaths()
runCallback("OnFrame")

local calls = trackJewelComparisons(true, finally)
local tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, item, slot)
local firstPassTooltipText = tooltipText(tooltip)
local firstPassCalcCalls = calls.calcCalls
assert.is_true(firstPassCalcCalls > 0,
"slot-only radius jewel hover should calculate its output on first pass")
tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, item, slot)
assert.are.equals(firstPassTooltipText, tooltipText(tooltip),
"cached slot-only radius output should preserve tooltip content")
assert.are.equals(1, calls.rebuilds,
"slot-only radius jewel hover should reuse its cached comparison output")
assert.are.equals(firstPassCalcCalls, calls.calcCalls,
"slot-only radius jewel hover should not recalculate a cached output")

build.outputRevision = build.outputRevision + 1
tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, item, slot)
assert.are.equals(2, calls.rebuilds,
"slot-only radius jewel output cache should reset when output changes")
assert.is_true(calls.calcCalls > firstPassCalcCalls,
"slot-only radius jewel comparison should recalculate after output changes")

local beforeReplacementCalls = calls.calcCalls
for pass = 1, 2 do
for _, replacement in ipairs({ { lifeJewel, "Life", "Mana" }, { manaJewel, "Mana", "Life" } }) do
tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, replacement[1], slot)
-- Stat differences distinguish calculated outputs; raw item text does not.
assert.is_true(tooltipContainsPositiveStat(tooltip, replacement[2]))
assert.is_false(tooltipContainsPositiveStat(tooltip, replacement[3]), tooltipText(tooltip))
end
assert.are.equals(beforeReplacementCalls + 2, calls.calcCalls,
pass == 1 and "distinct replacements must calculate distinct outputs"
or "revisiting either replacement must reuse its own output")
end

-- Snapshot entries without deep-copying calculator output tables.
local cachedOutputs = copyTable(build.itemsTab.jewelComparisonOutputCache.outputs, true)

local transientJewel = newStatJewel("Transient Mana", "+83 to maximum Mana")
assert.is_nil(transientJewel.id)
for pass = 1, 4 do
-- A copied ID is still transient unless the items table owns this object.
if pass == 3 then
transientJewel.id = lifeJewel.id
end
local beforeCalcCalls = calls.calcCalls
tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, transientJewel, slot)
assert.is_true(tooltipContainsPositiveStat(tooltip, "Mana"))
assert.is_false(tooltipContainsPositiveStat(tooltip, "Life"))
assert.are.equals(beforeCalcCalls + 1, calls.calcCalls,
"each transient hover must calculate without reading or populating the cache")
assert.same(cachedOutputs, build.itemsTab.jewelComparisonOutputCache.outputs,
"transient comparisons must not change cached outputs")
end
end)

it("AddItemTooltip skips UI path rebuilds for temporary radius jewel specs", function()
local spec, sockets = setupAllocatedSockets(2)

local radiusItem = newThreadOfHope()
local radiusSlot = equipJewelInSocket(radiusItem, sockets[1])
local splitItem = newSplitPersonality()
equipJewelInSocket(splitItem, sockets[2])
spec:BuildAllDependsAndPaths()
runCallback("OnFrame")

assert.is_true((spec.nodes[sockets[2].id].distanceToClassStart or 0) > 0,
"Split Personality socket should have a class-start distance in the base spec")

local specClass = getmetatable(spec)
local originalBuildAllDependsAndPaths = specClass.BuildAllDependsAndPaths
local calls = trackJewelComparisons(true, finally)
local trackedRebuild = specClass.BuildAllDependsAndPaths
local calculationOnlySpec
specClass.BuildAllDependsAndPaths = function(self, calculationOnly, ...)
local result = trackedRebuild(self, calculationOnly, ...)
if calculationOnly then
calculationOnlySpec = self
end
return result
end

local tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, radiusItem, radiusSlot)
local calculationOnlyTooltipText = tooltipText(tooltip)
assert.is_truthy(calculationOnlySpec,
"temporary tooltip specs should use the calculation-only path")
for _, node in pairs(calculationOnlySpec.nodes) do
assert.is_nil(node.path, "calculation-only tooltip specs should not retain UI node paths")
assert.is_nil(node.pathDist, "calculation-only tooltip specs should not retain UI path distances")
end
assert.are.equals(0, calls.splitPersonalityPathCalls,
"calculation-only tooltip specs should not rebuild Split Personality highlight paths")

build.itemsTab.jewelComparisonOutputCache = nil
specClass.BuildAllDependsAndPaths = function(self)
return originalBuildAllDependsAndPaths(self)
end
local fullRebuildTooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(fullRebuildTooltip, radiusItem, radiusSlot)
assert.are.equals(calculationOnlyTooltipText, tooltipText(fullRebuildTooltip),
"calculation-only radius jewel specs should preserve full-rebuild tooltip output")
assert.is_true(calls.splitPersonalityPathCalls > 0,
"the full-rebuild comparison should exercise Split Personality highlight paths")
end)

it("AddItemTooltip reuses full radius jewel comparison outputs until output changes", function()
local spec, sockets = setupAllocatedSockets(2)

local item = newCustomLeapJewel("Cached Full Leap")
local slot = equipJewelInSocket(item, sockets[1])
spec:BuildAllDependsAndPaths()
runCallback("OnFrame")

local calls = trackJewelComparisons(false, finally)
local tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, item, slot)
local firstPassCalcCalls = calls.calcCalls
local firstPassTooltipText = tooltipText(tooltip)
assert.is_true(firstPassCalcCalls > 0,
"full radius jewel tooltip should calculate outputs on first pass")

tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, item, slot)
assert.are.equals(firstPassTooltipText, tooltipText(tooltip),
"cached radius outputs should preserve tooltip content")
local secondPassCalcCalls = calls.calcCalls - firstPassCalcCalls
assert.is_true(secondPassCalcCalls < firstPassCalcCalls,
"full radius jewel tooltip should reuse cached radius outputs on second pass")

build.outputRevision = build.outputRevision + 1
local beforeInvalidationCalcCalls = calls.calcCalls
tooltip = new("Tooltip"):Tooltip()
build.itemsTab:AddItemTooltip(tooltip, item, slot)
assert.is_true(calls.calcCalls - beforeInvalidationCalcCalls > secondPassCalcCalls,
"full radius jewel output cache should reset when output changes")
end)

end)
10 changes: 7 additions & 3 deletions src/Classes/CompareTab.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3801,6 +3801,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents)
local hoverX, hoverY = 0, 0
local hoverW, hoverH = 0, 0
local hoverItemsTab = nil
local hoverSlotName = nil

-- Track item copy button clicks
local clickedCopySlot = nil
Expand Down Expand Up @@ -3902,6 +3903,7 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents)
if rowHoverItem then
hoverItem = rowHoverItem
hoverItemsTab = rowHoverItemsTab
hoverSlotName = pHover and equipSlotName or cHover and copySlotName or nil
hoverX, hoverY = rowHoverX, rowHoverY
hoverW, hoverH = rowHoverW, rowHoverH
end
Expand Down Expand Up @@ -3984,16 +3986,18 @@ function CompareTabClass:DrawItems(vp, compareEntry, inputEvents)
SetViewport()
local maxTooltipWidth = m_min(600, m_max(260, vp.width - 24))
if not main.popups[1] and hoverItem and hoverItemsTab then
self.itemTooltip:Clear()
hoverItemsTab:AddItemTooltip(self.itemTooltip, hoverItem, nil, nil, maxTooltipWidth)
local hoverBuild = hoverItemsTab.build
if self.itemTooltip:CheckForUpdate(hoverItemsTab, hoverItem, hoverSlotName, maxTooltipWidth, main.slotOnlyTooltips, launch.devModeAlt, IsKeyDown("SHIFT"), hoverBuild and hoverBuild.outputRevision) then
hoverItemsTab:AddItemTooltip(self.itemTooltip, hoverItem, hoverSlotName, nil, maxTooltipWidth)
end
SetDrawLayer(nil, 100)
self.itemTooltip:Draw(vp.x + hoverX, vp.y + checkboxOffset + hoverY, hoverW, hoverH, vp)
SetDrawLayer(nil, 0)
end

-- Draw stat comparison tooltip when hovering Equip button
if not main.popups[1] and hoverEquipItem and hoverEquipSlotName and not hoverItem then
self.itemTooltip:Clear()
self.itemTooltip:Clear(true)
self.itemTooltip.maxWidth = maxTooltipWidth
local calcFunc, calcBase = self.calcs.getMiscCalculator(self.primaryBuild)
if calcFunc then
Expand Down
Loading
Loading