Repository navigation
Expand file tree
/
Copy pathtest_core.lua
More file actions
8172 lines (7781 loc) · 406 KB
/
Copy pathtest_core.lua
File metadata and controls
8172 lines (7781 loc) · 406 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- Standalone check for Core.lua: lua test_core.lua
--
-- Core talks to the game only through a handful of functions, so stubbing those
-- lets the whole event machine run outside WoW. Both review passes found their
-- worst bugs here (a countdown expiring threw every frame, a kicked cast never
-- resolved), and neither was reachable by the data-only test.
local failures = 0
local function check(ok, msg)
if not ok then
failures = failures + 1
print("FAIL: " .. msg)
end
end
-- WoW stubs ----------------------------------------------------------------
local now = 1000
GetTime = function() return now end
MouseIsDown = true
IsMouseButtonDown = function() return MouseIsDown end
local combat, hostile, dead = {}, {}, {}
UnitAffectingCombat = function(unit) return combat[unit] == true end
UnitExists = function(unit) return hostile[unit] ~= nil end
UnitCanAttack = function(_, unit) return hostile[unit] == true end
UnitIsDead = function(unit) return dead[unit] == true end
local levels = {}
UnitLevel = function(unit) return levels[unit] or 90 end
UnitClassification = function() return "elite" end
UnitPowerType = function() return 0 end
UnitIsLieutenant = function() return false end
UnitCreatureFamily = function() return nil end
-- The player's harmful auras, as C_UnitAuras hands them out by index. In 12.1
-- UNIT_AURA's updateInfo is Secret in instances, so Core enumerates instead.
local playerAuras = {}
C_UnitAuras = {
GetAuraDataByIndex = function(unit, index, filter)
if unit ~= "player" or filter ~= "HARMFUL" then return nil end
return playerAuras[index]
end,
}
IsInInstance = function() return true end
GetInstanceInfo = function() return "d", "party", 0, "", 0, 0, false, 1877 end
local plateShown = true
local elsewhere = { GetParent = function() return nil end }
-- Platynator hands Blizzard's UnitFrame to a hidden frame, so it no longer
-- belongs to the plate and anchoring to it drops the icon wherever that frame
-- happens to sit.
local strayUnitFrame = {
GetObjectType = function() return "Frame" end,
GetParent = function() return elsewhere end,
}
local lastAnchor
C_NamePlate = { GetNamePlateForUnit = function(unit)
if not hostile[unit] then return nil end
return {
unit = unit,
IsShown = function() return plateShown end,
GetAlpha = function() return plateShown and 1 or 0 end,
GetObjectType = function() return "Frame" end,
GetParent = function() return nil end,
UnitFrame = strayUnitFrame,
}
end }
-- The real API returns nothing for spells the client has not cached, which is
-- why every icon came out as the same placeholder.
-- Deliberately empty: enemy spell data is typically not cached client-side.
local cachedSpells = {}
local textures = { [100] = 1001, [101] = 1002, [200] = 1003, [300] = 1004, [610] = 1061, [900] = 1009, [950] = 1010, [960] = 1011 }
local requested = {}
C_Spell = {
-- Modelled on the real thing: enemy spells are usually NOT cached, so this
-- returns nothing and only GetSpellTexture answers. Relying on GetSpellInfo
-- alone is what made every mob show the same placeholder icon.
GetSpellInfo = function(id)
if not cachedSpells[id] then return nil end
return { name = "Spell" .. id, iconID = cachedSpells[id] }
end,
GetSpellTexture = function(id) return textures[id] end,
RequestLoadSpellData = function(id) requested[id] = true end,
}
wipe = function(t) for k in pairs(t) do t[k] = nil end return t end
-- Present in game, absent from the earlier stub: the voice path has to be
-- exercised, not skipped.
Enum = { CombatAudioAlertCategory = { TargetCast = 4 } }
local spoken, sounds = 0, 0
local allFrames = {}
-- The real SpeakText returns an utteranceID when it actually speaks and
-- nothing when alerts are disabled; Core reads that to decide on the beep
-- fallback, so the stub must return one.
C_CombatAudioAlert = { SpeakText = function() spoken = spoken + 1 return 1 end }
-- Our own clips come first; PlaySoundFile answers willPlay like the real one.
local clips, clipsPlayable = 0, true
PlaySoundFile = function() clips = clips + 1 return clipsPlayable, 1 end
-- Alerts come out as a clip, as speech, or as a beep, so the tests count
-- whichever the addon chose.
local function Alerts() return sounds + spoken + clips end
-- How many icons are on screen right now, regardless of whether they carry a
-- countdown.
-- Nameplate bars, told apart from the centre-call frames by the glow texture
-- only a bar has. The frame stub answers unknown fields with a function, so a
-- type check is the discriminator.
local function IsBar(f)
return type(f.icon) == "table" and type(f.glow) == "table"
end
local function VisibleBars()
local n = 0
for i = 1, #allFrames do
if allFrames[i].shown and IsBar(allFrames[i]) then n = n + 1 end
end
return n
end
-- Blizzard's encounter timeline, recorded so the tests can see what we sent it.
local timeline = { added = 0, cancelled = 0, finished = 0, cancelAll = 0, last = nil, live = {} }
local nextEventID = 0
C_EncounterTimeline = {
IsFeatureEnabled = function() return true end,
AddScriptEvent = function(request)
nextEventID = nextEventID + 1
timeline.added = timeline.added + 1
timeline.last = request
timeline.live[nextEventID] = true
return nextEventID
end,
CancelScriptEvent = function(id)
timeline.cancelled = timeline.cancelled + 1
timeline.live[id] = nil
end,
FinishScriptEvent = function(id)
timeline.finished = timeline.finished + 1
timeline.live[id] = nil
end,
CancelAllScriptEvents = function() timeline.cancelAll = timeline.cancelAll + 1 end,
}
bit = { bor = function(a, b) return (a or 0) + (b or 0) end }
Enum.EncounterEventIconmask = { TankRole = 128, HealerRole = 256, DpsRole = 512, DeadlyEffect = 1, MagicEffect = 8 }
Enum.EncounterEventSeverity = { Low = 0, Medium = 1, High = 2 }
SOUNDKIT = { RAID_WARNING = 1, ALARM_CLOCK_WARNING_3 = 2, ALARM_CLOCK_WARNING_2 = 3,
ALARM_CLOCK_WARNING_1 = 4 }
PlaySound = function() sounds = sounds + 1 end
local framesMade, shownCount = 0, 0
local function StubRegion()
-- `textureValue` is a real field: the catch-all __index below hands back a
-- fresh closure for anything unknown, so a test reading an unset field would
-- always see two "different" values and could never fail.
local r = { textureValue = false, shown = false }
r.SetTexture = function(_, tex) r.textureValue = tex end
r.Show = function() r.shown = true end
r.Hide = function() r.shown = false end
r.SetShown = function(_, on) r.shown = on and true or false end
r.IsShown = function() return r.shown end
r.SetAllPoints = function(_, target) r.allPointsTo = target end
r.GetTexture = function() return r.textureValue end
setmetatable(r, { __index = function() return function() return r end end })
return r
end
local eventHandler, updateHandler
CreateFrame = function(kind, _, parent)
local f = { shown = false, texts = {}, kind = kind, handlers = {}, parent = parent,
parentStrata = parent and parent.strata }
allFrames[#allFrames + 1] = f
framesMade = framesMade + 1
f.Show = function(self)
if not self.shown then shownCount = shownCount + 1 end
self.shown = true
end
f.Hide = function(self) self.shown = false end
f.SetShown = function(self, on)
if on then self:Show() else self:Hide() end
end
f.IsShown = function(self) return self.shown end
f.SetScript = function(_, script, fn)
f.handlers[script] = fn
-- Core registers more than one event frame (the main one, plus a small
-- frame that saves diagnostics on logout). Only the first owns the
-- event flow; keeping just the last one silently redirected everything.
if script == "OnEvent" then
eventHandler = eventHandler or fn
elseif script == "OnUpdate" then
updateHandler = updateHandler or fn
end
end
if kind == "EditBox" then
f.SetText = function(self, text) self.value = text end
f.GetText = function(self) return self.value end
elseif kind == "Button" then
f.SetText = function(self, text) rawset(self, "buttonText", text) end
end
f.CreateTexture = StubRegion
f.CreateFontString = function()
local fs = StubRegion()
f.fontStrings = f.fontStrings or {}
table.insert(f.fontStrings, fs)
-- Record into separate fields: Core assigns the font strings to
-- bar.text / bar.time, so writing there would overwrite them.
fs.SetText = function(self, text) f.labelValue, self.value = text, text end
fs.GetText = function(self) return self.value end
fs.SetFormattedText = function(_, fmt, ...) f.timeValue = string.format(fmt, ...) end
-- The layout steps the icons sideways by the width of the advice under
-- them, so a stub that answers with a table (the catch-all below) turns
-- into an arithmetic error rather than a wrong number.
fs.GetStringWidth = function()
-- The widest line, the way the real font string measures it.
local widest = 0
for line in tostring(f.labelValue or ""):gmatch("[^\n]+") do
widest = math.max(widest, #line * 6)
end
return widest
end
-- The layout also stacks icons by the height of the advice, so a
-- verdict written over two lines does not overlap its neighbour.
fs.GetStringHeight = function()
local lines = 0
for _ in tostring(f.labelValue or ""):gmatch("[^\n]+") do lines = lines + 1 end
return math.max(lines, 1) * 11
end
return fs
end
f.GetParent = function(self) return self.parent end
f.SetPoint = function(self, ...)
local _, anchor, _, x, y = ...
lastAnchor = anchor
self.x, self.y = x, y -- offsets, for the layout cases
self.points = { ... }
end
f.GetPoint = function(self) return unpack(self.points or {}) end
f.SetTexture = function(self, tex) self.texture = tex end
f.SetParent = function(self, p) self.parent = p end
f.SetFrameStrata = function(self, strata) self.strata = strata end
f.GetFrameStrata = function(self) return self.strata end
f.EnableMouse = function(self, on) self.mouse = on end
f.EnableMouseWheel = function(self, on) self.wheel = on end
f.SetFrameLevel = function(self, level) self.level = level end
f.GetFrameLevel = function(self) return self.level end
local barOnly = {
SetValue = true, SetStatusBarColor = true,
SetMinMaxValues = true, SetStatusBarTexture = true,
}
setmetatable(f, { __index = function(_, key)
-- A plain Frame has no StatusBar methods. The old stub answered every
-- call, so leftover bar calls after the icon rework went unnoticed.
if barOnly[key] and kind ~= "StatusBar" then
return nil
end
if type(key) == "string" and key:match("^%l") then return nil end
return function() end
end })
return f
end
-- Data the addon will match against.
CastAheadData = {
[1877] = { name = "Test",
{ spell = 100, npc = 1, mob = "Caster", name = "Big", cast = 3.0, cd = { 20.0 },
first = 5.0, offset = 0.0, hits = 5, dmg = 0.5, kick = 0, cc = 0.05 },
-- Same creature as spell 100: identifying one must lay this one out too.
{ spell = 101, npc = 1, mob = "Caster", name = "Second", cast = 4.5, cd = { 18.0 },
first = 12.0, offset = 9.0, hits = 4, dmg = 0.35, kick = 0, cc = 0 },
{ spell = 200, npc = 2, mob = "Filler", name = "Bolt", cast = 2.5, cd = { 3.6 },
first = 4.0, hits = 1, dmg = 0.3, kick = 0.5, cc = 0.2, filler = true },
-- firstN matters: the neighbour-lock cases below tie this row against
-- 950, and a row without opening samples loses that tie to any rival
-- inside FIRST_TOLERANCE before the lock ever gets a say.
{ spell = 300, npc = 3, mob = "Cycler", name = "Cycle", cast = 1.5, cd = { 4.8, 4.8, 8.7 },
first = 3.0, firstN = 5, hits = 3, dmg = 0.25, kick = 0, cc = 0.05 },
-- Statistically dangerous (5 targets, half their health) but NOT in the
-- curated set: the "important only" filter must hide it anyway.
{ spell = 700, npc = 7, mob = "Unmarked", name = "Loud", cast = 7.0, cd = { 25.0 },
first = 6.0, hits = 5, dmg = 0.5, kick = 0, cc = 0 },
-- A channel, 6.0s long, curated TANK. It is the only 6.0s length in the
-- base fixture on purpose; the collision case below hands npc 11 a 6.0s
-- cast as well, to prove the two kinds stay apart. It gets a creature of
-- its own (npc 11) so that projecting it as a sibling never reorders the
-- icons the npc 1 cases above measure.
{ spell = 610, npc = 11, mob = "Drainer", name = "Drain", cast = 6.0, cd = { 22.0 },
first = 8.0, firstN = 5, hits = 1, dmg = 0.3, kick = 0, cc = 0, channel = true },
},
}
-- The curated priority set and provisional extras, normally Priority.lua.
-- Spell 600 is deliberately absent.
CastAheadPriority = { [100] = "AOE", [101] = "AOE", [200] = "KICK", [300] = "DODGE", [500] = "TANK", [610] = "TANK" }
-- Creature traits, normally Traits.lua. The stubs report level 90, power 0,
-- "elite", not a lieutenant, no creature family. Nothing is listed at level
-- 90 on purpose: the cast-length tests above and below must run without an
-- identity, as they do for creatures the table does not know.
-- 91 -> npc 1 alone (narrows; locks once a 3.0s or 4.5s cast confirms it)
-- 93 -> npc 2 alone; 92 -> npc 3 or npc 4, where 3 is listed as npc 2's
-- packmate, so a locked npc 2 nearby narrows a level-92 plate to 3
CastAheadTraits = {
[1877] = {
{ npc = 1, level = 91, power = 0, elite = true, lieutenant = false, family = false, casts = { 3, 4.5 } },
{ npc = 2, level = 93, power = 0, elite = true, casts = { 2.5 } },
{ npc = 3, level = 92, power = 0, elite = true, co = { 2 }, casts = { 1.5 } },
{ npc = 4, level = 92, power = 0, elite = true, casts = { 1.5 } },
-- Channel lengths are listed apart from cast lengths. These two share a
-- level and differ only in the channel they are known for, so at level
-- 94 nothing but `channels` can tell them apart.
{ npc = 11, level = 94, power = 0, elite = true, channels = { 6 } },
{ npc = 12, level = 94, power = 0, elite = true, channels = { 9 } },
},
}
CastAheadExtra = {
[1877] = {
{ spell = 500, npc = 5, mob = "Imported", name = "Provisional", cast = 6.0,
cd = { 20.0 }, first = 5.0, n = 0, approx = true },
},
}
CastAheadSaveButtons = {}
CastAheadDefensives = { spells = {}, alias = {} }
StaticPopupDialogs = {}
dofile("Config.lua")
dofile("Match.lua")
dofile("Lists.lua")
dofile("Saves.lua")
dofile("Burst.lua")
dofile("BossAdapter.lua")
dofile("Timeline.lua")
-- UI.lua only needs the stubs above for its option helpers.
UISpecialFrames = {}
SlashCmdList = {}
SearchBoxTemplate_OnTextChanged = function() end
GameTooltip = setmetatable({}, { __index = function() return function() end end })
GameTooltip_Hide = function() end
dofile("Window.lua")
dofile("CastsPage.lua")
dofile("SavesPage.lua")
dofile("UI.lua")
dofile("Core.lua")
dofile("Setup.lua")
dofile("Recorder.lua")
dofile("ReportWindow.lua")
local function fire(event, ...) eventHandler(nil, event, ...) end
-- Config: flat settings -------------------------------------------------
local savedInInstance, savedInstanceInfo = IsInInstance, GetInstanceInfo
local instanceKind = "party"
IsInInstance = function() return true, instanceKind end
GetInstanceInfo = function() return "d", instanceKind, 0, "", 0, 0, false, 1877 end
CastAheadDB = nil
CastAheadConfig.SetEnabled("nameplates", false)
check(CastAheadConfig.Enabled("nameplates") == false, "an option switched off reads back off")
CastAheadConfig.SetEnabled("nameplates", true)
check(CastAheadConfig.Enabled("nameplates") == true, "switching it back on is honored")
check(CastAheadDB.nameplates == nil, "and stores nothing, so a later default change still applies")
instanceKind = "raid"
check(CastAheadConfig.Enabled("nameplates") == true, "settings do not change with the instance kind")
CastAheadConfig.Set("anchor", "top")
check(CastAheadDB.anchor == "top", "values are stored flat")
-- Lead seconds: off by default, clamped.
CastAheadDB = nil
check(CastAheadConfig.Lead() == 0, "the early warning is off by default")
CastAheadConfig.Set("leadSeconds", 5)
check(CastAheadConfig.Lead() == 5, "a stored lead is used")
CastAheadConfig.Set("leadSeconds", 99)
check(CastAheadConfig.Lead() == 15, "a lead beyond the range is clamped to 15")
-- Migration: a per-context profile collapses onto the flat one, and the old
-- leadWarning boolean becomes 5 or 0.
CastAheadDB = { ctx = { key = { centerText = false, leadSeconds = 7 },
raid = { sound = false } }, anchor = "left" }
CastAheadConfig.Migrate()
check(CastAheadConfig.Enabled("centerText") == false, "the key context is lifted to the flat profile")
check(CastAheadConfig.Lead() == 7, "including its early warning")
check(CastAheadConfig.Enabled("sound") == true, "the raid context is dropped, not merged")
check(CastAheadDB.ctx == nil, "and the context table is gone")
check(CastAheadDB.anchor == "left", "a global value survives migration untouched")
CastAheadDB = { leadWarning = true }
CastAheadConfig.Migrate()
check(CastAheadDB.leadSeconds == 5, "the old leadWarning boolean becomes 5 seconds")
check(CastAheadDB.leadWarning == nil, "and is removed")
CastAheadDB = { leadWarning = false }
CastAheadConfig.Migrate()
check(CastAheadConfig.Lead() == 0, "leadWarning = false stays off")
CastAheadDB = {}
CastAheadConfig.Migrate()
check(CastAheadDB.leadSeconds == nil, "a fresh profile leaves leadSeconds unset")
check(CastAheadConfig.Lead() == 0, "so a fresh profile gets the documented default of off")
CastAheadDB = nil
CastAheadConfig.Migrate()
check(CastAheadConfig.CenterText(), "a new install starts with the centre call on")
CastAheadDB = { anchor = "left" }
CastAheadConfig.Migrate()
check(CastAheadConfig.CenterText(), "a profile with no stored choice has it on")
CastAheadDB.centerText = false
CastAheadConfig.Migrate()
check(not CastAheadConfig.CenterText(), "switching it off survives the next zone-in")
CastAheadDB = { centerText = false }
CastAheadConfig.Migrate()
check(not CastAheadConfig.CenterText(), "an old profile that had it off keeps it off")
CastAheadDB = nil
fire("ADDON_LOADED", "CastAhead")
CastAheadDB = CastAheadDB or {}
CastAheadDB.minimap = CastAheadDB.minimap or {}
fire("PLAYER_ENTERING_WORLD")
check(CastAheadConfig.CenterText(),
"a new install has the centre call on although the minimap icon creates the profile at login")
CastAheadDB = { ctx = { key = { voice = false } } }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.voice == false and CastAheadDB.ctx == nil and CastAheadConfig.CenterText(),
"loading an old profile migrates it and keeps its centre call")
CastAheadDB = { anchor = "left" }
fire("ADDON_LOADED", "SomeOtherAddon")
fire("PLAYER_ENTERING_WORLD")
check(CastAheadDB.schema == nil, "another addon loading, or a zone-in, does not migrate")
instanceKind = "party"
CastAheadDB = { schema = 2 }
fire("ADDON_LOADED", "CastAhead")
check(not CastAheadConfig.SaveCalls() and CastAheadDB.saveCallsNotice == true,
"a profile from before the default changed ends with its defensive calls off and the notice flagged")
CastAheadDB = { schema = 2, saveCalls = false }
fire("ADDON_LOADED", "CastAhead")
check(not CastAheadConfig.SaveCalls(), "and one that had them off keeps them off")
CastAheadDB = nil
fire("ADDON_LOADED", "CastAhead")
check(not CastAheadConfig.SaveCalls(), "a new install starts with defensive calls off")
CastAheadDB = { schema = 3 }
fire("ADDON_LOADED", "CastAhead")
check(not CastAheadConfig.SaveCalls(), "a profile that switched them off after the change keeps them off")
CastAheadDB = { schema = 3, saveCalls = true, voice = false, window = { width = 900 }, journal = { 1 }, probe = { 2 },
dispel = { [100] = "Magic" } }
CastAheadConfig.ResetAll()
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.voice == nil and CastAheadDB.window == nil and not CastAheadConfig.SaveCalls()
and CastAheadDB.journal[1] == 1 and CastAheadDB.probe[1] == 2 and (CastAheadDB.dispel or {})[100] == "Magic",
"a full reset reads as a new install after the reload and keeps the recorded data")
instanceKind = "raid"
CastAheadSaveButtons = { [72] = { def = { 7, 3 } }, [75] = { def = { 8 } }, [76] = { def = { 8 } } }
CastAheadDB = { schema = 3, saveCalls = true, saveButtons = {
[71] = { big = { 1, { use = 5 } }, small = { 2, 1, { use = 5 } }, heal = { 9 } },
[72] = { small = { 3 } },
[73] = { big = {}, small = {} },
[74] = { heal = { 9 } },
[75] = { small = {} },
[76] = { big = {}, small = { 8 } },
[77] = { small = { 108416 }, def = { 104773, { use = 452930 } }, big = { 5 }, heal = { 9 } },
} }
fire("ADDON_LOADED", "CastAhead")
do
local sb = CastAheadDB.saveButtons
check(#sb[77].def == 2 and sb[77].def[1] == 104773 and sb[77].def[2].use == 452930
and sb[77].small == nil and sb[77].big == nil and sb[77].heal == nil,
"a defensive list already saved survives and the stale keys go")
check(sb[75].def == nil and sb[75].small == nil, "one side emptied on its own leaves the shipped list in place")
check(#sb[76].def == 1 and sb[76].def[1] == 8, "both sides present and one empty: the other alone")
check(#sb[72].def == 2 and sb[72].def[1] == 3 and sb[72].def[2] == 7,
"one side custom: its entries first, then the shipped ones not already there")
check(#sb[71].def == 3 and sb[71].def[1] == 2 and sb[71].def[2] == 1 and sb[71].def[3].use == 5
and sb[71].small == nil and sb[71].big == nil and sb[71].heal == nil,
"schema 4 joins small then big entries into one list without repeats")
check(sb[72].small == nil, "the small list is gone after the join")
check(sb[73].def and #sb[73].def == 0, "a spec the player emptied on purpose stays empty")
check(sb[74].def == nil and sb[74].heal == nil, "a spec with only heal entries falls back to the shipped list")
check(CastAheadDB.schema == 8, "the profile is stamped schema 8")
CastAheadDB = { schema = 3, saveCalls = true, smallCalls = false, bigCalls = false, healCalls = false }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.saveCalls == false and CastAheadDB.smallCalls == nil and CastAheadDB.bigCalls == nil
and CastAheadDB.healCalls == nil, "small and big both off read as defensive calls off")
CastAheadDB = { smallCalls = false, bigCalls = false }
CastAheadConfig.Migrate()
check(CastAheadDB.saveCalls == false, "an old profile with small and big off ends with the defensive calls off")
CastAheadDB = { schema = 3, saveCalls = true, smallCalls = false }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.saveCalls == false and CastAheadDB.smallCalls == nil, "only small off still ends with the one-time reset")
CastAheadDB = { schema = 3, sounds = { SMALL = "x", HEAL = "h" } }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.sounds.DEFENSIVE == "x" and CastAheadDB.sounds.SMALL == nil and CastAheadDB.sounds.HEAL == nil,
"the small sound becomes the defensive sound")
CastAheadDB = { schema = 3, sounds = { SMALL = "x", BIG = "b" } }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.sounds.DEFENSIVE == "b", "the big sound wins over the small one")
CastAheadDB = { schema = 3, sounds = { TANK = "Cast Ahead: big defensive", HEALER = "Cast Ahead: small defensive",
BIG = "Cast Ahead: big defensive", SMALL = "Cast Ahead: heal up", KEEP = "Cast Ahead: defensive" },
spellSounds = { [123] = "Cast Ahead: heal up", [124] = "Cast Ahead: small defensive" } }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.sounds.TANK == "CastAhead: defensive" and CastAheadDB.sounds.HEALER == "CastAhead: defensive"
and CastAheadDB.sounds.KEEP == "CastAhead: defensive" and CastAheadDB.sounds.DEFENSIVE == "CastAhead: defensive",
"sound picks naming the deleted defensive clips point at the merged one")
check(CastAheadDB.spellSounds[123] == nil and CastAheadDB.spellSounds[124] == "CastAhead: defensive",
"spell sound picks follow, and a heal up pick is dropped")
CastAheadDB = { schema = 3, saveButtons = { [71] = { big = { 1 }, small = { 2 } } }, sounds = { SMALL = "x" } }
fire("ADDON_LOADED", "CastAhead")
local once = { #CastAheadDB.saveButtons[71].def, CastAheadDB.sounds.DEFENSIVE }
fire("ADDON_LOADED", "CastAhead")
check(#CastAheadDB.saveButtons[71].def == once[1] and CastAheadDB.sounds.DEFENSIVE == once[2],
"migrating twice changes nothing more")
end
CastAheadSaveButtons = {}
CastAheadDB = nil
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.schema == 8 and CastAheadDB.saveButtons == nil, "a new install is schema 8 with no saved lists")
CastAheadDB = { schema = 6, kickImportantOnly = false, sounds = { KICK = "CastAhead: interrupt", AOE = "CastAhead: stun" },
spellSounds = { [200] = "CastAhead: interrupt" } }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.kickFocusOnly == nil and CastAheadDB.kickImportantOnly == nil,
"the old kick switch turned off goes, and the focus switch stays off")
check(CastAheadDB.sounds.KICK == "CastAhead: kick" and CastAheadDB.sounds.AOE == "CastAhead: stun"
and CastAheadDB.spellSounds[200] == "CastAhead: kick", "sound picks of the interrupt clip point at the kick clip")
CastAheadDB = { schema = 6, kickImportantOnly = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.kickFocusOnly == nil and CastAheadDB.kickImportantOnly == nil,
"the old kick switch left on goes, and the focus switch stays off")
CastAheadDB = { schema = 7, sounds = { KICK = "CastAhead: must kick", KICK_IMPORTANT = "Other" },
spellSounds = { [200] = "CastAhead: must kick" } }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.sounds.KICK == "CastAhead: kick" and CastAheadDB.sounds.KICK_IMPORTANT == nil
and CastAheadDB.spellSounds[200] == "CastAhead: kick", "must kick picks point at the kick clip and its row goes")
do
CastAheadDB = { schema = 4, saveCalls = true, raidSaves = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.schema == 8 and CastAheadDB.saveCalls == false and CastAheadDB.saveCallsNotice == true
and CastAheadDB.raidSaves == false, "a schema 4 profile with defensive calls on is turned off once and flagged")
CastAheadDB = { schema = 3, raidSaves = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.raidSaves == false and CastAheadDB.saveCallsNotice == true,
"raid calls alone on are turned off once and flagged")
CastAheadDB = { schema = 3, saveCalls = true, raidSaves = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.saveCalls == false and CastAheadDB.raidSaves == false and CastAheadDB.saveCallsNotice == true,
"both on are both turned off with one flag")
CastAheadDB.saveCalls = true
CastAheadDB.saveCallsNotice = nil
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.schema == 8 and CastAheadDB.saveCalls == true and CastAheadDB.saveCallsNotice == nil,
"migrating again changes nothing and raises no flag")
CastAheadDB = nil
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.saveCalls == nil and CastAheadDB.saveCallsNotice == nil, "a new install has no stored choice and no flag")
for _, stored in ipairs({ false, "nil" }) do
CastAheadDB = { schema = 4, saveCalls = stored ~= "nil" and stored or nil }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.saveCalls == (stored ~= "nil" and stored or nil) and CastAheadDB.saveCallsNotice == nil,
"a schema 4 profile with defensive calls off or unset stays as it was")
end
CastAheadDB = { schema = 5, burstCalls = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.schema == 8 and CastAheadDB.raidMode == true and CastAheadDB.burstCalls == true,
"a schema 5 profile with burst windows on gets the raid switch on")
CastAheadDB = { schema = 5, raidSaves = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.raidMode == true and CastAheadDB.raidSaves == true,
"a schema 5 profile with raid defensive calls on gets the raid switch on")
CastAheadDB = { schema = 5 }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.raidMode == nil, "a schema 5 profile with both raid features off leaves the raid switch unset")
CastAheadDB = nil
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.raidMode == nil, "a new install has the raid switch unset")
CastAheadDB = { schema = 3, raidSaves = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.raidSaves == false and CastAheadDB.raidMode == nil,
"raid calls turned off by the schema 5 reset do not turn the raid switch on")
CastAheadDB = { schema = 3, burstCalls = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.raidMode == true and CastAheadDB.burstCalls == true,
"a schema 3 profile with burst windows on keeps them on under the raid switch")
CastAheadDB = { schema = 3, burstCalls = true, raidSaves = true }
fire("ADDON_LOADED", "CastAhead")
check(CastAheadDB.raidMode == true and CastAheadDB.burstCalls == true and CastAheadDB.raidSaves == false
and CastAheadDB.saveCallsNotice == true,
"a schema 3 profile with both raid features on keeps bursts, loses raid calls once and is flagged")
local savedInfo = GetInstanceInfo
CastAheadDB = { saveCalls = true, raidSaves = false }
GetInstanceInfo = function() return "d", "party", 0, "", 0, 0, false, 1877 end
check(CastAheadConfig.SaveCalls() == true, "outside a raid SaveCalls follows saveCalls")
GetInstanceInfo = function() return "r", "raid", 0, "", 0, 0, false, 1878 end
check(CastAheadConfig.SaveCalls() == false, "in a raid SaveCalls follows raidSaves, off")
CastAheadDB = { saveCalls = false, raidSaves = true }
check(CastAheadConfig.SaveCalls() == false, "in a raid raidSaves alone does nothing while the raid switch is off")
CastAheadDB = { saveCalls = false, raidMode = true, raidSaves = true }
check(CastAheadConfig.SaveCalls() == true, "in a raid SaveCalls follows raidSaves, on")
GetInstanceInfo = function() return "d", "party", 0, "", 0, 0, false, 1877 end
check(CastAheadConfig.SaveCalls() == false, "outside a raid raidSaves does not count")
check(CastAheadConfig.DungeonSaves() == false and CastAheadConfig.RaidSaves() == true, "the page accessors ignore the zone")
CastAheadDB = { raidSaves = true, burstCalls = true }
check(CastAheadConfig.RaidSaves() == false and not (CastAheadConfig.BurstCalls and CastAheadConfig.BurstCalls()),
"with the raid switch off raid defensive calls and burst windows are off even when stored on")
CastAheadDB = { raidMode = true, burstCalls = true }
check(CastAheadConfig.BurstCalls ~= nil and CastAheadConfig.BurstCalls() == true and CastAheadConfig.RaidSaves() == false,
"with the raid switch on each child follows its own switch")
GetInstanceInfo = nil
CastAheadDB = { saveCalls = true, raidSaves = false }
check(CastAheadConfig.SaveCalls() == true, "without GetInstanceInfo SaveCalls follows saveCalls")
GetInstanceInfo = savedInfo
local savedShowTab, savedCreate, savedNotice = CastAheadUI.ShowTab, CreateFrame, CastAheadUI.ShowUpdateNotice
local shows, noticeFrame = 0, nil
CastAheadUI.ShowUpdateNotice = function(...)
shows = shows + 1
return savedNotice(...)
end
CreateFrame = function(kind, name, ...)
local f = savedCreate(kind, name, ...)
local setScript = f.SetScript
f.scripts = {}
f.SetScript = function(self, script, fn)
self.scripts[script] = fn
return setScript(self, script, fn)
end
if name == "CastAheadUpdateNotice" then noticeFrame = f end
return f
end
local realPrint, lines = print, {}
print = function(text) lines[#lines + 1] = tostring(text) end
CastAheadDB = { schema = 5, saveCallsNotice = true }
fire("PLAYER_LOGIN")
fire("PLAYER_LOGIN")
check(shows == 0, "the notice waits for the world to load")
local savedTimer, delays = C_Timer, {}
C_Timer = { After = function(delay, fn) delays[#delays + 1] = delay fn() end }
fire("PLAYER_ENTERING_WORLD")
fire("PLAYER_ENTERING_WORLD")
C_Timer = savedTimer
check(#delays == 1 and delays[1] > 0, "the notice is shown once, a moment after the world loads")
print = realPrint
check(#lines == 1 and lines[1]:find("CastAhead:|r defensive calls are now off by default. Turn them on in /ca - Features.", 1, true)
and CastAheadDB.saveCallsNotice == nil, "the notice prints once and clears")
do
local body
for _, fs in ipairs(noticeFrame and noticeFrame.fontStrings or {}) do
if type(fs.value) == "string" and fs.value:find("Turn them on", 1, true) then body = fs.value end
end
check(body and body:find("Turn them on in Features.", 1, true) and not body:find("General", 1, true),
"the notice sends the user to Features, not General, got " .. tostring(body))
end
check(shows == 1 and noticeFrame and noticeFrame.shown and CastAheadDB.saveCallsNotice == nil,
"the update notice shows once and clears the flag")
check(noticeFrame.open and noticeFrame.close, "the notice has an Open settings and a Close button")
local inSpecial
for _, n in ipairs(UISpecialFrames) do inSpecial = inSpecial or n == "CastAheadUpdateNotice" end
check(inSpecial, "Escape closes the notice")
local shownTab
CastAheadUI.ShowTab = function(name) shownTab = name end
noticeFrame.open.scripts.OnClick()
check(shownTab == "Features" and not noticeFrame.shown, "Open settings opens the Features page and hides the notice")
CastAheadUI.ShowUpdateNotice()
check(noticeFrame.shown, "the notice shows again on request")
noticeFrame.close.scripts.OnClick()
check(not noticeFrame.shown, "Close hides the notice")
CastAheadDB, shownTab, lines = {}, nil, {}
print = function(text) lines[#lines + 1] = tostring(text) end
CastAheadUI.ShowUpdateNotice(CastAheadWindow.NEWS)
noticeFrame.open.scripts.OnClick()
print = realPrint
check(CastAheadDB.kickFocusOnly == true and shownTab == nil and not noticeFrame.shown and #lines == 1,
"Turn it on in What's new turns the focus switch on, says so in chat and hides the notice")
CastAheadUI.ShowTab, CreateFrame, CastAheadUI.ShowUpdateNotice = savedShowTab, savedCreate, savedNotice
-- What's new: once per message after an update, with a button to its
-- page; a new install is not told what changed.
local told = {}
CastAheadUI.ShowUpdateNotice = function(message) told[#told + 1] = message end
savedTimer = C_Timer
C_Timer = { After = function(_, fn) fn() end }
local function login(db, newInstall)
CastAheadDB = db
CastAheadConfig.newInstall = newInstall
fire("PLAYER_LOGIN")
fire("PLAYER_ENTERING_WORLD")
end
login({ schema = 6 }, false)
check(#told == 1 and told[1] == CastAheadWindow.NEWS and told[1].enable == "kickFocusOnly",
"an updated profile is told what's new once, with a button that turns the focus switch on")
check(CastAheadDB.newsSeen == CastAheadWindow.NEWS.id, "the message is marked seen")
login(CastAheadDB, false)
check(#told == 1, "the same message is not shown again")
login({}, true)
check(#told == 1 and CastAheadDB.newsSeen == CastAheadWindow.NEWS.id,
"a new install is not told what changed and starts with the message seen")
C_Timer = savedTimer
CastAheadConfig.newInstall = nil
CastAheadUI.ShowUpdateNotice = savedNotice
CastAheadDB = nil
end
-- The UI helpers write where the accessor reads.
CastAheadDB = nil
CastAheadUI.SetOption("voice", false)
check(CastAheadConfig.Enabled("voice") == false, "a UI toggle writes where the accessor reads")
check(CastAheadUI.OptionEnabled("voice") == false, "and reads back through the same path")
CastAheadUI.SetOption("voice", true)
check(CastAheadConfig.Enabled("voice") == true, "switching it back on clears the stored value")
CastAheadDB = nil
IsInInstance, GetInstanceInfo = savedInInstance, savedInstanceInfo
CastAheadDB = { leadSeconds = 5 } -- the heads-up is opt-in; most cases want it on
-- Count bars that actually became visible: counting CreateFrame would miss the
-- pool, which hands back an existing frame instead of making a new one.
local function BarsShown() return shownCount end
-- Helpers ------------------------------------------------------------------
local unit = "nameplate1"
local function advance(seconds)
now = now + seconds
if updateHandler then updateHandler() end
end
local function enter()
hostile[unit], combat[unit], dead[unit] = true, true, nil
fire("PLAYER_ENTERING_WORLD")
fire("NAME_PLATE_UNIT_ADDED", unit)
end
local function reset()
fire("NAME_PLATE_UNIT_REMOVED", unit)
hostile[unit], combat[unit], dead[unit] = nil, nil, nil
end
-- A cast of `length`, identified by its duration.
local function castFor(length)
fire("UNIT_SPELLCAST_START", unit)
advance(length)
fire("UNIT_SPELLCAST_STOP", unit)
end
-- A channel of `length`, identified by its duration like a cast.
local function channelFor(length)
fire("UNIT_SPELLCAST_CHANNEL_START", unit)
advance(length)
fire("UNIT_SPELLCAST_CHANNEL_STOP", unit)
end
-- Cases --------------------------------------------------------------------
-- A hostile unit may hand back a level that cannot be compared - a secret
-- value. Comparing one throws, and that used to kill the handler that tracks
-- the plate at all, so nothing appeared on any nameplate.
local realUnitLevel = UnitLevel
UnitLevel = function() return setmetatable({}, { __lt = function() error("secret value") end }) end
hostile[unit], combat[unit] = true, true
fire("PLAYER_ENTERING_WORLD")
ok, err = pcall(fire, "NAME_PLATE_UNIT_ADDED", unit)
check(ok, "an unreadable level must not break plate tracking: " .. tostring(err))
ok, err = pcall(castFor, 3.0)
check(ok, "and a cast on that plate must still resolve: " .. tostring(err))
reset()
UnitLevel = realUnitLevel
-- Out of combat, a plate whose level pins the creature down shows its kit with
-- no timers at all. A level shared by several creatures shows nothing.
table.insert(CastAheadData[1877], { spell = 600, npc = 6, mob = "Known", name = "Preview", cast = 3.0,
cd = { 20.0 }, first = 5.0, offset = 0.0, level = 66,
hits = 5, dmg = 0.5, kick = 0, cc = 0, prio = "AOE" })
local madeBefore = BarsShown()
hostile[unit], combat[unit] = true, false
levels[unit] = 66
fire("PLAYER_ENTERING_WORLD")
ok, err = pcall(fire, "NAME_PLATE_UNIT_ADDED", unit)
check(ok, "a pre-combat preview must not error: " .. tostring(err))
check(BarsShown() > madeBefore, "a uniquely levelled mob shows its kit before the pull")
madeBefore = BarsShown()
reset()
levels[unit] = 90 -- shared by several creatures in the fixture
hostile[unit], combat[unit] = true, false
fire("NAME_PLATE_UNIT_ADDED", unit)
check(BarsShown() == madeBefore, "an ambiguous level shows nothing before the pull")
reset()
levels[unit] = nil
table.remove(CastAheadData[1877])
-- Entering combat alone identifies a mob when its level is unique enough, and
-- the opening cast is timed from that moment - no cast needed first.
-- spell/npc renumbered away from the CastAheadExtra fixture (500/5), and
-- appended rather than parked in slot 5, which spell 700 now occupies.
table.insert(CastAheadData[1877], { spell = 800, npc = 8, mob = "Lonely", name = "Opener", cast = 2.0,
cd = { 15.0 }, first = 6.0, level = 77, hits = 5, dmg = 0.4,
kick = 0, cc = 0, prio = "AOE" })
levels[unit] = 77
enter()
ok, err = pcall(advance, 1)
check(ok, "an opening prediction must not error: " .. tostring(err))
reset()
levels[unit] = nil
table.remove(CastAheadData[1877])
-- One identified cast must lay out the rest of the creature kit: every other
-- spell that mob has, timed where possible and listed where not, and an
-- estimate that runs out must leave the icon standing.
local shownBefore = BarsShown()
enter()
local ok, err = pcall(castFor, 3.0)
check(ok, "projecting a mob other spells must not error: " .. tostring(err))
check(BarsShown() > shownBefore + 1, "one identified cast should reveal the rest of the kit")
ok, err = pcall(advance, 30)
check(ok, "an estimate running out must not error: " .. tostring(err))
ok, err = pcall(advance, 1)
check(ok, "and the frame after it must not error: " .. tostring(err))
reset()
-- The alert needs the spell identified first: the opening cast of a pull is
-- unknown until it finishes, so nothing sounds for it.
sounds, spoken, clips = 0, 0, 0
enter()
castFor(3.0) -- spell 100: 5 targets, 50% health -> DEFENCE
check(Alerts() == 0, "an unidentified opening cast must not alert")
-- The repeat lands where predicted, is recognised on the way in, and alerts
-- exactly once no matter how many frames pass.
advance(17)
castFor(3.0)
advance(3)
check(Alerts() == 1, string.format("one alert per identified dangerous cast, got %d", Alerts()))
reset()
-- A sound picked for one spell wins over its category's, and only once the
-- cast is that spell rather than one of several candidates.
local lastFile
local playFile = PlaySoundFile
PlaySoundFile = function(path, ...) lastFile = path return playFile(path, ...) end
LibStub = function() return { Fetch = function(_, _, name) return "custom/" .. name end } end
CastAheadDB = { leadSeconds = 5, sounds = { AOE = "Horn" }, spellSounds = { [100] = "Bell" } }
enter()
castFor(3.0)
advance(17)
castFor(3.0)
advance(3)
check(lastFile == "custom/Bell", "the spell's own sound plays, got " .. tostring(lastFile))
reset()
CastAheadDB.spellSounds[100] = ""
lastFile = nil
enter()
castFor(3.0)
advance(17)
castFor(3.0)
advance(3)
check(lastFile == "custom/Horn", "an empty spell pick falls back to the category sound, got " .. tostring(lastFile))
reset()
PlaySoundFile, LibStub = playFile, nil
CastAheadDB = { leadSeconds = 5 }
-- A predicted cast warns ahead of time, once, and then alerts again when it
-- actually starts.
sounds, spoken, clips = 0, 0, 0
enter()
castFor(3.0)
advance(13.9) -- inside the 5s warning lead of a 20s cooldown
advance(0.1) -- the overdue sibling row ahead of it settles on the first frame
check(Alerts() == 1, string.format("one heads-up before a predicted cast, got %d", Alerts()))
advance(2)
check(Alerts() == 1, "the heads-up does not repeat every frame")
reset()
-- The heads-up lead is configurable: at 8 seconds it fires earlier than the
-- old fixed 3, and at 0 it never fires at all.
CastAheadDB = { leadSeconds = 8 }
sounds, spoken, clips = 0, 0, 0
enter()
castFor(3.0) -- spell 100, 20s rotation
advance(12.5) -- 8.5s before the next cast: outside the lead
check(Alerts() == 0, string.format("no heads-up before the configured lead, got %d", Alerts()))
advance(1.0) -- 7.5s before it: inside an 8s lead
check(Alerts() == 1, string.format("the heads-up fires at the configured lead, got %d", Alerts()))
reset()
CastAheadDB = { leadSeconds = 0 }
sounds, spoken, clips = 0, 0, 0
enter()
castFor(3.0)
advance(19.0) -- 1s before the next cast
check(Alerts() == 0, string.format("leadSeconds = 0 silences the heads-up, got %d", Alerts()))
reset()
CastAheadDB = { leadSeconds = 5 }
-- With the early warning switched off there is no heads-up at all - only the
-- call when the cast starts.
CastAheadDB = { leadSeconds = 0 }
sounds, spoken, clips = 0, 0, 0
enter()
castFor(3.0)
advance(15.5) -- inside the lead, nothing said
check(Alerts() == 0, string.format("no heads-up while leadSeconds is 0, got %d", Alerts()))
castFor(3.0) -- the predicted cast itself, 1.5s early, still alerts
check(Alerts() == 1, string.format("the cast-start call is unaffected, got %d", Alerts()))
reset()
CastAheadDB = { leadSeconds = 5 }
-- Turning the sound off in the UI is honoured.
CastAheadDB = { sound = false }
sounds, spoken, clips = 0, 0, 0
enter()
castFor(3.0)
advance(17)
castFor(3.0)
check(Alerts() == 0, "no alert when the sound is switched off")
reset()
CastAheadDB = { leadSeconds = 5 } -- the heads-up is opt-in; most cases want it on
-- Helpers for the trait cases: which spell icons are on screen.
local function IconShown(spellID)
for i = 1, #allFrames do
local f = allFrames[i]
if f.shown and IsBar(f) and f.icon.textureValue == textures[spellID] then return true end
end
return false
end
-- A trait match alone must not name a creature: the trait table does not know
-- every mob, and a level-91 plate that matches npc 1 may be an unlisted
-- creature that merely looks like it. So nothing is previewed on sight...
CastAheadDB = { leadSeconds = 5 } -- the heads-up is opt-in; most cases want it on
table.insert(CastAheadData[1877], { spell = 900, npc = 9, mob = "Lookalike", name = "Twin", cast = 3.0,
cd = { 20.0 }, first = 5.0, firstN = 5, hits = 5, dmg = 0.5, n = 50,
kick = 0, cc = 0, prio = "AOE" })
levels[unit] = 91
local beforeSight = BarsShown()
hostile[unit], combat[unit] = true, false
fire("NAME_PLATE_UNIT_ADDED", unit)
check(BarsShown() == beforeSight, "a trait match alone must not preview a kit")
-- ...but the match does steer a cast several creatures share: 3.0s is spell
-- 100 (npc 1) or the lookalike 900 (npc 9, seen far more often), and the trait
-- match picks 100. That cast also fits npc 1's known lengths, which locks it.
combat[unit] = true
fire("NAME_PLATE_UNIT_ADDED", unit)
castFor(3.0)
check(IconShown(100) and not IconShown(900), "the trait match steers a shared cast length to its creature")
-- That lock rests on the trait table, which does not know every creature. A
-- cast the named creature does not have contradicts it, so the lock is
-- released and the cast identified on its own - not hidden.
castFor(2.5) -- spell 200 belongs to npc 2
check(IconShown(200), "a trait-confirmed lock yields to a cast the creature does not have")
reset()
-- A lock earned by the creature's own unique cast is evidence, and holds: a
-- cast belonging to another creature is not shown at all.
levels[unit] = nil
enter()
castFor(4.5) -- spell 101, unique length: npc 1 locked by cast
castFor(2.5) -- spell 200 belongs to npc 2
check(not IconShown(200), "a cast-confirmed lock never shows another creature's cast")
reset()
levels[unit] = nil
table.remove(CastAheadData[1877])
-- Company: a level-92 plate could be npc 3 or npc 4, whose 1.5s casts (300 and
-- 950) are indistinguishable by length. With npc 2 locked on a neighbouring
-- plate and npc 3 listed as its packmate, the plate leans to 3 and the cast is
-- named as 300; alone it stays a tie and shows the better-sampled 950.
table.insert(CastAheadData[1877], { spell = 950, npc = 4, mob = "Other", name = "Also", cast = 1.5,
cd = { 4.8, 4.8, 8.7 }, first = 3.0, firstN = 5, hits = 3, dmg = 0.25, n = 99,
kick = 0, cc = 0, prio = "DODGE" })
-- A third 1.5s spell from a creature outside the trait match (npc 9, sampled
-- most of all) shows what the match is worth on its own: it is preferred
-- away from, even though nothing locks yet.
table.insert(CastAheadData[1877], { spell = 960, npc = 9, mob = "Stranger", name = "Loud", cast = 1.5,
cd = { 4.8, 4.8, 8.7 }, first = 3.0, firstN = 5, hits = 3, dmg = 0.25, n = 200,
kick = 0, cc = 0, prio = "DODGE" })
local other = "nameplate2"
levels[unit] = 92
enter()
castFor(1.5)
check(not IconShown(960), "a trait match steers a tie away from creatures it excludes")
check(IconShown(950), "and within the match the tie falls to the better-sampled lookalike")
reset()
levels[other] = 93
hostile[other], combat[other] = true, true
fire("NAME_PLATE_UNIT_ADDED", other)
fire("UNIT_SPELLCAST_START", other)
advance(2.5)
fire("UNIT_SPELLCAST_STOP", other) -- spell 200, unique length: npc 2 locked
enter()
castFor(1.5)
check(IconShown(300) and not IconShown(950), "a locked packmate nearby steers the tie to its listed companion")
reset()
fire("NAME_PLATE_UNIT_REMOVED", other)
-- The other way round: the tie is already on screen when the neighbour locks.
-- The lock reaches this plate through IdentifyOthers with no cast of its own
-- to repaint it, so LockNPC has to redraw the icons itself.
enter()
castFor(1.5) -- tie, drawn as the better-sampled 950
check(IconShown(950), "before the neighbour locks, the tie shows the lookalike")
hostile[other], combat[other] = true, true
fire("NAME_PLATE_UNIT_ADDED", other)
fire("UNIT_SPELLCAST_START", other)
advance(2.5)
fire("UNIT_SPELLCAST_STOP", other) -- npc 2 locked next door
check(IconShown(300) and not IconShown(950), "a lock arriving from a neighbour repaints the plate at once")