Files
benilla-octo/crates/benilla-app/src/ui_script/spellbook_tests.rs
T
SamandClaude Fable 5.1 2ec92beeed the chat and spellbook go stock, and hidden props park their meshes
- the chat window is the reference's own `ChatFrame.xml` and `FloatingChatFrame.xml`, run off the player's chain; our 2216-line transcription is deleted, the feed fires `CHAT_MSG_*` and the reference's `ChatFrame_OnEvent` prints — no app-side composition into the windows, no routing table, no whisper chime or flash of ours, and the transcribed edit-box machine (sticky type, tell ring, Tab cycle) retires with it; the verbs the two files call are built to the bytes — the whole `0x98` chat-window record and its eight verbs, the 94-entry chat-type colour registry, the language walk, the channel verbs as a command queue, the slash handlers' emote/roll/assist/uninvite/console/logging verbs; `chat-cache.txt` is written and read in the reference's whole grammar, the 68-entry message-group table in flag order and the no-file boot included, and the chat bindings and macro lines run through the reference's own arms (1948)
- the spellbook is the reference's own `SpellBookFrame.xml`; our 965-line transcription is deleted, and two quirks it reproduced by hand (the pet page ignores its page number, its arrows write the spell book's counter) now come from the reference's lines; one adapter remains because the reference's autocast shine is a `<Model>` that draws no pixels in this engine — the script puts the token-texture shine beside each button's model and carries it with the model's shown state; the nine spell-slot bindings (`GetSpellName`, `CastSpell`, `PickupSpell` and their kin) now parse their arguments the way the reference's shared marshaller `0x4b3ec0` does — a numeric arg 1, a mandatory arg 2, the index gated to [0, 0x400) with "Invalid spell slot in <Verb>" on a miss, "pet" matched without case — where ours raised mlua's own message on a negative id, answered nil for 0 and above 1024, and read the wrong book for "PET" (1952)
- a long-hidden world model part parks its `Mesh3d`: the entity path keeps the batches the retained pass declines resident — 5.3k hidden submeshes at any instant at the Stormwind auction house — and Bevy's sweeps walk every `Mesh3d` whether it draws or not, so a part parks after 30 consecutive hidden frames and unparks the frame its verdict turns visible; every world model part takes part, units' bodies included, while a booth's mirror parts stay out; interleaved pairs read an alone Stormwind 0.1–0.4 ms lighter, and `WOW_NO_MESH_PARK=1` is the A/B; the audio thread's realtime class was the standing suspect for the raid's `cpu_p99` spikes with sound on, and `WOW_AUDIO_NO_RT=1` across two interleaved Blackrock pairs read no difference, so the lever stays as an instrument (1949); and a no-merge prop batch now says which rule declined it — animated (1551 at the auction house) or transparent, not order-free (340) (1951)
- the probe reads CPU per thread across its window (user and system split, because a user-mode sampler never sees kernel time) and the main thread's CPU inside the app's frame beside its whole-frame CPU; together they place the vsync tax every player pays by default — cpu_ms 10.7 uncapped against 12.5 under vsync, alone in Stormwind, with no system growing and no thread spinning: it lands on the main thread, in user time, inside the schedules, the same systems each running slower, and a low-priority spinner holding one core busy erases it to the last decimal (vsync + spinner 10.18 against uncapped 10.17), which is frequency scaling — under vsync the cores idle deeper between frames and the next burst starts at a lower clock; nothing in the client is the cause and nothing in the client changes for it (1950)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:55:55 +07:00

847 lines
36 KiB
Rust

