Files
benilla-octo/crates/benilla-app/src/ui_script/spellbook_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

790 lines
32 KiB
Rust

//! The stock spellbook window (`SpellBookFrame.xml`) over a small synthetic book, engine only.
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
use super::test_ui::load_ui as load_xml;
/// Fire (two spells) and Frost (one); `offset` is a tab's 0-based start in the flat `slots`.
fn book() -> SpellBookState {
SpellBookState {
tabs: vec![
SpellTabView {
name: "Fire".into(),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
offset: 0,
num_spells: 2,
},
SpellTabView {
name: "Frost".into(),
texture: Some("Interface\\Icons\\Spell_Frost_FrostBolt02".into()),
offset: 2,
num_spells: 1,
},
],
slots: vec![
SpellSlotView {
spell_id: 133,
name: "Fireball".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
current: false,
cooldown: None,
..Default::default()
},
SpellSlotView {
spell_id: 2136,
name: "Fire Blast".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Fire_FireBolt02".into()),
passive: false,
current: false,
cooldown: None,
..Default::default()
},
SpellSlotView {
spell_id: 168,
name: "Frost Armor".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Spell_Frost_FrostArmor02".into()),
passive: false,
current: false,
cooldown: None,
..Default::default()
},
],
}
}
/// The centre of a laid-out frame, for a click through the hit test.
fn center(s: &UiScript, name: &str) -> (f32, f32) {
let l: f32 = s.eval(&format!("return {name}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {name}:GetRight()")).unwrap();
let t: f32 = s.eval(&format!("return {name}:GetTop()")).unwrap();
let b: f32 = s.eval(&format!("return {name}:GetBottom()")).unwrap();
((l + r) * 0.5, (t + b) * 0.5)
}
/// One full press/release of `button` on the named frame.
fn click(s: &mut UiScript, name: &str, button: &str) {
s.resolve();
let (x, y) = center(s, name);
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
}
/// The stock spellbook in manifest order, over the multibar grids and `UpdateMicroButtons` its show
/// and hide call (`SpellBookFrame.lua:94-104`, `:186-203`).
pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(w, h);
for f in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, f);
}
s
}
#[test]
fn shipped_spellbook_loads_clean() {
benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
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, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
let frames = load_xml(&s, "Interface\\FrameXML\\SpellBookFrame.xml");
load_xml(&s, "SpellBookAdapters.xml");
assert!(s.errors().is_empty(), "loader errors: {:?}", s.errors());
assert_eq!(
frames, 52,
"window + close + prev/next + 12 spell buttons (each with its Cooldown and AutoCast \
Model children) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame"
);
for name in ["SpellBookFrame", "SpellButton12", "SpellButton1AutoCast"] {
assert!(
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
"{name} exists"
);
}
}
#[test]
fn shipped_spellbook_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
assert!(!s.eval::<bool>("return SpellBookFrame:IsVisible()").unwrap());
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(s.eval::<bool>("return SpellBookFrame:IsVisible()").unwrap());
// Button ids run column-major (`SpellBookFrame.xml:387-486`): SpellButton3 is id 2, and
// SpellButton2 is id 7, past the two-spell Fire tab that `OnLoad` selects.
assert_eq!(
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Fireball"
);
assert_eq!(
s.eval::<String>("return SpellButton1SubSpellName:GetText()")
.unwrap(),
"Rank 1"
);
assert_eq!(
s.eval::<String>("return SpellButton3SpellName:GetText()")
.unwrap(),
"Fire Blast"
);
assert!(
!s.eval::<bool>("return SpellButton2:IsEnabled() ~= 0")
.unwrap(),
"book id 7 is past the 2-spell Fire tab — disabled"
);
s.resolve();
let (x1, y1) = center(&s, "SpellButton1");
s.mouse_button(x1, y1, "LeftButton", true);
s.mouse_button(x1, y1, "LeftButton", false);
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(s.take_spell_casts(), vec![133]);
assert!(s.cursor_payload().is_none(), "a cast never picks up");
s.set_modifiers(true, false, false);
s.mouse_button(x1, y1, "LeftButton", true);
s.mouse_button(x1, y1, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(s.take_spell_casts().is_empty(), "shift-click never casts");
let (kind, _book_id, book_type, spell_id) = s
.eval::<(String, i64, String, i64)>(
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id",
)
.unwrap();
assert_eq!(
(kind.as_str(), book_type.as_str(), spell_id),
("spell", "spell", 133)
);
// An empty slot is hidden (`ActionButton.lua:214-215`) until a held payload opens the grid;
// the engine fires `ACTIONBAR_SHOWGRID` on the next tick.
s.tick(0.016);
assert!(
s.eval::<bool>("return ActionButton1:IsVisible()").unwrap(),
"the held spell opened the empty well"
);
let (ax, ay) = center(&s, "ActionButton1");
s.mouse_button(ax, ay, "LeftButton", true);
s.mouse_button(ax, ay, "LeftButton", false);
assert!(s.errors().is_empty(), "place errors: {:?}", s.errors());
assert!(s.cursor_payload().is_none(), "placed — cursor clears");
assert!(s.eval::<bool>("return HasAction(1)").unwrap());
assert_eq!(s.take_action_sets(), vec![(1, 133)]); // 0x00<<24 | 133
}
/// A running cooldown arms the button's sweep (`SpellBookFrame.lua:359-360`); an on-hold one
/// (enable 0, as Stealth until `SMSG_COOLDOWN_EVENT`) draws none and dims the icon (`:361-365`).
#[test]
fn shipped_spellbook_shows_the_cooldown_pie() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::QuadContent;
let mut s = spellbook_ui(1024.0, 768.0);
s.set_spellbook(book());
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(10.0); // GetTime = 10
// A 10 s cooldown started at t=6, 6 s left.
let mut b = book();
b.slots[0].cooldown = Some((6_000, 10_000, true));
s.set_spellbook(b);
s.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
// `CooldownFrame_SetTimer` arms sequence 0; the next paint's `OnUpdateModel` scrubs it.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let play = super::test_ui::cooldown_play(&s, "SpellButton1Cooldown")
.expect("SpellButton1's cooldown pane is showing");
assert_eq!(
play,
(0, 400),
"4 s elapsed of 10 ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
);
// On hold: `CooldownFrame_SetTimer`'s enable gate draws no sweep, and the icon dims to 0.4.
let mut b = book();
b.slots[0].cooldown = Some((6_000, 10_000, false));
s.set_spellbook(b);
s.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
s.resolve();
let quads = s.extract();
assert!(
!quads
.iter()
.any(|q| { s.quad_owner_name(q.target).as_deref() == Some("SpellButton1Cooldown") }),
"an on-hold cooldown draws no sweep"
);
let icon_color = quads.iter().find_map(|q| {
if s.quad_owner_name(q.target).as_deref() != Some("SpellButton1") {
return None;
}
match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("Spell_Fire_FlameBolt") => Some(*color),
_ => None,
}
});
let c = icon_color.expect("icon quad").expect("vertex color set");
assert_eq!((c[0], c[1], c[2]), (0.4, 0.4, 0.4), "the on-hold 40% dim");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// An empty slot is a disabled button that keeps its `UI-Quickslot2` ring (whitened at
/// `SpellBookFrame.lua:337`): created enabled (`0x7786a0` ends in `SetState(NORMAL)`), and
/// `SetState` (`0x779790`) has no step that takes the ring off on `Disable()`. `SetChecked(0)`
/// unchecks, so there is no `CheckButtonHilight` glow.
#[test]
fn shipped_spellbook_empty_slot_draws_its_background_and_socket_ring() {
benilla_formats::wow_data_or_skip!();
let mut s = spellbook_ui(640.0, 700.0);
// One spell: SpellButton5, id 3, takes the `id > offset + numSpells` disable path.
let mut b = book();
b.tabs.truncate(1);
b.tabs[0].num_spells = 1;
b.slots.truncate(1);
s.set_spellbook(b);
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(0.05);
s.resolve();
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
let mut slot5_paths: Vec<String> = Vec::new();
for eq in s.extract() {
if s.quad_owner_name(eq.target).as_deref() == Some("SpellButton5") {
if let benilla_ui::script::QuadContent::Texture { path: Some(p), .. } = &eq.content {
slot5_paths.push(p.clone());
}
}
}
assert_eq!(
slot5_paths,
vec![
"Interface\\Spellbook\\UI-Spellbook-SpellBackground".to_string(),
"Interface\\Buttons\\UI-Quickslot2".to_string(),
],
"an empty slot keeps its socket ring and gains no checked glow"
);
// `SetChecked(0)` unchecks: a number is coerced, not tested for Lua truthiness.
assert!(!s
.eval::<bool>("return SpellButton5:GetChecked() and true or false")
.unwrap());
}
/// `SetChecked`'s coercion over the arguments the stock call sites pass
/// (`SpellBookFrame.lua:132`, `:134`, `:268`, `:296-303`, `:336`).
#[test]
fn set_checked_uses_blizzard_bool_coercion() {
benilla_formats::wow_data_or_skip!();
let s = spellbook_ui(1024.0, 768.0);
for (arg, want) in [
("1", true),
("0", false),
("nil", false),
("true", true),
("false", false),
("\"true\"", true),
("\"false\"", false),
("\"1\"", true),
("\"0\"", false),
("\"junk\"", false),
] {
let got = s
.eval::<bool>(&format!(
"SpellButton1:SetChecked({arg}) \
return SpellButton1:GetChecked() and true or false"
))
.unwrap();
assert_eq!(got, want, "SetChecked({arg})");
}
}
/// A pet book: Growl (autocast on, cooling down), Claw (autocast off), Avoidance (passive).
fn pet_book() -> benilla_ui::script::PetBookState {
benilla_ui::script::PetBookState {
token: Some("PET".into()),
slots: vec![
SpellSlotView {
spell_id: 2649,
name: "Growl".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Ability_Physical_Taunt".into()),
cooldown: Some((9400, 5000, true)),
autocast: Some((true, true)),
packed: 0xC100_0000 | 2649,
..Default::default()
},
SpellSlotView {
spell_id: 16827,
name: "Claw".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
autocast: Some((true, false)),
packed: 0x8100_0000 | 16827,
..Default::default()
},
SpellSlotView {
spell_id: 3025,
name: "Avoidance".into(),
texture: Some("Interface\\Icons\\Spell_Nature_SpiritArmor".into()),
passive: true,
autocast: Some((false, false)),
packed: 0x0100_0000 | 3025,
..Default::default()
},
],
}
}
#[test]
fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
benilla_formats::wow_data_or_skip!();
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
// ── No pet: no toggle row, and no pet book (`SpellBookFrame.lua:11-14`) ──────────────────
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return SpellBookFrameTabButton1:IsVisible()")
.unwrap(),
"with no pet spells the ref hides the whole toggle row"
);
s.run("ToggleSpellBook(BOOKTYPE_PET)").unwrap();
assert!(
s.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_SPELL")
.unwrap(),
"asking for a pet book you have not got does nothing — not even close the window"
);
assert!(s.eval::<bool>("return SpellBookFrame:IsVisible()").unwrap());
// ── A pet arrives: the row appears on the next repaint ────────────────────────────────────
s.set_pet_book(pet_book());
s.fire_event("SPELLS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "repaint errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return SpellBookFrameTabButton1:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return SpellBookFrameTabButton2:GetText()")
.unwrap(),
"Pet",
"the label is PET_TYPE_<token>, not the token"
);
// ── Click the pet tab ─────────────────────────────────────────────────────────────────────
click(&mut s, "SpellBookFrameTabButton2", "LeftButton");
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_PET")
.unwrap());
assert_eq!(
s.eval::<String>("return SpellBookTitleText:GetText()")
.unwrap(),
"Pet",
"the window title becomes the pet's, not SPELLBOOK"
);
assert!(
!s.eval::<bool>("return SpellBookSkillLineTab1:IsVisible()")
.unwrap(),
"the pet book has no skill lines — the whole strip hides (ref l.124)"
);
// The pet book's ids are the button ids (`SpellBookFrame.lua:460-461`): SpellButton3 is Claw.
assert_eq!(
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Growl"
);
assert_eq!(
s.eval::<String>("return SpellButton3SpellName:GetText()")
.unwrap(),
"Claw"
);
// The overlay follows `GetSpellAutocast`'s first return (can it), the shine model its second
// (is it on) (`SpellBookFrame.lua:367-377`).
assert!(s
.eval::<bool>("return SpellButton1AutoCastable:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return SpellButton5AutoCastable:IsVisible()")
.unwrap(),
"a passive is not autocastable"
);
assert!(s
.eval::<bool>("return SpellButton1AutoCast:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return SpellButton3AutoCast:IsVisible()")
.unwrap());
// Deviation (SpellBookAdapters.xml): the brackets are 71.53 square and the shine 37x37 at
// scale 1.48, both centred on the button, so the two share a centre; the reference's are 60,
// and 36x36 at CENTER (1,1) and 1.22 (`SpellBookFrame.xml:121-147`), which reads off-centre.
let br: Vec<f32> = [
"GetWidth()",
"GetHeight()",
"GetLeft() + SpellButton1AutoCastable:GetWidth() / 2 - (SpellButton1:GetLeft() + SpellButton1:GetWidth() / 2)",
"GetBottom() + SpellButton1AutoCastable:GetHeight() / 2 - (SpellButton1:GetBottom() + SpellButton1:GetHeight() / 2)",
]
.iter()
.map(|e| {
s.eval::<f32>(&format!("return SpellButton1AutoCastable:{e}"))
.unwrap()
})
.collect();
assert!(
(br[0] - 71.53).abs() < 0.01 && (br[1] - 71.53).abs() < 0.01,
"brackets are {}x{}, benilla draws them at 71.53 so the art reaches this button's corners",
br[0],
br[1]
);
assert!(
br[2].abs() < 0.01 && br[3].abs() < 0.01,
"brackets sit ({}, {}) off the button's centre; the ref centres them exactly",
br[2],
br[3]
);
let geom: Vec<f32> = ["GetWidth", "GetHeight", "GetModelScale"]
.iter()
.map(|m| {
s.eval::<f32>(&format!("return SpellButton1AutoCast:{m}()"))
.unwrap()
})
.collect();
assert!(
(geom[0] - 37.0).abs() < 0.01 && (geom[1] - 37.0).abs() < 0.01,
"shine pane is {geom:?}, expected 37x37 (benilla squares it on the button)"
);
assert!(
(geom[2] - 1.48).abs() < 0.001,
"shine pane's model scale is {}, expected 1.48 (the pet button's rim ratio)",
geom[2]
);
let dx = s
.eval::<f32>("return SpellButton1AutoCast:GetLeft() - SpellButton1:GetLeft()")
.unwrap();
let dy = s
.eval::<f32>("return SpellButton1AutoCast:GetBottom() - SpellButton1:GetBottom()")
.unwrap();
assert!(
dx.abs() < 0.01 && dy.abs() < 0.01,
"shine pane sits at ({dx}, {dy}) inside the button; benilla squares it on the button so it \
is concentric with the brackets — the ref's +1,+1 is what read as a top/right bias"
);
// ── The clicks ────────────────────────────────────────────────────────────────────────────
click(&mut s, "SpellButton1", "LeftButton");
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(s.take_pet_spell_casts(), vec![2649]);
assert!(s.take_spell_casts().is_empty());
assert!(s.take_pet_spell_autocasts().is_empty());
// Right: autocast, never a cast (`SpellBookFrame.lua:284-285`).
click(&mut s, "SpellButton3", "RightButton");
assert!(
s.errors().is_empty(),
"right-click errors: {:?}",
s.errors()
);
assert_eq!(s.take_pet_spell_autocasts(), vec![16827]);
assert!(
s.take_pet_spell_casts().is_empty(),
"a right-click on the pet page must not also cast"
);
// ── Two reference quirks, pinned ───────────────────────────────────────────────────────────
// The pet page ignores its page number: `SpellBook_GetSpellID`'s pet arm is a bare `return id`
// (`SpellBookFrame.lua:460-461`).
assert_eq!(s.eval::<i64>("return SpellBook_GetSpellID(1)").unwrap(), 1);
s.run(r#"SPELLBOOK_PAGENUMBERS["pet"] = 2"#).unwrap();
assert_eq!(
s.eval::<i64>("return SpellBook_GetSpellID(1)").unwrap(),
1,
"DO NOT FIX: the ref's pet arm has no page term"
);
// …and Next writes `SPELLBOOK_PAGENUMBERS[selectedSkillLine]` on both books
// (`SpellBookFrame.lua:423`), so the pet page stays put.
s.run(r#"SPELLBOOK_PAGENUMBERS["pet"] = 1"#).unwrap();
click(&mut s, "SpellBookNextPageButton", "LeftButton");
assert!(s.errors().is_empty(), "page errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>(r#"return SPELLBOOK_PAGENUMBERS["pet"]"#)
.unwrap(),
1,
"DO NOT FIX: the ref writes SPELLBOOK_PAGENUMBERS[selectedSkillLine] on both books"
);
assert_eq!(
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Growl",
"so the page does not turn"
);
// ── The pet leaves: the window closes and reverts (`SpellBookFrame.lua:147-151`) ─────────
s.set_pet_book(benilla_ui::script::PetBookState::default());
s.fire_event("SPELLS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "teardown errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return SpellBookFrame:IsVisible()").unwrap(),
"a pet book with no pet closes rather than showing an empty page"
);
assert!(s
.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_SPELL")
.unwrap());
}
/// The macro-editor fork (`SpellBookFrame.lua:271-283`, `Blizzard_MacroUI.lua:122-126`): only a
/// shift-click writes, appending a whole `/cast <name>[(<rank>)]` line with no separator. The
/// `/cast` line is 1.12's; the bare-name insert is a later client's.
#[test]
fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
CREATE_MACROS = "Create Macros"
GENERAL_MACROS = "General Macros"
CHARACTER_SPECIFIC_MACROS = "%s Specific Macros"
ENTER_MACRO_LABEL = "Enter Macro Commands:"
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"
MACRO_POPUP_CHOOSE_ICON = "Choose an Icon:"
CHANGE_MACRO_NAME_ICON = "Change Name/Icon"
DELETE = "Delete" NEW = "New" EXIT = "Exit" CANCEL = "Cancel" OKAY = "Okay"
MACROS = "Macros"
TOOLTIP_DEFAULT_COLOR = { r = 1.0, g = 1.0, b = 1.0 }
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
"#,
)
.unwrap();
// The macro window's character tab formats `UnitName("player")` in its OnLoad.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
for file in [
r"Interface\FrameXML\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\Cooldown.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The icon chooser's scroll frame inherits `ClassTrainerListScrollFrameTemplate`.
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// `ExhaustionTick_Update` reads `ReputationWatchBar` (`MainMenuBar.lua:52`, `:69`), which
// `ReputationFrame.xml` declares, after the templates its check boxes inherit.
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
// The multibar grids the spellbook toggles, whose file wants the options window's uvars.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, file);
}
// A LoadOnDemand addon, seated off the chain and loaded by stock `MacroFrame_LoadUI`
// (`UIParent.lua:178`), as the app does.
super::test_ui::seat_chain_addon(&mut s, "Blizzard_MacroUI");
s.run("MacroFrame_LoadUI()").unwrap();
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// [`book`] plus a passive third Fire spell: id 3, on SpellButton5 in the column-major grid.
let mut b = book();
b.tabs[0].num_spells = 3;
b.tabs[1].offset = 3;
b.slots.insert(
2,
SpellSlotView {
spell_id: 168,
name: "Frost Warding".into(),
texture: Some("Interface\\Icons\\Spell_Frost_FrostWard".into()),
passive: true,
..Default::default()
},
);
s.set_spellbook(b);
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
// Nothing is selected on open (`MacroFrame_Update` never assigns a selection), so the details
// pane waits for a click.
s.run("MacroButton1:Click()").unwrap();
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
let body =
|s: &UiScript| -> String { s.eval::<String>("return MacroFrameText:GetText()").unwrap() };
assert!(
s.eval::<bool>("return MacroFrameText:IsVisible()").unwrap(),
"the editor's body box is up — the ref's own `MacroFrame_AddMacroLine` gate"
);
assert_eq!(body(&s), "");
// ── A plain right click still casts and writes nothing ─────────────────────────────────────
click(&mut s, "SpellButton1", "RightButton");
assert!(
s.errors().is_empty(),
"right-click errors: {:?}",
s.errors()
);
assert_eq!(s.take_spell_casts(), vec![133], "a right-click casts");
assert_eq!(
body(&s),
"",
"an UNSHIFTED click must never reach the macro editor (ref l.271 tests shift first)"
);
assert!(s.cursor_payload().is_none());
click(&mut s, "SpellButton1", "LeftButton");
assert_eq!(s.take_spell_casts(), vec![133]);
assert_eq!(body(&s), "");
// ── Shift-click appends a whole `/cast` line and never casts ──────────────────────────────
s.set_modifiers(true, false, false);
click(&mut s, "SpellButton1", "LeftButton");
assert!(
s.errors().is_empty(),
"shift-click errors: {:?}",
s.errors()
);
assert_eq!(
body(&s),
"/cast Fireball(Rank 1)",
"ref l.276: SLASH_CAST1 + name + the rank in parens"
);
assert!(
s.take_spell_casts().is_empty(),
"the macro arm replaces the cast, it does not add to it"
);
assert!(
s.cursor_payload().is_none(),
"with the editor open a shift-click writes instead of picking up"
);
// The box's `OnTextChanged` sets the dirty flag, deferred to the next drain.
s.tick(0.0);
assert!(
s.eval::<bool>("return MacroFrame.textChanged == 1")
.unwrap(),
"the write goes through the box's own OnTextChanged, so the window is dirty"
);
// A second line runs straight on: the reference appends with no separator (`GetText()..line`,
// `Blizzard_MacroUI.lua:124`).
click(&mut s, "SpellButton3", "RightButton"); // shift outranks the button
assert_eq!(
body(&s),
"/cast Fireball(Rank 1)/cast Fire Blast(Rank 1)",
"ref MacroFrame.lua:95 — appended, unseparated"
);
assert!(s.take_spell_casts().is_empty());
// A passive writes nothing and does not pick up: its guard sits inside the macro arm
// (`SpellBookFrame.lua:274`).
let before = body(&s);
click(&mut s, "SpellButton5", "LeftButton");
assert_eq!(body(&s), before, "a passive is not a castable line");
assert!(s.cursor_payload().is_none());
// The name/icon popup hides the body box, which `MacroFrame_AddMacroLine` checks.
s.run("MacroNewButton_OnClick()").unwrap();
assert!(!s.eval::<bool>("return MacroFrameText:IsVisible()").unwrap());
click(&mut s, "SpellButton1", "LeftButton");
assert!(s.errors().is_empty(), "popup-open errors: {:?}", s.errors());
assert!(s.cursor_payload().is_none(), "still not a pickup");
s.run("MacroPopupFrame:Hide()").unwrap();
s.run("MacroFrame_Update()").unwrap();
assert_eq!(body(&s), before, "nothing landed while the popup was up");
// ── Editor hidden: shift-click picks up again (`SpellBookFrame.lua:281-282`) ───────────────
s.run("HideUIPanel(MacroFrame)").unwrap();
click(&mut s, "SpellButton1", "LeftButton");
s.set_modifiers(false, false, false);
assert!(s.errors().is_empty(), "pickup errors: {:?}", s.errors());
let (kind, spell_id) = s
.eval::<(String, i64)>("local k, _, _, id = GetCursorInfo() return k, id")
.unwrap();
assert_eq!((kind.as_str(), spell_id), ("spell", 133));
assert!(s.take_spell_casts().is_empty());
}