-- Run from the repository root with Lua 5.0: lua tests/threat.lua local now, party, raid, target, combat = 10, 1, 0, true, true local sent, refreshes, draws = {}, 0, 0 local visible = true function GetTime() return now end function GetNumPartyMembers() return party end function GetNumRaidMembers() return raid end function UnitExists() return target end function UnitIsPlayer() return nil end local dead = false function UnitIsDead() return dead end function UnitClassification() return 'elite' end function UnitAffectingCombat() return combat end function UnitName(unit) return unit == 'player' and 'Tester' or 'Boss' end function SendAddonMessage(prefix, text, channel) table.insert(sent, {prefix, text, channel}) end function CreateFrame() return {scripts={}, RegisterEvent=function() end, SetScript=function(self, name, fn) self.scripts[name] = fn end} end ShaguDPS = { config = {[1]={view=5,bars=8}}, data = {classes={}}, parser = {ScanName=function() end, callbacks={refresh={function() refreshes=refreshes+1 end}}}, window = {[1]={IsVisible=function() return visible end, Refresh=function() draws=draws+1 end}}, } dofile('threat.lua') local threat, data = ShaguDPS.threat, ShaguDPS.data local function update() this = threat threat.scripts.OnUpdate() end local function emit(kind, text) this, event, arg1, arg2 = threat, kind, 'SERVER', text threat.scripts.OnEvent() end update() assert(table.getn(sent) == 1 and sent[1][1] == 'TWT_UDTSv4' and sent[1][2] == 'limit=8' and sent[1][3] == 'PARTY') emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1000.5:100.0:1;Tester:0:500.25:50.0:1;#TMTv1=ignored') assert(data.threat.Tank and data.threat.Tank._sum == 1000.5, 'decimal threat must be parsed') assert(data.threat.Tester._perc == 50 and data.threat.Tester._melee) assert(data.threat['Pull Aggro']._sum == 1100) now = 12 emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1200:100:1;Bad:0:oops:10:1;') assert(data.threat.Tank._sum == 1000.5, 'malformed snapshots leave the previous data intact') emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1200:100:1;Tester:0:700:58:0;') assert(data.threat.Tester._tps == 99.875) assert(not data.threat.Bad) assert(data.threat['Pull Aggro']._sum == 1560) update() assert(table.getn(sent) == 1, 'recent external answers suppress duplicate queries') now = 13.5 update() assert(table.getn(sent) == 2, 'queries resume after external answers stop') emit('CHAT_MSG_ADDON', 'TWTv4=Tester:0:0:0:0;') assert(not data.threat.Tank and not data.threat['Pull Aggro']) emit('PLAYER_TARGET_CHANGED') assert(not next(data.threat)) assert(not threat.last_answer and threat.pending) assert(table.getn(sent) == 3, 'target changes query immediately') now, party = 15, 0 update() assert(table.getn(sent) == 3, 'no queries while solo') now, party, combat = 16, 1, false update() assert(table.getn(sent) == 3, 'no queries out of combat') now, combat, visible = 17, true, false update() assert(table.getn(sent) == 3, 'no queries with hidden threat windows') now, visible, raid = 18, true, 10 update() assert(sent[4][3] == 'RAID') assert(draws > 0 and refreshes == 0, 'threat does not refresh damage/heal callbacks') now = 22 update() assert(string.find(threat:GetStatus(), 'No threat reply', 1, true)) party, raid = 0, 0 assert(string.find(threat:GetStatus(), 'Join a party', 1, true)) party, target = 1, false assert(string.find(threat:GetStatus(), 'Select a living', 1, true)) target, combat = true, false assert(string.find(threat:GetStatus(), 'enter combat', 1, true)) combat = true emit('PLAYER_TARGET_CHANGED') assert(string.find(threat:GetStatus(), 'Waiting for threat data', 1, true)) emit('CHAT_MSG_ADDON', 'TWTv4=') assert(string.find(threat:GetStatus(), 'Waiting for threat data', 1, true), 'previous target reply discarded') now = 23 update() emit('CHAT_MSG_ADDON', 'TWTv4=') assert(string.find(threat:GetStatus(), 'no threat entries', 1, true)) -- Poll at 2 Hz, without waiting for the normal window refresh timer. emit('PLAYER_TARGET_CHANGED') emit('CHAT_MSG_ADDON', 'TWTv4=') now = 30 update() emit('CHAT_MSG_ADDON', 'TWTv4=') local requests, previous_draws = table.getn(sent), draws now = 30.21 update() assert(table.getn(sent) == requests, 'polls must be rate limited') now = 30.51 update() assert(table.getn(sent) == requests + 1, 'poll again after 0.5 seconds') emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:100:100:1;') assert(draws > previous_draws, 'visible threat window refreshes immediately') assert(data.threat.Tester._tps == 0, 'first sample has no elapsed time') now = 30.71 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:120:100:1;') assert(math.abs(data.threat.Tester._tps - 100) < 0.001, 'TPS updates on the next sample') now = 30.91 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:110:100:1;') assert(data.threat.Tester._tps == 0, 'threat reduction resets TPS baseline') now = 31.11 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:130:100:1;') assert(math.abs(data.threat.Tester._tps - 100) < 0.001) emit('CHAT_MSG_ADDON', 'TWTv4=') now = 31.3 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:200:100:1;') assert(data.threat.Tester._tps == 0, 'returning players start a fresh sample') visible = false previous_draws = draws emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:210:100:1;') assert(draws == previous_draws, 'hidden windows are not redrawn') -- Recent replies from another addon must not suppress a new target query. visible = true now = 40 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') local before_switch = table.getn(sent) emit('PLAYER_TARGET_CHANGED') assert(table.getn(sent) == before_switch + 1) assert(not next(data.threat), 'clear previous target data immediately') update() assert(table.getn(sent) == before_switch + 1, 'no duplicate poll in the same frame') visible = false emit('PLAYER_TARGET_CHANGED') assert(table.getn(sent) == before_switch + 1, 'hidden windows do not query on target change') visible, target = true, false emit('PLAYER_TARGET_CHANGED') assert(table.getn(sent) == before_switch + 1, 'no query when target is lost') local messages = {} DEFAULT_CHAT_FRAME = { AddMessage=function(self, text) table.insert(messages, text) end } now, visible, target = 50, true, true threat.last_foreign = nil threat:Measure() for sample = 0, 19 do now = 50 + sample * 0.5 update() emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') end assert(table.getn(messages) == 1, 'diagnostic does not spam each packet') now = 60 update() assert(table.getn(messages) == 2) assert(string.find(messages[2], '20 requests (2.00/s)', 1, true)) assert(string.find(messages[2], '20 replies (2.00/s)', 1, true)) assert(string.find(messages[2], '0 changed replies', 1, true)) now = 61 update() emit('CHAT_MSG_ADDON', 'TWTv4=') assert(table.getn(messages) == 2, 'measurement stops automatically') -- A constant 100 TPS stream remains stable across rolling-window boundaries. now = 70 emit('PLAYER_TARGET_CHANGED') for i = 0, 16 do now = 70 + i * 0.5 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:' .. (100 + i * 50) .. ':100:1;') if i > 0 then assert(math.abs(data.threat.Tester._tps - 100) < 0.001) end end -- Repeated identical totals age the old gains out of the five-second window. for i = 1, 10 do now = 78 + i * 0.5 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:900:100:1;') end assert(data.threat.Tester._tps == 0) now = 83.5 emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1000:100:1;') assert(data.threat['Pull Aggro']._minimum == 1100) assert(data.threat['Pull Aggro']._sum == 1300) assert(data.threat['Pull Aggro']._perc == 100) now = 84 emit('CHAT_MSG_ADDON', 'TWTv4=Tank:1:1000:100:1;Tester:0:100:10:0;') assert(not data.threat['Pull Aggro']._minimum and data.threat['Pull Aggro']._perc == 100) now = 87.1 update() assert(not next(data.threat), 'expire old bars after three seconds without replies') assert(string.find(threat:GetStatus(), 'stopped arriving', 1, true)) now = 87.2 emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:200:100:1;') assert(data.threat.Tester._tps == 0 and not threat.stale, 'fresh reply recovers from stale state') now, dead = 88, true update() assert(not next(data.threat), 'clear bars on target death') emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') assert(not next(data.threat), 'ignore late reply for a dead target') dead = false emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') now, combat = 89, false update() assert(not next(data.threat), 'clear bars on combat end') combat = true emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:300:100:1;') now, party, raid = 90, 0, 0 update() assert(not next(data.threat), 'clear bars on leaving the group') assert(refreshes == 0, 'damage/heal callbacks remain untouched') -- Same-name mobs remain distinct when client GUIDs are available. party, raid, target, combat, dead = 1, 0, true, true, false local targetGuid = '0xA' UnitGUID = function() return targetGuid end now = 100 emit('PLAYER_TARGET_CHANGED') targetGuid = '0xB' local before_guard = table.getn(sent) emit('PLAYER_TARGET_CHANGED') assert(table.getn(sent) == before_guard and not threat.pending) emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:111:100:1;') assert(not next(data.threat), 'old same-name target reply must not populate new target') now = 100.8 update() assert(threat.pending and table.getn(sent) == before_guard + 1) emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:222:100:1;') assert(data.threat.Tester._sum == 222) -- An expired pending request must not permanently block fresh replies. now = 103 update() emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:333:100:1;') assert(data.threat.Tester._sum == 333) -- Captured in-game examples: keep server percentages, never normalize twice. now = 105 emit('CHAT_MSG_ADDON', 'TWTv4=Francin:1:2034:100:1;Tester:0:516:19.5:0;') assert(data.threat.Tester._perc == 19.5 and data.threat.Francin._perc == 100) assert(data.threat['Pull Aggro']._sum == math.floor(2034 * 1.3)) assert(data.threat['Pull Aggro']._perc == 100) now = 106 emit('CHAT_MSG_ADDON', 'TWTv4=Francin:1:2614:100:1;Tester:0:486:16.9:1;') assert(data.threat.Tester._perc == 16.9) assert(data.threat['Pull Aggro']._sum == math.floor(2614 * 1.1)) assert(data.threat['Pull Aggro']._perc == 100) -- One outstanding poll, with retry delay and late-reply quarantine. now = 110 emit('PLAYER_TARGET_CHANGED') local first_poll = table.getn(sent) now = 110.5 update() assert(table.getn(sent) == first_poll and threat.pending) now = 111.5 update() assert(not threat.pending and table.getn(sent) == first_poll) emit('CHAT_MSG_ADDON', 'TWTv4=Tester:1:999:100:1;') assert(not next(data.threat)) now = 112 update() assert(table.getn(sent) == first_poll, 'retry waits after timeout') now = 112.5 update() assert(table.getn(sent) == first_poll + 1) now = 114 update() now = 115 update() assert(table.getn(sent) == first_poll + 1, 'consecutive failures increase the delay') now = 116 update() assert(table.getn(sent) == first_poll + 2) -- Warning only fires on usable local replies, rearms below the threshold, -- and never warns the player already holding aggro. local warnings = 0 UIErrorsFrame = {AddMessage=function() warnings = warnings + 1 end} ShaguDPS.config.threat_warning = 3 -- 90% local function reply(perc, tank) emit('CHAT_MSG_ADDON', 'TWTv4=Tester:' .. (tank or 0) .. ':900:' .. perc .. ':1;') end reply(90) assert(warnings == 1) local function pollReply(perc, tank) now = now + .5 update() assert(threat.pending) reply(perc, tank) end pollReply(91) pollReply(89) pollReply(92) assert(warnings == 1, 'no repeat from small fluctuations') pollReply(84) pollReply(92) assert(warnings == 2) pollReply(100, 1) assert(warnings == 2, 'no warning for the aggro holder') reply(95) assert(warnings == 2, 'unmatched reply does not warn') now = now + .5 emit('PLAYER_TARGET_CHANGED') reply(95) assert(warnings == 3, 'target change resets the warning') ShaguDPS.config.threat_warning = 0 pollReply(99) assert(warnings == 3, 'warning can be disabled') print('Threat regression checks passed (Lua 5.0)')