//! The shipped **spellbook window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/SpellBookFrame.xml` loaded behind `Fonts.xml`/`UiPanels.xml`/`GameTooltip.xml`
//! (plus `ActionBar.xml` for the cross-window place test) and fed a small synthetic book —
//! mirroring `character_tests.rs`'s/`action_bar_tests.rs`'s harness (decision 0216 §8, slice 5).
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
use super::test_ui::load_ui as load_xml;
/// Two tabs' worth of a small book: "Fire" (Fireball, Fire Blast — slots 0-1) and "Frost" (Frost
/// Armor — slot 2), the flat `slots` in tab order. `offset` is each tab's 0-based start index into
/// `slots` (`benilla-ui`'s own book-id seam doc) — tab 1's is 0, tab 2's is 2 (right after tab 1's
/// two spells).
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 real mouse click through the hit-test — the file's own
/// idiom, lifted out of `shipped_spellbook_drives_end_to_end` so the pet test shares it.
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 reference's spellbook needs the action-bar chain beneath it: `SpellBookFrame_OnShow` and
/// `_OnHide` call `MultiActionBar_ShowAllGrids`/`HideAllGrids` (MultiActionBars.lua, whose file
/// wants the options window's uvars), `OnShow` calls `UpdateMicroButtons`, and the window docks
/// in UiPanels' left slot. One chain, in the manifest's order, for every test that opens it.
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",
"UIParent.xml",
"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",
"MoneyFrame.xml",
"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",
"UiPanels.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"MicroMenu.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, f);
}
s
}
/// The loader itself: every file the window depends on parses and materializes with no errors —
/// the window + close + prev/next page buttons + 12 spell buttons (each with a Cooldown and an
/// AutoCast Model child) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame.
#[test]
fn shipped_spellbook_loads_clean() {
let s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
// The reference's own file, off the chain, with the one adapter it needs from this engine.
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",
"SpellButton1Shine",
] {
assert!(
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
"{name} exists"
);
}
}
/// The whole contract in one end-to-end drive: `ToggleSpellBook` (the 'P' binding's entry point)
/// opens through `ShowUIPanel`, tab 1's page renders names + ranks, a plain click casts (drains
/// `take_spell_casts`, no cursor payload), a shift-click picks it up instead (the modifier
/// mirror), and the held spell places on an action button through the SAME slice-4 machinery a
/// bar-to-bar drag uses — packing kind 0x00 (SPELL) with the spell id.
#[test]
fn shipped_spellbook_drives_end_to_end() {
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());
// Tab 1 ("Fire", selected by default — SpellBookFrame_OnLoad's own SkillLineTab_OnClick(1)):
// book id 1 (SpellButton1, id="1") shows Fireball; book id 2 (SpellButton3,
// id="2" — the SECOND row of column 1, not the second on-screen button) shows Fire Blast —
// the ref's own column-major id assignment (`id="1"`/`"7"`/`"2"`/`"8"`/…, this file's grid
// comment), not left-to-right on-screen order. SpellButton2 (id="7", book id 7) is
// past this 2-spell tab's end and stays disabled/hidden.
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");
// A plain click CASTS (drains the intent) — never picks up.
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");
// A shift-click PICKS UP instead (the modifier mirror) — never casts.
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)
);
// Place the held spell onto action button 1 — the SAME slice-4 machinery
// (`cursor::bar::place_action`) a bar-to-bar drag uses: a plain click on an action button
// routes through UseAction's checkCursor=1 fork to a place. Packs kind 0x00 (SPELL, decision
// 0216 §1) with the spell id — `action_sets` is the app's own CMSG_SET_ACTION_BUTTON queue.
// An EMPTY main-bar slot is hidden under the reference (ActionButton.lua:69-70) until a held
// payload opens the grid: the engine derives ACTIONBAR_SHOWGRID from the cursor's edge and
// fires it on the next tick, as the app ticks between any two mouse events.
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
}
/// The cooldown pie through the REAL shipped XML (the once-deferred half of slice 5): a pushed
/// per-slot triple + `SPELL_UPDATE_COOLDOWN` arms the button's Cooldown widget mid-sweep (ref
/// SpellButton_UpdateButton l.359-360), and an on-hold triple (enable 0 — Stealth/Feign Death
/// parked until SMSG_COOLDOWN_EVENT) keeps the widget hidden and dims the icon to 40% (l.361-365).
#[test]
fn shipped_spellbook_shows_the_cooldown_pie() {
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 running 10 s cooldown started at t=6 (6 s left): the event re-read arms the sweep.
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![]);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let sweep = s.extract().into_iter().find_map(|q| {
if s.quad_owner_name(q.target).as_deref() != Some("SpellButton1Cooldown") {
return None;
}
match q.content {
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
_ => None,
}
});
let (fraction, flash) = sweep.expect("SpellButton1's Cooldown widget is showing");
assert!(
(fraction - 0.4).abs() < 1e-3,
"4 s elapsed of 10 ⇒ the sweep sits at 40%, got {fraction}"
);
assert_eq!(flash, None);
// An on-hold triple: no sweep (CooldownFrame_SetTimer's enable gate), the icon dims to 40%.
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());
}
/// The empty-slot LOOK, pinned at the quad level (the regression that shipped with slice 5): a
/// slot past the book's length is a **disabled** SpellButton with no DisabledTexture and an
/// unchecked ring — so it draws its UI-Spellbook-SpellBackground square and **nothing else**. The
/// slice-5 build leaked both the UI-Quickslot2 NormalTexture (disabled wrongly fell back to
/// Normal) and the CheckButtonHilight ring (the reference's `SetChecked(0)` was read Lua-truthy),
/// putting a gold ring on all 12 slots — the director's "spellbook looks very wrong".
#[test]
fn shipped_spellbook_empty_slot_draws_only_the_background() {
let mut s = spellbook_ui(640.0, 700.0);
// A one-spell book: slot 5 takes the reference's `id > offset + numSpells` disable path. (A
// book with NO spells is a state no character is ever in — the reference's own
// `ToggleSpellBook` clamps the page to 0 for it and every button id goes negative.)
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()],
"an empty slot draws its background square and nothing else (no ring, no checked glow)"
);
// The reference passes SetChecked(0) — numeric coercion, not Lua truthiness.
assert!(!s
.eval::<bool>("return SpellButton5:GetChecked() and true or false")
.unwrap());
}
/// The SetChecked coercion table, pinned from the reference's own call sites (SpellBookFrame.lua
/// l.132/134/268/296-303/336): 1/"true"/true check; 0/"false"/nil/non-numeric strings uncheck.
#[test]
fn set_checked_uses_blizzard_bool_coercion() {
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 hunter's pet book: Growl (autocast ON, on cooldown), Claw (autocast OFF), Avoidance (a
/// passive — not autocastable). Three spells so the tab row raises and the page has content.
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()
},
],
}
}
/// **The pet tab, end to end through the shipped XML** (decision 1032): the toggle row appears
/// only once there are pet spells, clicking the pet tab switches the book, the page renders the
/// pet's own spells with their autocast overlay, the skill-line strip goes away, the title becomes
/// the class token's label, and a right-click flips autocast instead of casting.
#[test]
fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
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 the pet book cannot be opened at all (ref l.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 page: book ids are the button ids themselves on this book, so button 1 is Growl and
// button 3 (id="2") is Claw — the same column-major id map the spell book uses.
assert_eq!(
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Growl"
);
assert_eq!(
s.eval::<String>("return SpellButton3SpellName:GetText()")
.unwrap(),
"Claw"
);
// The autocast overlay follows GetSpellAutocast's FIRST return (can it), not the second.
assert!(s
.eval::<bool>("return SpellButton1AutoCastable:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return SpellButton5AutoCastable:IsVisible()")
.unwrap(),
"a passive is not autocastable"
);
// …and the shine marker follows the SECOND (is it on) — the native lane (decision 1383)
// draws the sparkle wherever a shown marker sits, so shown-ness IS the enable.
assert!(s
.eval::<bool>("return SpellButton1Shine:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return SpellButton3Shine:IsVisible()")
.unwrap());
// The corner brackets' RECT, through the live widget. Asserted on the resolved rect rather
// than the XML, so an anchor bug between the two is still caught — and CENTERED with no
// offset, which is what makes them concentric with the shine below (1393).
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{}, 1393 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]
);
// The marker's RECT: 1391 gave it the ref's own 36x36 at CENTER (1,1); 1393 squares it on the
// button instead, so the glow and the brackets share a centre. Checked here rather than
// trusted to the XML, because the whole spell-book thread turns on where this viewport sits.
let geom: Vec<f32> = ["GetWidth", "GetHeight"]
.iter()
.map(|m| {
s.eval::<f32>(&format!("return SpellButton1Shine:{m}()"))
.unwrap()
})
.collect();
assert!(
(geom[0] - 37.0).abs() < 0.01 && (geom[1] - 37.0).abs() < 0.01,
"shine marker is {geom:?}, expected 37x37 (1393 squares it on the button)"
);
let dx = s
.eval::<f32>("return SpellButton1Shine:GetLeft() - SpellButton1:GetLeft()")
.unwrap();
let dy = s
.eval::<f32>("return SpellButton1Shine:GetBottom() - SpellButton1:GetBottom()")
.unwrap();
assert!(
dx.abs() < 0.01 && dy.abs() < 0.01,
"shine marker sits at ({dx}, {dy}) inside the button; 1393 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 ────────────────────────────────────────────────────────────────────────────
// Left: a pet cast, on the pet queue and NOT the player's.
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 (ref l.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"
);
// ── The two reference QUIRKS, asserted on purpose (decisions 1030/1032) ───────────────────
// The pet page ignores its page number: `SpellBook_GetSpellID`'s pet arm is a bare `return id`.
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 (decision 1032)"
);
// …and the Next arrow writes the SPELL book's counter, leaving the pet page where it was.
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 (ref l.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 spellbook's **macro-editor fork**, pinned in both directions (report B248).
///
/// The reference's contract, read off the 1.12.1 install's own `SpellBookFrame.lua:271-283` and
/// `MacroFrame.lua:93-97`, is narrow and easy to "fix" into a later expansion's:
///
/// * only a **shift**-click reaches the editor at all — a plain left OR **right** click still
/// casts, exactly as it does with no macro window on screen;
/// * what lands is a whole **`/cast <name>[(<rank>)]` line**, not the bare name (the bare-name
/// insert is 2.x's `ChatEdit_InsertLink`, a different client — and `MACRO_HELP_TEXT_LINE5`,
/// which advertises this feature, says "*Shift* click");
/// * it is **appended with no separator** — the reference's own `GetText()..line` — so two
/// shift-clicks run together on one line;
/// * a **passive** contributes nothing, and neither does a shift-click while the name/icon
/// popup has the body box hidden;
/// * with the editor hidden the shift-click is a **pickup** again.
///
/// DO NOT "FIX" the `/cast` prefix away (the standing rule, decision 1030): B248 reported it as
/// wrong, and the install says it is what 1.12.1 does.
#[test]
fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The macro window's own strings (the app runs the real `GlobalStrings.lua`; `macro_tests`'
// harness is the source of this list).
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 stock macro window's character tab formats `UnitName("player")` into its label in its
// own OnLoad, so the player exists before the load (decision 1848).
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`
"MoneyFrame.xml",
"UiPanels.xml",
// `ShowMacroFrame` lives here since 1848.
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
// **ScrollTemplates BEFORE UIPanelTemplates, which is the manifest's own order.** Ours
// still carries dead `FauxScrollFrame_*` copies that the chain overrides by loading after
// (1846's step 3, deliberately not done); load them the other way round and OUR copies win
// — which is exactly the silent drift that record names.
"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",
"MicroMenu.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",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml",
// The reference's spellbook shows and hides the multibar grids (MultiActionBars.lua),
// whose file wants the options window's uvars — the same tail `spellbook_ui` carries.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, file);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The file's own book plus a third Fire spell that is PASSIVE — book id 3, which the ref's
// column-major grid puts on SpellButton5.
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();
// The reference selects NOTHING on open — `MacroFrame_Update` only highlights an existing
// selection, it never assigns one — so the details pane stays down until a macro is clicked.
// Our retired file auto-selected the first (decision 1848).
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), "");
// ── B248's reported gesture: 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),
"",
"B248: an UNSHIFTED click must never reach the macro editor (ref l.271 tests shift first)"
);
assert!(s.cursor_payload().is_none());
// …and so does a plain left click, the other half of the same arm.
click(&mut s, "SpellButton1", "LeftButton");
assert_eq!(s.take_spell_casts(), vec![133]);
assert_eq!(body(&s), "");
// ── The reference gesture: 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 dirty flag is set by the box's own `OnTextChanged`, which is deferred to the drain — the
// write landed in the buffer synchronously, the notification did not (decision 1831).
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 one runs straight on: the reference appends with NO separator (`GetText()..line`),
// which is why a two-spell macro built this way needs the player to break the line himself.
// DO NOT "fix" this into a newline join.
click(&mut s, "SpellButton3", "RightButton"); // shift outranks the button — ref l.271 vs l.284
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 at all — and does not fall through to a pickup either (ref l.274's
// guard sits INSIDE the macro-frame arm).
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; the ref's AddMacroLine gates on exactly that.
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: the shift-click is a PICKUP again (ref l.281-282's else) ───────────────
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());
}