Files
benilla-octo/crates/benilla-app/src/ui_script/buff_tests.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

932 lines
31 KiB
Rust

//! The stock buff bar (`BuffFrame.xml`) under a stubbed aura feed. A button's `id` is an ordinal
//! within its filter; `GetPlayerBuff` maps it to the cache position every other buff verb takes.
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, which `SecondsToTimeAbbrev` formats unguarded.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `BuffButton_Update` asks `GameTooltip:IsOwned(this)` unguarded (`BuffFrame.lua:104`).
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// `SecondsToTimeAbbrev` comes with `UIParent.xml` (`UIParent.lua:1034`).
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// Timers on for these tests; the 1.12 default is "0" (`UIOptionsFrame.lua:104`).
s.run("SHOW_BUFF_DURATIONS = \"1\"").unwrap();
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
// As the app does: `BuffFrame_OnLoad` never calls `BuffButtons_UpdatePositions`, which 1.12
// runs on `VARIABLES_LOADED` (`UIOptionsFrame.lua:206`).
super::manifest::apply_buff_durations(&s).unwrap();
s
}
/// An [`AuraState`]: `expiration_time` 0 is permanent, and the harness's `GetTime()` starts at 0.
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,
slot: if helpful { 0 } else { 32 },
cancelable,
// The feed takes this from the spell (`ui_aura::until_cancelled`); here, from the expiry.
until_cancelled: expiration_time == 0.0,
channeled: false,
}
}
/// Push the list and fire `PLAYER_AURAS_CHANGED`, as the app's feed does (`BuffFrame.lua:113`).
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 texture quad whose path ends in `leaf`.
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);
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");
assert!(
tex_quad(&quads, "Spell_Nature_Regeneration").is_some(),
"buff icon drew"
);
assert!(
tex_quad(&quads, "Spell_Shadow_ShadowWordPain").is_some(),
"debuff icon drew"
);
// `DebuffTypeColor["Magic"]` is 0.20, 0.60, 1.00 (`BuffFrame.lua:11`).
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 overlay crop of `BuffFrame.xml:72`.
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:?}"),
}
// The count shows only above 1 (`BuffFrame.lua:97`).
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![
aura(
2457,
"Battle Stance",
"Interface\\Icons\\Ability_Warrior_OffensiveStance",
true,
1,
None,
0.0,
false,
),
// Timed, 120 s out: 119.9 s left after the tick, which rounds up to "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 31 s `BUFF_WARNING_TIME` window, so full alpha.
aura(
1,
"Long",
"Interface\\Icons\\INV_Misc_QuestionMark",
true,
1,
None,
300.0,
true,
),
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"
);
// `CancelPlayerBuff(this.buffIndex)`; the app sends `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");
// An empty list and a re-fire hide the button and its timer (`BuffFrame.lua:64-67`).
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());
}
/// The countdown polls `GetPlayerBuffTimeLeft` every frame (`BuffFrame.lua:129-130`), so a refresh
/// (recast, pushback, the replay at world entry), which fires no aura event, still reaches the bar.
#[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,
)
};
// 20 s out: 19.9 s after the tick, which `%d` truncates to "19 s".
push(&mut s, vec![mark(20.0)]);
frame(&mut s, 0.1);
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
// The refresh: a new expiry and no event.
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"
);
assert!(
(alpha(&s, "BuffButton0") - 1.0).abs() < 1e-6,
"no longer inside the 31s warning window, so full alpha"
);
// A fresh apply, stamp a frame behind the icon: `untilCancelled` comes from the spell, so a
// timed aura with no stamp yet shows "0 s" and pulses until the stamp joins.
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` "0" hides the timers and closes each row's 15px gutter to 5px
/// (`BuffFrame.lua:152-160`); the warning pulse runs regardless of it (`BuffFrame.lua:131-135`).
#[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 31 s warning window, so the flash runs 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}");
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"
);
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());
}
/// Positions 0 to 4 of one cache: buff, debuff (Magic, 3), buff, debuff (Poison, 5), a stance.
fn mixed_bar() -> Vec<AuraState> {
vec![
aura(
1126,
"Mark of the Wild",
"Interface\\Icons\\A",
true,
1,
None,
120.0,
true,
),
aura(
589,
"Shadow Word: Pain",
"Interface\\Icons\\B",
false,
3,
Some("Magic"),
18.0,
false,
),
aura(
1459,
"Arcane Intellect",
"Interface\\Icons\\C",
true,
1,
None,
600.0,
true,
),
aura(
2818,
"Deadly Poison",
"Interface\\Icons\\D",
false,
5,
Some("Poison"),
12.0,
false,
),
aura(
2457,
"Battle Stance",
"Interface\\Icons\\E",
true,
1,
None,
0.0,
false,
),
]
}
/// A button's `id` is an ordinal within its filter and `GetPlayerBuff` maps it to a position
/// shared across filters. The addon walk `while GetPlayerBuff(i) >= 0 do` stops on `-1`, not nil,
/// and under the default `HELPFUL|HARMFUL` filter it sees the debuffs too.
#[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);
// Unfiltered, the walk visits all five, each position equal to its ordinal.
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 button caches the position, 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"
);
}
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"
);
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"]` is 0.00, 0.60, 0.00 (`BuffFrame.lua:14`).
.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());
}
/// `BuffButton_OnClick` is `CancelPlayerBuff(this.buffIndex)` (`BuffFrame.lua:149`): it cancels by
/// position, and a stance or a debuff queues nothing.
#[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());
// Ordinal 1 is position 2 (spell 1459); by ordinal it would cancel 589, the debuff at 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"
);
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"
);
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 (`BuffFrame.lua:162-233`): idle, it hides both slots and parks the
/// bar; enchanted, it counts down from milliseconds and slides the top buff row left.
#[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();
// Auras up, so one leaking into an enchant slot would show.
push(&mut s, mixed_bar());
frame(&mut s, 0.1);
assert!(
!shown(&s, "TempEnchant1"),
"the enchant slots are not buff buttons: no aura may reach them"
);
assert!(!shown(&s, "TempEnchant2"));
// `BuffFrame.xml:101-109` blanks three inherited handlers with whitespace bodies, which 1.12
// compiles to empty functions, not nil: `0x7025c0` clears only a NULL or empty body.
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"
);
}
// Nor the inherited body, which would paint the first buff into the slot and arm its cancel.
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}"
);
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 debuff row stays only because `BuffButtons_UpdatePositions` re-anchored it to
// `TemporaryEnchantFrame`; its XML anchor is inside `BuffFrame`, which moves.
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()
);
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 branch hides both slots and re-parks the bar every frame, unconditionally
/// (`BuffFrame.lua:166-173`), so the sentinels below are overwritten.
#[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);
frame(&mut s, 0.1);
s.resolve();
let resting = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
// Sentinels the idle branch must overwrite.
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}"
);
// Both hands: off hand in slot 1, main hand in slot 2 to its left (`BuffFrame.lua:179-223`).
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}"
);
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());
}
/// `BuffButton_OnUpdate` writes the alpha and the duration every tick, unconditionally
/// (`BuffFrame.lua:130-138`), so the sentinels (alpha 0.42, text "X") are overwritten.
#[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 and 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"
);
s.tick(60.0);
assert_eq!(
text(&s, "BuffButton0Duration"),
"4 m",
"the minute rollover overwrites the sentinel: the gate passes real changes"
);
// In the last 31 s the pulse ramps each tick; under a minute the number is
// `HIGHLIGHT_FONT_COLOR` white (`BuffFrame.lua:251-252`).
s.tick(210.0); // ~29.5 s 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 aura tooltip's duration line over the install's own `GlobalStrings.lua`, formatted by
/// `tooltip::duration_text` on the reference's ladder (`0x52fa50`).
#[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:SetWidth(10); BENILLA_ANCHOR:SetHeight(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()
};
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"
);
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"
);
assert_eq!(line(129_600.0), "2 days remaining", "was '2160 minutes'");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}