- a setting's default is the reference's, enforced: eight player-facing options booted at something a stock client does not — `UnitNameNPC`, `UnitNameOwn`, `nameplateShowEnemies`, `ChatBubblesParty`, `cameraDistanceMaxFactor`, `QUEST_FADING_DISABLE`, `SHOW_BUFF_DURATIONS`, `SHOW_COMBAT_TEXT` — each a defensible "turn this on so I can see it while you build it" on its day and invisible as a set; the CVar table grows a mandatory `Reference` column (Same / Overridden / Deviates-with-reason) that a test checks in both directions, the FrameXML saved-variable store is compared against the reference's own `UIOptionsFrame.lua` off the chain (which found `SHOW_COMBAT_TEXT`, declared inside a table walked by a loop where a grep could not see it), and the wow-re census of all 214 `CVar::Register` sites folded in — `WorldDetail` back to `same`, since `GetWorldDetail` reads only `SmallCull` and the reference's own slider reads Medium at boot - looting on a cold item cache raised `LootFrame.lua:85: attempt to index local 'color'` out of the stock window: `GetLootSlotInfo` answered nil where the reference answers cache-miss SENTINELS — quality −1 (which is exactly why stock `UIParent.lua` builds `ITEM_QUALITY_COLORS` over `for i = -1, 6`), "" for a name in flight, `INV_Misc_QuestionMark` for a display-info miss — and four values on every leg (`mov eax,4` at `0x4c2d07` is unconditional); `GetLootRollItemInfo` takes its own miss tail (`nil, nil, 1.0, 1.0, nil`), `GetItemQualityColor(-1)` reads Common because the compare is unsigned, and the window now WAITS for every item template before opening as `0x4c1cb0` does — there is no repaint path in 1.12 to fall back on, so deferring IS the mechanism - the coin row is quality 0 (grey), not white — `0x4c23a0`'s coin leg falls to `xor eax,eax` before the item-cache read — and its icon walks the reference's six-step ladder (`0x6c6307`: one icon per decade of copper; two of the six were not in our set at all, and the pairs do not fall where "highest nonzero denomination" put them) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1083 lines
44 KiB
Rust
1083 lines
44 KiB
Rust
//! The player buff bar (`assets/ui/BuffFrame.xml`, decisions 0255/0257) against its reference
|
|
//! behaviour. The XML/Lua is the unit under test; the app-side feed (`crate::ui_aura`) is stubbed by
|
|
//! pushing an [`AuraState`] list straight through [`UiScript::set_auras`] and firing
|
|
//! `PLAYER_AURAS_CHANGED`, so these exercise the *button* handlers — the row/filter wiring, the
|
|
//! dispel-tinted border, the stack count, the countdown, the warning flash, and the right-click
|
|
//! cancel — the way the reference's own `BuffButton_*` do.
|
|
//!
|
|
//! **The window runs on the 1.12 verbs and the reference's index space** (b2ede294 landed the
|
|
//! `GetPlayerBuff` family): a button's `id` is a 0-based ordinal within its own filter,
|
|
//! `GetPlayerBuff` turns it into a CACHE POSITION, and every read below — texture, dispel class,
|
|
//! stacks, time left, cancel, tooltip — consumes that position. The tests drive it the way the
|
|
//! corpus does, including the `while GetPlayerBuff(i) >= 0 do` walk and a cancel by position.
|
|
//!
|
|
//! State is read back through the widget (`IsShown`/`GetText`/`GetAlpha`) and the paint through the
|
|
//! [`draw list`](UiScript::extract) (icon path + border tint), the same two-lens split the cast-bar
|
|
//! tests use — Lua-visible state is blind to what actually renders.
|
|
|
|
use benilla_ui::script::{AuraState, ExtractedQuad, QuadContent, UiScript};
|
|
|
|
use super::test_ui::load_ui as load_xml;
|
|
|
|
fn harness() -> UiScript {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
// The one-letter duration strings (DAY/HOUR/MINUTE/SECOND_ONELETTER_ABBR) the bar prints
|
|
// under a timed aura. Our copy carried them as `X = X or "%d s"` fallbacks; the reference's
|
|
// file formats them straight and `format(nil, …)` raises.
|
|
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
|
load_xml(&s, "Fonts.xml"); // NORMAL/HIGHLIGHT_FONT_COLOR + the FontStrings' faces
|
|
// `GameTooltip`, which the reference's BuffButton_Update indexes on EVERY repaint to ask
|
|
// `IsOwned(this)` (BuffFrame.lua l.104) — not just on hover. Ours guarded it; the reference
|
|
// does not, so a session without the tooltip loses the whole repaint.
|
|
load_xml(&s, "GameTooltip.xml");
|
|
// `SecondsToTimeAbbrev`, which 1.12 keeps in UIParent.lua and so do we since window 18.
|
|
load_xml(&s, "MoneyFrame.xml");
|
|
load_xml(&s, "UiPanels.xml");
|
|
load_xml(&s, "UIParent.xml");
|
|
load_xml(&s, "Cooldown.xml");
|
|
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
|
|
// The timer switch, PLANTED ON — not the shipped value. 1.12 declares it in
|
|
// UIOptionsFrame.lua (default "0"); we have no counterpart to that file, so it lives with the
|
|
// row that drives it (OptionsFrame.xml), where it shipped "1" from 0255/1139 until 1804 put
|
|
// it back on the reference's "0". These tests are about the timer text and the geometry it
|
|
// buys, so the harness turns it on the way the Interface page's row does. Order matters: the
|
|
// reference's `BuffFrame_OnLoad` calls `BuffButtons_UpdatePositions`, which seats the debuff
|
|
// row 20px differently depending on this value, so setting it afterwards leaves the bar laid
|
|
// out for the wrong one.
|
|
s.run("SHOW_BUFF_DURATIONS = \"1\"").unwrap();
|
|
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
|
|
// …and APPLIED, the way the app applies it (`manifest::apply_buff_durations`).
|
|
// `BuffFrame_OnLoad` does not call `BuffButtons_UpdatePositions`: in 1.12 that is
|
|
// `UIOptionsFrame.lua`'s job and we have no counterpart to that file. A session that skips it
|
|
// is laid out for durations-OFF while the setting says on.
|
|
super::manifest::apply_buff_durations(&s).unwrap();
|
|
s
|
|
}
|
|
|
|
/// Build an [`AuraState`] as the feed would push it. `expiration_time` 0 = permanent (no wire
|
|
/// duration — the reference's "until cancelled"); the `GetTime()` clock starts at 0 in the harness.
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn aura(
|
|
spell_id: u32,
|
|
name: &str,
|
|
icon: &str,
|
|
helpful: bool,
|
|
count: u8,
|
|
debuff_type: Option<&str>,
|
|
expiration_time: f64,
|
|
cancelable: bool,
|
|
) -> AuraState {
|
|
AuraState {
|
|
spell_id,
|
|
name: Some(name.into()),
|
|
icon: Some(icon.into()),
|
|
count,
|
|
debuff_type: debuff_type.map(Into::into),
|
|
duration: expiration_time,
|
|
expiration_time,
|
|
helpful,
|
|
cancelable,
|
|
// What the feed derives for a permanent aura: no finite duration to display. Here the two
|
|
// agree by construction, but they are different questions in the real feed — the flag is
|
|
// DBC-derived and correct on the frame the aura appears, before any duration packet lands
|
|
// (`benilla::ui_aura::until_cancelled`).
|
|
until_cancelled: expiration_time == 0.0,
|
|
channeled: false,
|
|
}
|
|
}
|
|
|
|
/// Push the list, then fire the discrete-change event the app's feed fires — the buttons repaint.
|
|
///
|
|
/// `PLAYER_AURAS_CHANGED`, no args: the reference's own event, which `ui_aura` fires beside the
|
|
/// Era-shaped `UNIT_AURA` on the same rebuild. It is what the buttons register for now, and it is
|
|
/// what every corpus aura addon registers for.
|
|
fn push(s: &mut UiScript, auras: Vec<AuraState>) {
|
|
s.set_auras("player", Some(auras));
|
|
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
|
|
}
|
|
|
|
/// One tick of the app's real order (OnUpdate → resolve → draw list), returning the quads.
|
|
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
|
|
s.tick(dt);
|
|
s.resolve();
|
|
s.extract()
|
|
}
|
|
|
|
fn shown(s: &UiScript, name: &str) -> bool {
|
|
s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap()
|
|
}
|
|
|
|
fn text(s: &UiScript, name: &str) -> String {
|
|
s.eval::<String>(&format!("return tostring(({name}:GetText()) or \"\")"))
|
|
.unwrap()
|
|
}
|
|
|
|
fn alpha(s: &UiScript, name: &str) -> f64 {
|
|
s.eval::<f64>(&format!("return {name}:GetAlpha()")).unwrap()
|
|
}
|
|
|
|
/// The first quad drawn from `Interface\...\<leaf>` — texture regions keyed by their art.
|
|
fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQuad> {
|
|
quads.iter().find(|q| match &q.content {
|
|
QuadContent::Texture { path: Some(p), .. } => p.ends_with(leaf),
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn buffs_and_debuffs_fill_their_own_rows_with_counts_and_the_dispel_tint() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(
|
|
&mut s,
|
|
vec![
|
|
aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\Spell_Nature_Regeneration",
|
|
true,
|
|
1,
|
|
None,
|
|
120.0,
|
|
true,
|
|
),
|
|
aura(
|
|
589,
|
|
"Shadow Word: Pain",
|
|
"Interface\\Icons\\Spell_Shadow_ShadowWordPain",
|
|
false,
|
|
3,
|
|
Some("Magic"),
|
|
18.0,
|
|
false,
|
|
),
|
|
],
|
|
);
|
|
let quads = frame(&mut s, 0.1);
|
|
|
|
// The buff takes the first helpful button, the debuff the first harmful one; the rest hide.
|
|
assert!(shown(&s, "BuffButton0"), "first buff -> BuffButton0");
|
|
assert!(!shown(&s, "BuffButton1"), "no second buff");
|
|
assert!(shown(&s, "BuffButton16"), "first debuff -> BuffButton16");
|
|
assert!(!shown(&s, "BuffButton17"), "no second debuff");
|
|
|
|
// Both icons drew.
|
|
assert!(
|
|
tex_quad(&quads, "Spell_Nature_Regeneration").is_some(),
|
|
"buff icon drew"
|
|
);
|
|
assert!(
|
|
tex_quad(&quads, "Spell_Shadow_ShadowWordPain").is_some(),
|
|
"debuff icon drew"
|
|
);
|
|
|
|
// The debuff border wears the Magic tint (DebuffTypeColor["Magic"] = 0.20, 0.60, 1.00).
|
|
let border = tex_quad(&quads, "UI-Debuff-Overlays").expect("debuff border drew");
|
|
match &border.content {
|
|
QuadContent::Texture {
|
|
color: Some(c),
|
|
tex_coords: Some(uv),
|
|
..
|
|
} => {
|
|
assert!(
|
|
(c[0] - 0.20).abs() < 1e-3
|
|
&& (c[1] - 0.60).abs() < 1e-3
|
|
&& (c[2] - 1.00).abs() < 1e-3,
|
|
"Magic tint, got {c:?}"
|
|
);
|
|
// The reference's overlay-quadrant crop rode through unchanged.
|
|
let uv = uv.edges();
|
|
assert!(
|
|
(uv[0] - 0.296875).abs() < 1e-6 && (uv[3] - 0.515625).abs() < 1e-6,
|
|
"debuff overlay tex-coords, got {uv:?}"
|
|
);
|
|
}
|
|
other => panic!("border is not a tinted, cropped texture: {other:?}"),
|
|
}
|
|
|
|
// Stack count shows only above 1: the 3-stack debuff shows "3", the single buff shows nothing.
|
|
assert_eq!(text(&s, "BuffButton16Count"), "3");
|
|
assert_eq!(text(&s, "BuffButton0Count"), "");
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn a_timed_aura_counts_down_and_a_permanent_one_shows_no_timer() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(
|
|
&mut s,
|
|
vec![
|
|
// Permanent (a stance): no wire duration -> expiration 0 -> no timer.
|
|
aura(
|
|
2457,
|
|
"Battle Stance",
|
|
"Interface\\Icons\\Ability_Warrior_OffensiveStance",
|
|
true,
|
|
1,
|
|
None,
|
|
0.0,
|
|
false,
|
|
),
|
|
// Timed: 120s out. After a 0.1s tick GetTime()=0.1, so 119.9s -> SecondsToTimeAbbrev -> "2 m".
|
|
aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\Spell_Nature_Regeneration",
|
|
true,
|
|
1,
|
|
None,
|
|
120.0,
|
|
true,
|
|
),
|
|
],
|
|
);
|
|
frame(&mut s, 0.1);
|
|
|
|
assert!(
|
|
!shown(&s, "BuffButton0Duration"),
|
|
"a permanent aura shows no timer (our 'until cancelled')"
|
|
);
|
|
assert!(shown(&s, "BuffButton1Duration"));
|
|
assert_eq!(
|
|
text(&s, "BuffButton1Duration"),
|
|
"2 m",
|
|
"120s remaining abbreviates to minutes"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn the_warning_flash_pulses_a_low_aura_but_leaves_a_long_one_solid() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(
|
|
&mut s,
|
|
vec![
|
|
// 5 min out — never inside the 31s warning window, so it stays at full alpha.
|
|
aura(
|
|
1,
|
|
"Long",
|
|
"Interface\\Icons\\INV_Misc_QuestionMark",
|
|
true,
|
|
1,
|
|
None,
|
|
300.0,
|
|
true,
|
|
),
|
|
// 20s out — inside the window the whole run, so its button pulses.
|
|
aura(
|
|
2,
|
|
"Short",
|
|
"Interface\\Icons\\INV_Misc_QuestionMark",
|
|
true,
|
|
1,
|
|
None,
|
|
20.0,
|
|
true,
|
|
),
|
|
],
|
|
);
|
|
|
|
let (mut short_min, mut long_min) = (2.0_f64, 2.0_f64);
|
|
for _ in 0..20 {
|
|
s.tick(0.1);
|
|
short_min = short_min.min(alpha(&s, "BuffButton1"));
|
|
long_min = long_min.min(alpha(&s, "BuffButton0"));
|
|
}
|
|
assert!(
|
|
short_min < 0.9,
|
|
"the 20s aura's icon pulses toward BUFF_MIN_ALPHA (min seen {short_min})"
|
|
);
|
|
assert!(
|
|
(long_min - 1.0).abs() < 1e-9,
|
|
"the 5min aura's icon stays solid (min seen {long_min})"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn right_clicking_a_cancelable_buff_queues_its_spell_cancel() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(
|
|
&mut s,
|
|
vec![aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\Spell_Nature_Regeneration",
|
|
true,
|
|
1,
|
|
None,
|
|
120.0,
|
|
true,
|
|
)],
|
|
);
|
|
frame(&mut s, 0.1);
|
|
|
|
assert!(
|
|
s.take_cancel_aura_requests().is_empty(),
|
|
"nothing queued yet"
|
|
);
|
|
// The RightButtonUp handler -> CancelPlayerBuff(this.buffIndex) -> the app drains one
|
|
// CMSG_CANCEL_AURA by spell id.
|
|
s.eval::<()>("this = BuffButton0; BuffButton_OnClick()")
|
|
.unwrap();
|
|
assert_eq!(s.take_cancel_aura_requests(), vec![1126]);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn an_emptied_bar_hides_every_button() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(
|
|
&mut s,
|
|
vec![aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\Spell_Nature_Regeneration",
|
|
true,
|
|
1,
|
|
None,
|
|
120.0,
|
|
true,
|
|
)],
|
|
);
|
|
frame(&mut s, 0.1);
|
|
assert!(shown(&s, "BuffButton0"), "shown while the aura is up");
|
|
|
|
// The aura drops: the feed pushes an empty list and re-fires. The button hides, and so does its
|
|
// timer — a HIDDEN region since 1139, where it used to be blanked, so the text it last drew is
|
|
// stale and beside the point; what the bar promises is that nothing paints.
|
|
push(&mut s, vec![]);
|
|
frame(&mut s, 0.1);
|
|
assert!(!shown(&s, "BuffButton0"), "hidden once the aura is gone");
|
|
assert!(!shown(&s, "BuffButton0Duration"));
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **Decision 0846's second defect, pinned.** The bar's countdown is a per-frame POLL, not a value
|
|
/// cached when the aura event fires — exactly as the reference does it (`BuffButton_OnUpdate` →
|
|
/// `GetPlayerBuffTimeLeft`, extracted 1.12 `BuffFrame.lua` l.128-130, which caches only the buff
|
|
/// *index* on `PLAYER_AURAS_CHANGED`).
|
|
///
|
|
/// This is what makes a *refresh* work. A recast, a reapplied DoT, cast pushback and the server's
|
|
/// replay of every duration at world entry all change an aura's expiry while changing nothing
|
|
/// discrete about it — same spell, same stacks, same dispel class — so the feed's change key does
|
|
/// not trip and **no aura event fires**. The push below therefore deliberately does NOT fire one.
|
|
/// Against the old event-cached bar the button kept counting the stale expiry down, reached zero
|
|
/// and sat at `"0 s"` for as long as the buff lived (live-measured: `app left 59.9s` beside
|
|
/// `lua left -9.9s`).
|
|
#[test]
|
|
fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
let mark = |expiry: f64| {
|
|
aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\Spell_Nature_Regeneration",
|
|
true,
|
|
1,
|
|
None,
|
|
expiry,
|
|
true,
|
|
)
|
|
};
|
|
// 20s out. After the tick GetTime()=0.1 -> 19.9s -> "19 s" (SecondsToTimeAbbrev floors via %d).
|
|
push(&mut s, vec![mark(20.0)]);
|
|
frame(&mut s, 0.1);
|
|
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
|
|
|
|
// The refresh: a new expiry, everything discrete unchanged — so the feed pushes the list and
|
|
// fires NOTHING. The button must still pick the new expiry up on its next frame.
|
|
s.set_auras("player", Some(vec![mark(300.0)]));
|
|
frame(&mut s, 0.1);
|
|
assert_eq!(
|
|
text(&s, "BuffButton0Duration"),
|
|
"5 m",
|
|
"the countdown re-reads the aura every frame; it does not wait for an event"
|
|
);
|
|
// ...and the flash follows it back out of the warning window (20s pulsed, 300s is solid).
|
|
assert!(
|
|
(alpha(&s, "BuffButton0") - 1.0).abs() < 1e-6,
|
|
"no longer inside the 31s warning window, so full alpha"
|
|
);
|
|
|
|
// The same poll is what shows a timer that arrived AFTER the icon did — the fresh-apply case,
|
|
// where the duration packet's stamp lands a frame later than the descriptor delta.
|
|
//
|
|
// **What the icon-first frame looks like MOVED when the bar came onto the 1.12 verbs.** The
|
|
// permanence test used to be `expirationTime > 0`, which cannot tell "no duration, ever" from
|
|
// "the duration has not landed yet" and so drew nothing for both. It is now `untilCancelled` —
|
|
// GetPlayerBuff's second return, derived from the SPELL (`ui_aura::until_cancelled`) — so a
|
|
// timed aura awaiting its stamp is known to be timed, and the reference counts it from `0 s`
|
|
// and pulses it for the frame or two until the packet joins. `mark` is a timed spell, so that
|
|
// is what it does here; the permanent case (a stance) is
|
|
// [`a_timed_aura_counts_down_and_a_permanent_one_shows_no_timer`], and it still shows nothing.
|
|
let mut pending = mark(0.0);
|
|
pending.until_cancelled = false; // a timed spell, stamp not yet arrived
|
|
s.set_auras("player", Some(vec![pending]));
|
|
frame(&mut s, 0.1);
|
|
assert_eq!(
|
|
text(&s, "BuffButton0Duration"),
|
|
"0 s",
|
|
"the reference draws the floor of a timed aura it has no stamp for, not a blank"
|
|
);
|
|
s.set_auras("player", Some(vec![mark(60.0)])); // the stamp joins, still no event
|
|
frame(&mut s, 0.1);
|
|
assert_eq!(
|
|
text(&s, "BuffButton0Duration"),
|
|
"59 s",
|
|
"a duration that lands after the icon still reaches the bar"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **`SHOW_BUFF_DURATIONS`** (decision 1139) — 0255 shipped the durations-shown geometry with no
|
|
/// switch because there was no panel to hang one on, and said the other branch was already there
|
|
/// waiting. This is it: the global — the reference's "0" since 1804, planted "1" by this file's
|
|
/// harness — hides the timer text and closes the 15px gutter each row leaves for it, down to the
|
|
/// 5px the columns use. What it does NOT touch is the warning flash — with the numbers gone, the
|
|
/// pulse is the only thing left saying an aura is about to drop, and the reference pulses from
|
|
/// `BuffButton_OnUpdate` regardless of the setting.
|
|
#[test]
|
|
fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(
|
|
&mut s,
|
|
vec![aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\Spell_Nature_Regeneration",
|
|
true,
|
|
1,
|
|
None,
|
|
20.0, // inside the 31s warning window, so the flash is live either way
|
|
true,
|
|
)],
|
|
);
|
|
frame(&mut s, 0.1);
|
|
assert!(
|
|
shown(&s, "BuffButton0Duration"),
|
|
"timers on — the harness planted the switch"
|
|
);
|
|
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
|
|
s.resolve();
|
|
let shown_gap = s
|
|
.eval::<f64>("return BuffButton0:GetBottom() - BuffButton8:GetTop()")
|
|
.unwrap();
|
|
assert!((shown_gap - 15.0).abs() < 1e-3, "gutter: {shown_gap}");
|
|
|
|
// The switch, applied the way the options row applies it.
|
|
s.run("SHOW_BUFF_DURATIONS = \"0\"; BuffButtons_UpdatePositions()")
|
|
.unwrap();
|
|
frame(&mut s, 0.1);
|
|
assert!(!shown(&s, "BuffButton0Duration"), "no timer drawn");
|
|
assert!(shown(&s, "BuffButton0"), "the icon stays");
|
|
s.resolve();
|
|
let hidden_gap = s
|
|
.eval::<f64>("return BuffButton0:GetBottom() - BuffButton8:GetTop()")
|
|
.unwrap();
|
|
assert!((hidden_gap - 5.0).abs() < 1e-3, "gutter: {hidden_gap}");
|
|
assert!(
|
|
alpha(&s, "BuffButton0") < 1.0,
|
|
"the last-31s pulse is not gated on the timers"
|
|
);
|
|
|
|
// And back: the text is re-written from the poll, not from anything it kept.
|
|
s.run("SHOW_BUFF_DURATIONS = \"1\"; BuffButtons_UpdatePositions()")
|
|
.unwrap();
|
|
frame(&mut s, 0.1);
|
|
assert!(shown(&s, "BuffButton0Duration"));
|
|
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// A mixed bar: three buffs and two debuffs in one insertion-ordered cache.
|
|
fn mixed_bar() -> Vec<AuraState> {
|
|
vec![
|
|
// pos 0 — buff, cancelable, timed
|
|
aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\A",
|
|
true,
|
|
1,
|
|
None,
|
|
120.0,
|
|
true,
|
|
),
|
|
// pos 1 — debuff, Magic, 3 stacks
|
|
aura(
|
|
589,
|
|
"Shadow Word: Pain",
|
|
"Interface\\Icons\\B",
|
|
false,
|
|
3,
|
|
Some("Magic"),
|
|
18.0,
|
|
false,
|
|
),
|
|
// pos 2 — buff, cancelable
|
|
aura(
|
|
1459,
|
|
"Arcane Intellect",
|
|
"Interface\\Icons\\C",
|
|
true,
|
|
1,
|
|
None,
|
|
600.0,
|
|
true,
|
|
),
|
|
// pos 3 — debuff, Poison
|
|
aura(
|
|
2818,
|
|
"Deadly Poison",
|
|
"Interface\\Icons\\D",
|
|
false,
|
|
5,
|
|
Some("Poison"),
|
|
12.0,
|
|
false,
|
|
),
|
|
// pos 4 — buff, NOT cancelable (a stance): right-click must be refused
|
|
aura(
|
|
2457,
|
|
"Battle Stance",
|
|
"Interface\\Icons\\E",
|
|
true,
|
|
1,
|
|
None,
|
|
0.0,
|
|
false,
|
|
),
|
|
]
|
|
}
|
|
|
|
/// **The two index spaces, end to end.** The button `id` is a 0-based ordinal within its own
|
|
/// filter; `GetPlayerBuff` turns it into a CACHE POSITION that is absolute across filters. The bar
|
|
/// is only correct if the two agree: button N of a row must draw the aura the corpus's own walk
|
|
/// finds at that ordinal, and the position it cached must be the one an unfiltered walk reports.
|
|
///
|
|
/// The walk is spelled the way the corpus spells it — `while GetPlayerBuff(i) >= 0 do`
|
|
/// (`_LazyPig/LazyPig.lua:1174`, `Zorlen/Zorlen.lua:2797`) — because `-1`, not nil, is the
|
|
/// terminator, and because the default filter is `HELPFUL|HARMFUL`: the walk must see the debuffs
|
|
/// too or the whole thing silently halves.
|
|
#[test]
|
|
fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(&mut s, mixed_bar());
|
|
frame(&mut s, 0.1);
|
|
|
|
// The corpus walk: five auras, buffs AND debuffs, positions identical to the ordinals under
|
|
// the default filter — and it terminates.
|
|
let visited = s
|
|
.eval::<i64>(
|
|
r#"local i = 0
|
|
while GetPlayerBuff(i) >= 0 do
|
|
assert((GetPlayerBuff(i)) == i, "unfiltered walk is position-identical")
|
|
i = i + 1
|
|
assert(i < 100, "GetPlayerBuff walk did not terminate")
|
|
end
|
|
return i"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(visited, 5, "three buffs and two debuffs, one cache");
|
|
|
|
// Each row filled from its own filter, in cache order — and each button's CACHED position is
|
|
// the absolute one, not its ordinal.
|
|
for (button, position) in [
|
|
("BuffButton0", 0), // helpful ordinal 0 -> position 0
|
|
("BuffButton1", 2), // helpful ordinal 1 -> position 2 (the debuff at 1 is skipped)
|
|
("BuffButton2", 4), // helpful ordinal 2 -> position 4
|
|
("BuffButton16", 1), // harmful ordinal 0 -> position 1
|
|
("BuffButton17", 3), // harmful ordinal 1 -> position 3
|
|
] {
|
|
assert!(shown(&s, button), "{button} draws its aura");
|
|
assert_eq!(
|
|
s.eval::<i64>(&format!("return {button}.buffIndex"))
|
|
.unwrap(),
|
|
position,
|
|
"{button} caches the CACHE POSITION, not its per-filter ordinal"
|
|
);
|
|
}
|
|
// The rows stop where the filter runs out; nothing bleeds across.
|
|
assert!(!shown(&s, "BuffButton3"), "only three buffs");
|
|
assert!(!shown(&s, "BuffButton18"), "only two debuffs");
|
|
assert_eq!(
|
|
s.eval::<i64>("return BuffButton3.buffIndex").unwrap(),
|
|
-1,
|
|
"a miss is -1, the sentinel the corpus terminates on — never nil"
|
|
);
|
|
|
|
// The reads that hang off the position land on the right aura: the 3-stack Magic debuff and
|
|
// the 5-stack Poison one are in the right buttons, tinted from their own dispel classes.
|
|
assert_eq!(text(&s, "BuffButton16Count"), "3");
|
|
assert_eq!(text(&s, "BuffButton17Count"), "5");
|
|
assert_eq!(
|
|
text(&s, "BuffButton0Count"),
|
|
"",
|
|
"a single stack shows nothing"
|
|
);
|
|
let quads = frame(&mut s, 0.1);
|
|
let poison = quads
|
|
.iter()
|
|
.filter_map(|q| match &q.content {
|
|
QuadContent::Texture {
|
|
path: Some(p),
|
|
color: Some(c),
|
|
..
|
|
} if p.ends_with("UI-Debuff-Overlays") => Some(*c),
|
|
_ => None,
|
|
})
|
|
// DebuffTypeColor["Poison"] = 0.00, 0.60, 0.00
|
|
.any(|c| c[0].abs() < 1e-3 && (c[1] - 0.60).abs() < 1e-3 && c[2].abs() < 1e-3);
|
|
assert!(poison, "the second debuff button wears the Poison tint");
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **Right-click cancels by cache position.** `BuffButton_OnClick` is
|
|
/// `CancelPlayerBuff(this.buffIndex)` — the reference's own line — so the button that gets
|
|
/// cancelled is the one under the cursor even when its ordinal and its position differ, which is
|
|
/// the case for every buff after the first debuff.
|
|
///
|
|
/// The gate is the app's, unchanged: a cancelable buff queues its SPELL id, a stance and a debuff
|
|
/// are silent no-ops. That last part is what a per-filter id could not guarantee on its own — here
|
|
/// it is structural, because a harmful button's handle simply names a harmful record.
|
|
#[test]
|
|
fn right_clicking_cancels_the_aura_under_the_cursor_by_its_cache_position() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(&mut s, mixed_bar());
|
|
frame(&mut s, 0.1);
|
|
assert!(s.take_cancel_aura_requests().is_empty());
|
|
|
|
// The SECOND buff button: helpful ordinal 1, cache position 2, spell 1459. A bar that cancelled
|
|
// by ordinal would send 589 — the debuff sitting at position 1.
|
|
s.eval::<()>("this = BuffButton1; BuffButton_OnClick()")
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.take_cancel_aura_requests(),
|
|
vec![1459],
|
|
"the aura under the cursor, not the record at the ordinal"
|
|
);
|
|
|
|
// A stance (helpful, not cancelable), both debuff buttons, and an empty slot: all silent.
|
|
for button in ["BuffButton2", "BuffButton16", "BuffButton17", "BuffButton7"] {
|
|
s.eval::<()>(&format!("this = {button}; BuffButton_OnClick()"))
|
|
.unwrap();
|
|
}
|
|
assert!(
|
|
s.take_cancel_aura_requests().is_empty(),
|
|
"a stance, a debuff and an empty button are all no-ops"
|
|
);
|
|
|
|
// The first buff still cancels normally.
|
|
s.eval::<()>("this = BuffButton0; BuffButton_OnClick()")
|
|
.unwrap();
|
|
assert_eq!(s.take_cancel_aura_requests(), vec![1126]);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **The temporary-enchant row** (ref `BuffFrame_Enchant_OnUpdate`, l.162-233). With nothing
|
|
/// enchanted it hides both slots and parks the bar on its resting point; with a weapon enchanted it
|
|
/// shows the icon, counts the expiry down from MILLISECONDS, and slides the top buff row left to
|
|
/// clear it — while rows 2 and 3, which hang off TempEnchant1 and TemporaryEnchantFrame, stay put.
|
|
#[test]
|
|
fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
// A LOADED bar, deliberately: the enchant slots must be driven by GetWeaponEnchantInfo and by
|
|
// nothing else. The reference builds them from `BuffButtonTemplate` and blanks the four aura
|
|
// handlers with empty `<OnLoad/>`/`<OnEvent/>`/`<OnClick/>` elements; our loader treats an
|
|
// empty handler body as "none given" and so KEEPS the inherited one (`compile_handler` returns
|
|
// None and `apply_scripts` never calls SetScript). Under that idiom TempEnchant1 would take
|
|
// `BuffButton_OnLoad`, register PLAYER_AURAS_CHANGED, and paint the first buff in the corner —
|
|
// and a right-click on it would cancel that aura. Hence the standalone template, and hence a
|
|
// populated bar here: an empty aura list cannot tell the two apart.
|
|
push(&mut s, mixed_bar());
|
|
frame(&mut s, 0.1);
|
|
|
|
// Nothing enchanted: both slots hidden, and the bar sits where it has always sat.
|
|
assert!(
|
|
!shown(&s, "TempEnchant1"),
|
|
"the enchant slots are not buff buttons: no aura may reach them"
|
|
);
|
|
assert!(!shown(&s, "TempEnchant2"));
|
|
// The direct probe, and its exact shape is the point. `BuffButtonTempEnchant` inherits
|
|
// `BuffButtonTemplate` and blanks its three aura handlers with `<OnLoad>`↵`</OnLoad>` — a
|
|
// WHITESPACE body, which 1.12 compiles into a valid empty function rather than storing nil
|
|
// (`SetScript 0x7025c0` tests `text[0]`, and nothing trims — wow-5875-re
|
|
// `xml-script-empty-element.md`). So each handler is still a FUNCTION here, and it is the
|
|
// reference's own no-op rather than `BuffButton_OnLoad`.
|
|
//
|
|
// Asserting `== nil` instead would be asserting a bug: an addon reading
|
|
// `TempEnchant1:GetScript("OnLoad")` gets a function on a real client.
|
|
for handler in ["OnLoad", "OnEvent", "OnClick"] {
|
|
assert!(
|
|
s.eval::<bool>(&format!(
|
|
"return type(TempEnchant1:GetScript(\"{handler}\")) == \"function\""
|
|
))
|
|
.unwrap(),
|
|
"an enchant slot's {handler} is the reference's compiled no-op, not nil"
|
|
);
|
|
}
|
|
// …and it is NOT the buff-button body: an aura event reaching it would paint the first buff
|
|
// into the corner and arm a right-click cancel, which is what a naive "empty means absent"
|
|
// loader produces.
|
|
assert!(
|
|
s.eval::<bool>(
|
|
"return TempEnchant1:GetScript(\"OnLoad\") ~= BuffButton0:GetScript(\"OnLoad\")"
|
|
)
|
|
.unwrap(),
|
|
"the blank displaces the inherited handler rather than sharing it"
|
|
);
|
|
s.resolve();
|
|
let resting = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
|
|
let row2 = s.eval::<f64>("return BuffButton16:GetRight()").unwrap();
|
|
assert!(
|
|
(resting - (1024.0 - 175.0)).abs() < 1e-3,
|
|
"UIParent TOPRIGHT -175: {resting}"
|
|
);
|
|
|
|
// A main-hand enchant, 8 minutes out. The app feeds it the way `ui_char` does.
|
|
s.set_weapon_enchants(
|
|
Some(benilla_ui::script::WeaponEnchant {
|
|
remaining_ms: Some(480_000),
|
|
charges: 0,
|
|
}),
|
|
None,
|
|
);
|
|
frame(&mut s, 0.1);
|
|
assert!(
|
|
shown(&s, "TempEnchant1"),
|
|
"the enchanted weapon takes slot 1"
|
|
);
|
|
assert!(!shown(&s, "TempEnchant2"));
|
|
assert_eq!(
|
|
s.eval::<i64>("return TempEnchant1:GetID()").unwrap(),
|
|
16,
|
|
"the live-API MainHandSlot id, which is what its item tooltip hover needs"
|
|
);
|
|
assert_eq!(
|
|
text(&s, "TempEnchant1Duration"),
|
|
"8 m",
|
|
"480000 ms / 1000 -> 480 s -> SecondsToTimeAbbrev"
|
|
);
|
|
|
|
// The top buff row slid left by one icon plus the 5px gutter; the debuff row did not move.
|
|
//
|
|
// That is true only because the harness has APPLIED the durations setting, and the
|
|
// qualification is worth keeping: `BuffButton16`'s XML anchor is `TOPRIGHT` to `BuffButton8`'s
|
|
// `BOTTOM` — inside BuffFrame, so it would travel with it — and the only thing that re-anchors
|
|
// it to `TemporaryEnchantFrame` is `BuffButtons_UpdatePositions`, which `BuffFrame_OnLoad`
|
|
// does NOT call. A bar that nobody has applied the setting to therefore moves BOTH rows. The
|
|
// window's own file cannot reach that state in production (our Interface page applies it), and
|
|
// finding it was what showed the harness had to apply it too.
|
|
s.resolve();
|
|
let shifted = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
|
|
assert!(
|
|
(resting - shifted - 37.0).abs() < 1e-3,
|
|
"one 32px enchant column + the 5px gutter: {resting} -> {shifted}"
|
|
);
|
|
assert!(
|
|
(s.eval::<f64>("return BuffButton16:GetRight()").unwrap() - row2).abs() < 1e-3,
|
|
"the debuff row hangs off TemporaryEnchantFrame and stays put: {row2} -> {}",
|
|
s.eval::<f64>("return BuffButton16:GetRight()").unwrap()
|
|
);
|
|
|
|
// And back: the enchant drops, the row hides and the bar returns to its resting point.
|
|
s.set_weapon_enchants(None, None);
|
|
frame(&mut s, 0.1);
|
|
assert!(!shown(&s, "TempEnchant1"));
|
|
s.resolve();
|
|
assert!((s.eval::<f64>("return BuffFrame:GetRight()").unwrap() - resting).abs() < 1e-3);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **The idle enchant row rewrites the bar every frame, because the reference's does** — and this
|
|
/// pins that rather than the write-gating it replaced.
|
|
///
|
|
/// Our own `BuffFrame.xml` latched the no-enchant branch on a `benillaCleared` flag after its first
|
|
/// pass; decision 1396 measured that branch at ~14 µs/frame and took the gate as its post-fix lead.
|
|
/// 1751's eighteenth window made the bar `Interface\FrameXML\BuffFrame.xml`, whose
|
|
/// `BuffFrame_Enchant_OnUpdate` opens with an unconditional `TempEnchant1:Hide()` … `BuffFrame:
|
|
/// SetPoint(…)` and returns. So the writes are back, they are the reference's, and 1751 §2 takes
|
|
/// them: an optimisation is not a divergence we get to keep silently once the file is theirs.
|
|
///
|
|
/// **1396's finding is not withdrawn** — the cost is real and the same measurement would find it
|
|
/// again. If it is ever worth paying down, the fix is an adapter over this one handler with a
|
|
/// record beside it, not a re-transcription of the window.
|
|
///
|
|
/// The sentinels below are inverted from what they were: the branch overwrites both, every frame.
|
|
/// The rest of the test is unchanged and is the part that always mattered — the row DRIVEN with
|
|
/// both hands keeps the reference's off-hand-first packing order, and the drop back to empty
|
|
/// re-parks the bar.
|
|
#[test]
|
|
fn an_idle_enchant_row_rewrites_the_bar_as_the_reference_does() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(&mut s, mixed_bar());
|
|
frame(&mut s, 0.1); // first pass: the clear + park write once and latch
|
|
frame(&mut s, 0.1);
|
|
s.resolve();
|
|
let resting = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
|
|
|
|
// Sentinels the no-enchant branch can never leave standing if it still writes.
|
|
s.run(
|
|
r#"BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPRIGHT", -41, 0)
|
|
TempEnchant1:Show()"#,
|
|
)
|
|
.unwrap();
|
|
for _ in 0..10 {
|
|
frame(&mut s, 0.1);
|
|
}
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
assert!(
|
|
!shown(&s, "TempEnchant1"),
|
|
"the reference's idle branch re-hides the slot on every tick"
|
|
);
|
|
let displaced = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
|
|
assert!(
|
|
(displaced - resting).abs() < 1e-3,
|
|
"…and re-parks the bar with it: got {displaced}, resting {resting}"
|
|
);
|
|
|
|
// The control: both hands enchant. Off hand packs slot 1 (the reference's order — main hand
|
|
// sits to its LEFT in slot 2), the row is 64 wide, the bar starts one 5px gutter left of it.
|
|
s.set_weapon_enchants(
|
|
Some(benilla_ui::script::WeaponEnchant {
|
|
remaining_ms: Some(480_000),
|
|
charges: 0,
|
|
}),
|
|
Some(benilla_ui::script::WeaponEnchant {
|
|
remaining_ms: Some(120_000),
|
|
charges: 0,
|
|
}),
|
|
);
|
|
frame(&mut s, 0.1);
|
|
assert!(shown(&s, "TempEnchant1") && shown(&s, "TempEnchant2"));
|
|
assert_eq!(
|
|
s.eval::<i64>("return TempEnchant1:GetID()").unwrap(),
|
|
17,
|
|
"off hand first: slot 1 is the OffHandSlot id"
|
|
);
|
|
assert_eq!(s.eval::<i64>("return TempEnchant2:GetID()").unwrap(), 16);
|
|
assert_eq!(text(&s, "TempEnchant1Duration"), "2 m");
|
|
assert_eq!(text(&s, "TempEnchant2Duration"), "8 m");
|
|
s.resolve();
|
|
assert_eq!(
|
|
s.eval::<f64>("return TemporaryEnchantFrame:GetWidth()")
|
|
.unwrap(),
|
|
64.0,
|
|
"two 32px columns"
|
|
);
|
|
let both = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
|
|
assert!(
|
|
(resting - both - 69.0).abs() < 1e-3,
|
|
"the bar clears both columns + the gutter: {resting} -> {both}"
|
|
);
|
|
|
|
// And back to empty: the transition still writes (the latch re-arms, not re-fires).
|
|
s.set_weapon_enchants(None, None);
|
|
frame(&mut s, 0.1);
|
|
assert!(!shown(&s, "TempEnchant1") && !shown(&s, "TempEnchant2"));
|
|
s.resolve();
|
|
let parked = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
|
|
assert!(
|
|
(parked - resting).abs() < 1e-3,
|
|
"the drop-to-empty pass re-parks the bar: {parked} vs {resting}"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **A settled buff button rewrites its alpha and its duration every frame, because the
|
|
/// reference's does** — the sibling of the enchant-row test above, and the same trade.
|
|
///
|
|
/// Our copy gated both writes on what was last written (the 1396 class, one row down); the
|
|
/// reference's `BuffButton_OnUpdate` polls `GetPlayerBuffTimeLeft` and then writes
|
|
/// `SetAlpha` and `BuffFrame_UpdateDuration` unconditionally, every tick. So the sentinels below —
|
|
/// alpha 0.42 and the text "X", neither of which the handler can produce — are overwritten within
|
|
/// one frame, and that is what is pinned.
|
|
///
|
|
/// **The poll itself was never the divergence** (`GetPlayerBuffTimeLeft` every frame is
|
|
/// load-bearing, decision 0846) and the controls below are unchanged, because they are the part
|
|
/// that describes the WINDOW rather than our implementation of it: the minute rollover drops "5 m"
|
|
/// to "4 m", the warning band turns the number white, and inside the last 31s the pulse ramps the
|
|
/// alpha. Those three are the reference's behaviour and they still hold.
|
|
#[test]
|
|
fn a_settled_buff_button_rewrites_alpha_and_duration_as_the_reference_does() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = harness();
|
|
push(
|
|
&mut s,
|
|
vec![aura(
|
|
1126,
|
|
"Mark of the Wild",
|
|
"Interface\\Icons\\Spell_Nature_Regeneration",
|
|
true,
|
|
1,
|
|
None,
|
|
300.0,
|
|
true,
|
|
)],
|
|
);
|
|
frame(&mut s, 0.1); // the event repaint + the poll's first write ("5 m", alpha 1.0)
|
|
frame(&mut s, 0.1);
|
|
assert_eq!(text(&s, "BuffButton0Duration"), "5 m");
|
|
|
|
s.run(r#"BuffButton0:SetAlpha(0.42); BuffButton0Duration:SetText("X")"#)
|
|
.unwrap();
|
|
for _ in 0..10 {
|
|
frame(&mut s, 0.016);
|
|
}
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
assert!(
|
|
(alpha(&s, "BuffButton0") - 1.0).abs() < 1e-6,
|
|
"the reference's OnUpdate writes a fresh alpha every tick — got {}",
|
|
alpha(&s, "BuffButton0")
|
|
);
|
|
assert_eq!(
|
|
text(&s, "BuffButton0Duration"),
|
|
"5 m",
|
|
"…and re-writes the duration with it, unchanged or not"
|
|
);
|
|
|
|
// Control 1: a REAL text change flows — the abbreviation drops to "4 m" past the boundary.
|
|
s.tick(60.0);
|
|
assert_eq!(
|
|
text(&s, "BuffButton0Duration"),
|
|
"4 m",
|
|
"the minute rollover overwrites the sentinel: the gate passes real changes"
|
|
);
|
|
|
|
// Control 2: inside the last 31s the pulse writes a fresh ramp value every tick, and the
|
|
// number wears the sub-minute HIGHLIGHT white (the band is part of the write tuple).
|
|
s.tick(210.0); // ~29.5s left
|
|
s.resolve();
|
|
let a1 = alpha(&s, "BuffButton0");
|
|
s.tick(0.2);
|
|
let a2 = alpha(&s, "BuffButton0");
|
|
s.tick(0.3);
|
|
let a3 = alpha(&s, "BuffButton0");
|
|
assert!(
|
|
a1 != a2 && a2 != a3,
|
|
"the warning pulse flows through the gate per tick: {a1} {a2} {a3}"
|
|
);
|
|
s.resolve();
|
|
let seconds_text = text(&s, "BuffButton0Duration");
|
|
assert!(
|
|
seconds_text.ends_with(" s"),
|
|
"inside the last minute the timer counts seconds: {seconds_text}"
|
|
);
|
|
let white = s.extract().iter().any(|q| match &q.content {
|
|
QuadContent::Text {
|
|
text: Some(t),
|
|
color: Some(c),
|
|
..
|
|
} => {
|
|
*t == seconds_text
|
|
&& (c[0] - 1.0).abs() < 1e-3
|
|
&& (c[1] - 1.0).abs() < 1e-3
|
|
&& (c[2] - 1.0).abs() < 1e-3
|
|
}
|
|
_ => false,
|
|
});
|
|
assert!(
|
|
white,
|
|
"the sub-minute band rewrote the vertex color to HIGHLIGHT white"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The duration line against the **real shipped strings**, end to end — the leg the engine's own
|
|
/// synthetic-template tests cannot reach.
|
|
///
|
|
/// The engine formats through `tooltip::duration_text` (`0x52fa50`'s ladder, wow-re
|
|
/// §3-BUFF-TIME-FORMAT) over whatever `GlobalStrings.lua` the player's install carries; this runs
|
|
/// that file into a real VM exactly as the boot's `load_global_strings` does, then asserts the
|
|
/// wording that comes back. Every expectation below is a reading the PREVIOUS two-arm formatter got
|
|
/// wrong, so this is the carve's whole delta in one place. Skips without client data.
|
|
#[test]
|
|
fn the_duration_line_reads_the_real_global_strings() {
|
|
let data = benilla_formats::wow_data_or_skip!();
|
|
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let src = chain
|
|
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
|
.expect("GlobalStrings.lua in the chain");
|
|
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
|
s.set_spell_tooltip(
|
|
1459,
|
|
benilla_ui::script::SpellTooltipView {
|
|
name: "Arcane Intellect".into(),
|
|
aura_description: "Intellect increased by 2.".into(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
s.tick(10.0); // GetTime = 10
|
|
s.run(
|
|
r#"
|
|
BENILLA_ANCHOR = CreateFrame("Button", "BF9")
|
|
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
|
|
BENILLA_TIP = CreateFrame("GameTooltip", "TT9")
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// `secs_left` seconds remaining at GetTime = 10, read back as the tooltip's last line.
|
|
let mut line = |secs_left: f64| -> String {
|
|
s.set_auras(
|
|
"player",
|
|
Some(vec![AuraState {
|
|
spell_id: 1459,
|
|
name: Some("Arcane Intellect".into()),
|
|
duration: 86_400.0,
|
|
expiration_time: 10.0 + secs_left,
|
|
helpful: true,
|
|
..Default::default()
|
|
}]),
|
|
);
|
|
s.run(
|
|
r#"
|
|
BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT")
|
|
BENILLA_TIP:SetPlayerBuff(0)
|
|
BENILLA_LAST = TT9TextLeft3:GetText()
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
s.eval::<String>("return BENILLA_LAST").unwrap()
|
|
};
|
|
|
|
// The headline divergence: anything past an hour was reading in MINUTES.
|
|
assert_eq!(line(7_200.0), "2 hours remaining", "was '120 minutes'");
|
|
assert_eq!(line(3_600.0), "1 hour remaining", "the hour edge, singular");
|
|
assert_eq!(
|
|
line(3_599.999),
|
|
"60 minutes remaining",
|
|
"one ms under the hour stays in minutes — no '1 hour' until it is whole"
|
|
);
|
|
// The singular half: the shipped pair really is "%d minute" / "%d minutes".
|
|
assert_eq!(line(60.0), "1 minute remaining", "was '1 minutes'");
|
|
assert_eq!(line(61.0), "2 minutes remaining", "the minute arm ceils");
|
|
// The seconds arm truncates where every arm above it ceils.
|
|
assert_eq!(line(5.4), "5 seconds remaining", "was '6 seconds'");
|
|
assert_eq!(line(1.0), "1 second remaining", "singular at exactly one");
|
|
assert_eq!(
|
|
line(-0.4),
|
|
"0 seconds remaining",
|
|
"the lapsing read, plural at zero"
|
|
);
|
|
// And the top of the ladder, which had no arm at all.
|
|
assert_eq!(line(129_600.0), "2 days remaining", "was '2160 minutes'");
|
|
|
|
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
|
}
|