the addon arc: the AddOns folder opens

- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:18:31 +07:00
co-authored by Claude Fable 5
parent bfb3e3addc
commit 44ff435304
231 changed files with 31316 additions and 5594 deletions
@@ -94,11 +94,11 @@ fn shipped_action_bar_drives_end_to_end() {
s.mouse_button(26.0, 22.0, "LeftButton", false);
assert_eq!(s.take_action_uses(), vec![73]);
// The keybinding entry (the app's key feed runs `BenillaActionButtonDown/Up(i)` on the two
// The keybinding entry (the app's key feed runs `ActionButtonDown/Up(i)` on the two
// key edges — the ref's ACTIONBUTTONn binding, ActionButton.lua:15-45): UP fires UseAction
// directly with no checkCursor (a keybind never places, decision 0216 §7) — but ONLY from
// the PUSHED state a DOWN set, so a stray release with no press is the ref's own no-op.
s.run("BenillaActionButtonUp(2)").unwrap();
s.run("ActionButtonUp(2)").unwrap();
assert!(
s.take_action_uses().is_empty(),
"an Up without a Down is a no-op (the PUSHED gate)"
@@ -112,14 +112,14 @@ fn shipped_action_bar_drives_end_to_end() {
})
.count()
};
s.run("BenillaActionButtonDown(2)").unwrap();
s.run("ActionButtonDown(2)").unwrap();
assert_eq!(depressed(&s), 1, "key DOWN shows the pushed texture");
assert_eq!(
s.eval::<String>("return BenillaActionButton2:GetButtonState()")
s.eval::<String>("return ActionButton2:GetButtonState()")
.unwrap(),
"PUSHED"
);
s.run("BenillaActionButtonUp(2)").unwrap();
s.run("ActionButtonUp(2)").unwrap();
assert_eq!(s.take_action_uses(), vec![74], "key '2' fires action 74");
assert_eq!(depressed(&s), 0, "key UP restores the normal state");
@@ -212,9 +212,7 @@ fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
}),
);
s.fire_event("ACTIONBAR_UPDATE_STATE", vec![]);
assert!(s
.eval::<bool>("return BenillaActionButton1:GetChecked()")
.unwrap());
assert!(s.eval::<bool>("return ActionButton1:GetChecked()").unwrap());
// The OOM blue tint (the transcribed UpdateUsable): usable=false + notEnoughMana=true.
s.set_action_state(
@@ -442,7 +440,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
s.resolve();
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
s.run("BenillaActionButton_OnDragStart(BenillaActionButton1)")
s.run("BenillaActionButton_OnDragStart(ActionButton1)")
.unwrap();
assert!(
s.cursor_payload().is_none(),
@@ -468,7 +466,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
);
// The receiving end is guarded too: the held action cannot be dropped back by a drag…
s.run("BenillaActionButton_OnReceiveDrag(BenillaActionButton1)")
s.run("BenillaActionButton_OnReceiveDrag(ActionButton1)")
.unwrap();
assert!(
s.cursor_payload().is_some(),
@@ -476,7 +474,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
);
// …and unlocking makes both ends live again.
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
s.run("BenillaActionButton_OnReceiveDrag(BenillaActionButton1)")
s.run("BenillaActionButton_OnReceiveDrag(ActionButton1)")
.unwrap();
assert!(s.cursor_payload().is_none(), "unlocked, the drop lands");
assert_eq!(
@@ -614,10 +612,8 @@ fn count_fontstring_follows_is_consumable_action_not_the_bag_count() {
// Read the fontstrings by name, not by scanning painted text: a single-character count
// ("0") is indistinguishable from a neighbouring button's static HotKey label by content.
let count_of = |s: &UiScript, n: u32| {
s.eval::<String>(&format!(
"return BenillaActionButton{n}Count:GetText() or \"\""
))
.unwrap()
s.eval::<String>(&format!("return ActionButton{n}Count:GetText() or \"\""))
.unwrap()
};
assert_eq!(
count_of(&s, 1),
@@ -670,7 +666,7 @@ fn shipped_bag_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The bag toggle now seats on the main action bar (BenillaActionBarArtFrame, ActionBar.xml);
// The bag toggle now seats on the main action bar (MainMenuBarArtFrame, ActionBar.xml);
// load the bar FIRST so the toggle's cross-file relativeTo resolves to the real art frame at
// SetPoint time. The app loads ActionBar before BagFrame (load_default_ui order); without it the
// anchor would silently fall back to the screen root (it only lands right here by the 1024-wide
@@ -0,0 +1,360 @@
//! The shipped `assets/ui/AddonList.xml` — the in-game AddOns list (decision 1197).
//!
//! **The window is the test of the API as much as the API is the test of the window.** This panel
//! is written entirely against the eleven AddOn verbs 1191 landed — `GetNumAddOns`,
//! `GetAddOnInfo`, `GetAddOnDependencies`, `EnableAddOn`, `DisableAddOn`, `EnableAllAddOns`,
//! `DisableAllAddOns` — with no host support of its own. So driving it drives them, through the
//! same Lua an addon author would write, which is worth more than a Rust test of each verb.
//!
//! What these guard: the row painter's three-colour rule and its status column against a registry
//! with a real dependency graph; the checkbox round-trip through `EnableAddOn`/`DisableAddOn`;
//! Cancel restoring what the panel found (`ResetAddOns` written out, since that verb is
//! glue-namespace and absent in-world); the scroll clamp; and the Enable All / Disable All pair.
use benilla_ui::script::{AddOnInfo, UiScript};
/// Fonts + the panel, and a registry with a shape worth painting: one plain addon, a library, a
/// dependent on that library, and one whose dependency is not installed.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"AddonList.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let info = |name: &str, title: &str, deps: &[&str]| AddOnInfo {
name: name.into(),
title: Some(title.into()),
notes: Some(format!("{name}'s notes")),
url: None,
secure: false,
load_on_demand: false,
dependencies: deps.iter().map(|d| (*d).to_string()).collect(),
directives: Default::default(),
files: Vec::new(),
saved_variables: Vec::new(),
saved_variables_per_character: Vec::new(),
enabled: true,
loaded: false,
};
s.register_addons(
vec![
info("Plain", "Plain Addon", &[]),
info("Lib", "The Library", &[]),
info("Needs", "Needs The Library", &["Lib"]),
info("Orphan", "Orphaned", &["Nowhere"]),
],
None,
None,
None,
);
s
}
/// The text of row `i`'s title and status, as the panel painted them.
fn row(s: &UiScript, i: usize) -> (String, String) {
let title = s
.eval::<String>(&format!("return AddonListEntry{i}Title:GetText() or ''"))
.unwrap();
let status = s
.eval::<String>(&format!("return AddonListEntry{i}Status:GetText() or ''"))
.unwrap();
(title, status)
}
/// The panel paints the registry: title (`## Title` else the folder name), the reference's status
/// token, and a checkbox per row — all through `GetAddOnInfo`.
#[test]
fn the_panel_paints_the_registry_through_the_addon_api() {
let s = harness();
s.run("AddonList_Show()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
assert_eq!(
row(&s, 1).0,
"Plain Addon",
"`## Title` wins over the folder"
);
assert_eq!(row(&s, 3).0, "Needs The Library");
// Rows past the registry are hidden, not left showing a stale addon.
assert!(
!s.eval::<bool>("return AddonListEntry5:IsVisible()")
.unwrap(),
"row 5 has no addon behind it"
);
// Four addons is well under MAX_ADDONS_DISPLAYED, so the scroll pair stays hidden — the
// reference's own GlueScrollFrame_Update condition.
assert!(!s
.eval::<bool>("return AddonListScrollUp:IsVisible()")
.unwrap());
// A dependency that is not installed is a status, not a checkbox state.
assert_eq!(
row(&s, 4).1,
"DEP_MISSING",
"the reference's own reason token, shown when GlobalStrings has no ADDON_ string for it"
);
}
/// The checkbox round-trips through `EnableAddOn`/`DisableAddOn`, and the effect on a *dependent*
/// shows up in its status column — which is the whole reason the panel re-reads rather than
/// caching.
#[test]
fn a_checkbox_click_disables_through_the_api_and_the_dependent_says_so() {
let s = harness();
s.run("AddonList_Show()").unwrap();
// Row 2 is the library. Click its checkbox the way the XML does.
s.run("AddonList_ToggleEntry(2)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(row(&s, 2).1, "DISABLED", "the player turned it off");
assert_eq!(
row(&s, 3).1,
"DEP_DISABLED",
"its dependent reports the reference's dependency token, not a stale clean row"
);
assert!(
!s.eval::<bool>("return AddonListEntry2Check:GetChecked()")
.unwrap(),
"and the checkbox follows the registry rather than its own memory"
);
// Clicking again re-enables, and the dependent recovers.
s.run("AddonList_ToggleEntry(2)").unwrap();
assert_eq!(row(&s, 2).1, "");
assert_eq!(row(&s, 3).1, "");
}
/// **Cancel restores what the panel found** — the reference's `ResetAddOns`, written out in Lua
/// because that verb is glue-namespace and deliberately absent in-world (1191 §6).
#[test]
fn cancel_restores_the_state_the_panel_opened_with() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_ToggleEntry(1) AddonList_ToggleEntry(2)")
.unwrap();
assert_eq!(row(&s, 1).1, "DISABLED");
s.run("AddonList_OnCancel()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
// Re-open: nothing the cancelled session did survived.
s.run("AddonList_Show()").unwrap();
assert_eq!(row(&s, 1).1, "");
assert_eq!(row(&s, 2).1, "");
}
/// Okay keeps the edit — the other half of the pair, and the one that would silently pass if
/// `AddonList_OnOk` accidentally called the restore loop.
#[test]
fn okay_keeps_the_edit() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_ToggleEntry(1)").unwrap();
s.run("AddonList_OnOk()").unwrap();
assert!(!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap());
s.run("AddonList_Show()").unwrap();
assert_eq!(row(&s, 1).1, "DISABLED", "Okay is not a disguised Cancel");
}
/// Enable All / Disable All go through the API's own pair, and the panel repaints.
#[test]
fn enable_all_and_disable_all_move_every_row() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_SetAll(nil)").unwrap();
for i in 1..=4 {
assert_eq!(row(&s, i).1, "DISABLED", "row {i} after Disable All");
}
s.run("AddonList_SetAll(1)").unwrap();
assert_eq!(row(&s, 1).1, "");
assert_eq!(
row(&s, 4).1,
"DEP_MISSING",
"Enable All cannot fix a dependency that is not installed — the status is not a checkbox"
);
}
/// The scroll offset clamps at both ends, and a list that fits never scrolls.
#[test]
fn the_scroll_offset_clamps() {
let s = harness();
s.run("AddonList_Show()").unwrap();
s.run("AddonList_Scroll(5)").unwrap();
assert_eq!(
s.eval::<i64>("return AddonList.offset").unwrap(),
0,
"four addons fit in nineteen rows — there is nothing to scroll"
);
s.run("AddonList_Scroll(-5)").unwrap();
assert_eq!(s.eval::<i64>("return AddonList.offset").unwrap(), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The ESC-menu path, end to end** — the director's ask stated as a test: open the game menu,
/// click AddOns, and the list is up with the menu gone.
///
/// Loaded in the shipped manifest's own order (`AddonList.xml` before `GameMenuFrame.xml`), which
/// is what makes the button's `AddonList_Toggle()` resolve at click time. A test that loaded them
/// the other way round would pass on the button's `IsEnabled()` and fail on the click, which is
/// exactly the seam worth covering.
#[test]
fn the_esc_menu_addons_entry_opens_the_list() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"AddonList.xml",
"GameMenuFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
assert!(
s.eval::<bool>("return GameMenuButtonAddOns:IsEnabled()")
.unwrap(),
"the entry is live — it stopped being GameMenuButton_Pending in 1197"
);
s.run("GameMenuButtonAddOns:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap(),
"the list came up"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"and the menu went away — the Macros entry's own body shape"
);
}
/// **ESC cancels the list**, the reference's own `AddonList_OnKeyDown` arm — and it discards.
///
/// This engine gives plain frames no raw key events, so the window joins `ToggleGameMenu`'s
/// shared ESC chain like the stack-split spinner and the world map. The rung sits above the
/// options window and below the popup engine, so a confirm dialog still wins the key.
#[test]
fn escape_cancels_the_list_and_discards_the_edit() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"AddonList.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
s.register_addons(
vec![benilla_ui::script::AddOnInfo {
name: "Solo".into(),
title: Some("Solo".into()),
notes: None,
url: None,
secure: false,
load_on_demand: false,
dependencies: Vec::new(),
directives: Default::default(),
files: Vec::new(),
saved_variables: Vec::new(),
saved_variables_per_character: Vec::new(),
enabled: true,
loaded: false,
}],
None,
None,
None,
);
s.run("AddonList_Show()").unwrap();
s.run("AddonList_ToggleEntry(1)").unwrap();
assert_eq!(
s.eval::<String>("return AddonListEntry1Status:GetText() or ''")
.unwrap(),
"DISABLED"
);
s.run("ToggleGameMenu()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return AddonListFrame:IsVisible()").unwrap(),
"ESC put the list away"
);
s.run("AddonList_Show()").unwrap();
assert_eq!(
s.eval::<String>("return AddonListEntry1Status:GetText() or ''")
.unwrap(),
"",
"and discarded the edit — ESC is Cancel, not Okay"
);
}
/// The hover tooltip runs — `AddonTooltip_Update`'s three lines (title, notes, dependencies) on
/// the shared `GameTooltip` rather than a private frame.
///
/// Worth its own test because it is the one path the other seven never touch: a load error would
/// have shown up in the harness, but a *runtime* error in a hover handler goes to
/// `UiScript::errors` and nothing else, which is exactly the silent kind.
#[test]
fn hovering_a_row_raises_the_tooltip_with_notes_and_dependencies() {
let s = harness();
s.run("AddonList_Show()").unwrap();
// Row 3 is `Needs`, which depends on `Lib` — so it exercises both optional lines.
s.run("AddonList_RowEnter(AddonListEntry3)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return GameTooltip:IsOwned(AddonListEntry3)")
.unwrap());
s.run("AddonList_RowLeave()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// And a row with no dependencies takes the other branch without erroring.
s.run("AddonList_RowEnter(AddonListEntry1)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
File diff suppressed because it is too large Load Diff
+33 -63
View File
@@ -23,7 +23,7 @@ fn load_xml(s: &UiScript, file: &str) -> usize {
}
/// The equipped-bag BAR icons must draw ABOVE the action-bar art, not under it. The bar buttons are
/// relocated onto `BenillaActionBarArtFrame` but are top-level frames, so they default to a lower
/// relocated onto `MainMenuBarArtFrame` but are top-level frames, so they default to a lower
/// frame level than the bar's own child-hierarchy art (the ExpBar dwarf notches + metal/well art) —
/// which would then paint over the centered icons, leaving the ring but no bag icon. The OnLoad
/// `BenillaActionBarArt_SeatAbove` seats them one level above the art (the action buttons' level).
@@ -34,7 +34,7 @@ fn bag_bar_icons_draw_above_the_action_bar_art() {
// The screen the client defaults to; the action bar centers here and the bag bar lands over its
// right end, where the dwarf-notch strip overlaps — the exact geometry that reproduced the bug.
s.set_screen_size(1600.0, 900.0);
// ActionBar.xml carries both the anchor target (BenillaActionBarArtFrame) and the occluder (the
// ActionBar.xml carries both the anchor target (MainMenuBarArtFrame) and the occluder (the
// ExpBar dwarf art); MerchantFrame.xml is BagFrame's documented purse-helper dep.
for file in [
"Fonts.xml",
@@ -784,7 +784,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
}
/// A second bag window (decision 0216 slice 2): bag 1's snapshot feeds through the SAME
/// C_Container/BenillaBagWindow_Update plumbing the backpack uses, opened via the bag-bar path
/// container/BenillaBagWindow_Update plumbing the backpack uses, opened via the bag-bar path
/// (`BenillaBagBarSlot_OnClick`, not the backpack toggle) and painting its own slot 1 icon.
#[test]
fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
@@ -981,28 +981,21 @@ fn shift_click_on_a_stack_opens_the_split_frame() {
s.set_screen_size(1024.0, 768.0);
let (x, y) = open_backpack_with_a_five_stack(&mut s);
assert!(!s
.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap());
assert!(!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"shift-click opened the split frame"
);
assert!(
s.cursor_item().is_none(),
"the shift fork never picks the stack up"
);
assert_eq!(
s.eval::<i64>("return BenillaStackSplitFrame.maxStack")
.unwrap(),
5
);
assert_eq!(s.eval::<i64>("return StackSplitFrame.maxStack").unwrap(), 5);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1018,22 +1011,15 @@ fn split_okay_then_a_placement_queues_the_split_move() {
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(s
.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap());
assert!(s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
// Bump the spinner from 1 to 3, then Okay — the carry lands on the cursor.
s.run("BenillaStackSplitRight_Click()").unwrap();
s.run("BenillaStackSplitRight_Click()").unwrap();
assert_eq!(
s.eval::<i64>("return BenillaStackSplitFrame.split")
.unwrap(),
3
);
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 3);
s.run("BenillaStackSplitOkay_Click()").unwrap();
assert!(
!s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"Okay hides the spinner"
);
let held = s.cursor_item().expect("Okay picked up the split carry");
@@ -1078,9 +1064,7 @@ fn a_plain_click_hides_an_open_split_frame() {
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(s
.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap());
assert!(s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
s.run(
"BENILLA_TEST_B9 = nil\n\
@@ -1092,8 +1076,7 @@ fn a_plain_click_hides_an_open_split_frame() {
s.run("BenillaBagSlot_OnClick(BENILLA_TEST_B9, \"LeftButton\")")
.unwrap();
assert!(
!s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"the plain click on an unrelated slot hid the spinner"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1321,10 +1304,9 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
// Keyless: the button is hidden and the plate is the dwarf one.
s.set_has_key(false);
s.run("BenillaMainMenuBar_UpdateKeyRing()").unwrap();
s.run("MainMenuBar_UpdateKeyRing()").unwrap();
assert!(
!s.eval::<bool>("return BenillaKeyRingButton:IsShown()")
.unwrap(),
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"no key ⇒ no keyring button"
);
assert_eq!(
@@ -1339,8 +1321,7 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
s.set_container(-2, Some(keyring(8, true)));
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
s.eval::<bool>("return BenillaKeyRingButton:IsShown()")
.unwrap(),
s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"the first key reveals the keyring button"
);
assert_eq!(
@@ -1349,8 +1330,8 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
"exactly the two RIGHT-hand strips swap to the keyring plate"
);
for (strip, top, bottom) in [
("BenillaActionBarTexture2", 0.6640625, 1.0),
("BenillaActionBarTexture3", 0.1640625, 0.5),
("MainMenuBarTexture2", 0.6640625, 1.0),
("MainMenuBarTexture3", 0.1640625, 0.5),
] {
let (t, b) = s
.eval::<(f64, f64)>(&format!(
@@ -1367,10 +1348,8 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
// edge at -234 from the bar's right; ours steps 36px buttons with -6 gaps and lands at -235.
// Within a pixel of the real bar — which is the check that matters, because the socket the
// button sits in is PAINTED INTO the keyring plate and cannot be nudged to meet it.
let bar_right = s.eval::<f64>("return BenillaActionBar:GetRight()").unwrap();
let button_left = s
.eval::<f64>("return BenillaKeyRingButton:GetLeft()")
.unwrap();
let bar_right = s.eval::<f64>("return MainMenuBar:GetRight()").unwrap();
let button_left = s.eval::<f64>("return KeyRingButton:GetLeft()").unwrap();
assert!(
(bar_right - button_left - 235.0).abs() < 1.5,
"the keyring button must land in the plate's painted socket — the ref's own -234, got {}",
@@ -1379,9 +1358,9 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
// The performance meter clears the new socket (ref l.180: -227 → -235).
let perf_right = s
.eval::<f64>("return BenillaPerformanceBarFrame:GetRight()")
.eval::<f64>("return MainMenuBarPerformanceBarFrame:GetRight()")
.unwrap();
let bar_right = s.eval::<f64>("return BenillaActionBar:GetRight()").unwrap();
let bar_right = s.eval::<f64>("return MainMenuBar:GetRight()").unwrap();
assert!(
(bar_right - perf_right - 235.0).abs() < 0.01,
"the meter slid to -235 with the keyring up (got {})",
@@ -1389,12 +1368,11 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
);
// And it reverts: destroying the last key takes the keyring back off the bar (our divergence
// from the ref's one-way saved-variable latch — see BenillaMainMenuBar_UpdateKeyRing).
// from the ref's one-way saved-variable latch — see MainMenuBar_UpdateKeyRing).
s.set_has_key(false);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
!s.eval::<bool>("return BenillaKeyRingButton:IsShown()")
.unwrap(),
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"losing the last key takes the button away again"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1418,7 +1396,7 @@ fn the_keyring_button_opens_a_keyring_window() {
assert!(!s
.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
.unwrap());
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert!(
s.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
@@ -1451,12 +1429,12 @@ fn the_keyring_button_opens_a_keyring_window() {
assert_eq!(shown, 8, "only the level-unlocked keyring slots are drawn");
// The key is in the window (slot 1 of 8 is the LAST chain button — size - physIndex + 1).
assert_eq!(
s.eval::<i64>("return C_Container.GetContainerItemID(KEYRING_CONTAINER, 1)")
s.eval::<i64>("return BenillaGetContainerItemID(KEYRING_CONTAINER, 1)")
.unwrap(),
7146
);
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
@@ -1497,10 +1475,10 @@ fn a_key_dropped_on_the_button_files_itself() {
}),
);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
s.run("C_Container.PickupContainerItem(0, 3)").unwrap();
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert_eq!(
s.take_container_moves(),
@@ -1527,15 +1505,15 @@ fn a_key_dropped_on_the_button_files_itself() {
);
}
s.set_container(-2, Some(full));
s.run("C_Container.PickupContainerItem(0, 3)").unwrap();
s.run("BenillaKeyRingButton_OnClick(BenillaKeyRingButton)")
s.run("PickupContainerItem(0, 3)").unwrap();
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert!(
s.take_container_moves().is_empty(),
"a full keyring queues no move"
);
assert_eq!(
s.eval::<String>("return BenillaErrorsFrameLine1:GetText()")
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"Your keyring is full.",
"and says so — the reference's own dangling string name prints nothing"
@@ -1591,11 +1569,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
};
// Nothing is animating until a push arrives.
for b in [
"BenillaBagToggle",
"BenillaBagBarSlot2",
"BenillaKeyRingButton",
] {
for b in ["BenillaBagToggle", "BenillaBagBarSlot2", "KeyRingButton"] {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
@@ -1609,11 +1583,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
);
s.resolve();
assert!(shown(&s, "BenillaBagBarSlot2"), "bag 2's card plays");
for b in [
"BenillaBagToggle",
"BenillaBagBarSlot1",
"BenillaKeyRingButton",
] {
for b in ["BenillaBagToggle", "BenillaBagBarSlot1", "KeyRingButton"] {
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
}
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
@@ -1725,7 +1695,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Key_03".into()),
],
);
assert!(shown(&s, "BenillaKeyRingButton"), "the keyring card plays");
assert!(shown(&s, "KeyRingButton"), "the keyring card plays");
assert!(
!shown(&s, "BenillaBagToggle"),
"and the backpack's does not"
+18 -23
View File
@@ -60,13 +60,11 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
// Pre-open default: the XML-authored placeholder.
assert_eq!(
s.eval::<String>("return BenillaBankFrameTitleText:GetText()")
s.eval::<String>("return BankFrameTitleText:GetText()")
.unwrap(),
"Banker"
);
assert!(!s
.eval::<bool>("return BenillaBankFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return BankFrame:IsVisible()").unwrap());
let _ = s.take_sounds(); // ignore anything from load (every frame is hidden; nothing should fire)
@@ -78,11 +76,9 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaBankFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return BankFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return BenillaBankFrameTitleText:GetText()")
s.eval::<String>("return BankFrameTitleText:GetText()")
.unwrap(),
"Grumnus Steelshaper"
);
@@ -91,7 +87,7 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
"opening the bank opens the backpack (the 0561 OpenBackpack contract)"
);
assert!(
s.eval::<bool>("return GetLeftFrame() == BenillaBankFrame")
s.eval::<bool>("return GetLeftFrame() == BankFrame")
.unwrap(),
"ShowUIPanel landed the bank at the left slot"
);
@@ -130,8 +126,7 @@ fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaBankFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return BankFrame:IsVisible()").unwrap(),
"BANKFRAME_CLOSED hides the window"
);
assert!(
@@ -271,7 +266,7 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
};
// Button 1: its own bag icon (BenillaGetBankBagTexture(1)), purchased -> white.
let (x1, y1) = center("BenillaBankBagButton1");
let (x1, y1) = center("BankBagButton1");
let c1 = color_at(x1, y1, "INV_Misc_Bag_08").expect("button 1 shows its fed bag icon");
assert!(
near(c1, 1.0, 1.0, 1.0),
@@ -279,7 +274,7 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
);
// Button 2 (purchased, no bag fed): the fallback texture, still white.
let (x2, y2) = center("BenillaBankBagButton2");
let (x2, y2) = center("BankBagButton2");
let c2 =
color_at(x2, y2, "PaperDoll-Slot-Bag").expect("button 2 shows the empty-slot fallback");
assert!(
@@ -289,10 +284,10 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
// Buttons 3..6 (unpurchased): the fallback texture, tinted the reference's exact red.
for name in [
"BenillaBankBagButton3",
"BenillaBankBagButton4",
"BenillaBankBagButton5",
"BenillaBankBagButton6",
"BankBagButton3",
"BankBagButton4",
"BankBagButton5",
"BankBagButton6",
] {
let (x, y) = center(name);
let c = color_at(x, y, "PaperDoll-Slot-Bag")
@@ -319,7 +314,7 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
s.fire_event("BANKFRAME_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaBankFramePurchaseInfo:IsShown()")
s.eval::<bool>("return BankFramePurchaseInfo:IsShown()")
.unwrap(),
"the purchase row shows while unfilled"
);
@@ -354,7 +349,7 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
s.fire_event("PLAYERBANKBAGSLOTS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaBankFramePurchaseInfo:IsShown()")
!s.eval::<bool>("return BankFramePurchaseInfo:IsShown()")
.unwrap(),
"the purchase row hides once full (6 purchased)"
);
@@ -377,8 +372,8 @@ fn a_bank_bag_button_answers_the_right_button_too() {
let (cx, cy): (f64, f64) = s
.eval(
"return (BenillaBankBagButton1:GetLeft() + BenillaBankBagButton1:GetRight()) / 2, \
(BenillaBankBagButton1:GetTop() + BenillaBankBagButton1:GetBottom()) / 2",
"return (BankBagButton1:GetLeft() + BankBagButton1:GetRight()) / 2, \
(BankBagButton1:GetTop() + BankBagButton1:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
@@ -430,8 +425,8 @@ fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() {
let (cx, cy): (f64, f64) = s
.eval(
"return (BenillaBankItem1:GetLeft() + BenillaBankItem1:GetRight()) / 2, \
(BenillaBankItem1:GetTop() + BenillaBankItem1:GetBottom()) / 2",
"return (BankItem1:GetLeft() + BankItem1:GetRight()) / 2, \
(BankItem1:GetTop() + BankItem1:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(cx as f32, cy as f32, "LeftButton", true);
@@ -0,0 +1,164 @@
//! The shipped `assets/ui/BasicControls.xml` — the reference's second file, driven the way an
//! addon drives it.
//!
//! Nothing benilla ships calls `message`, `TEXT` or `_ERRORMESSAGE`; their only consumers are
//! third-party addons (26 call `TEXT`, ~10 genuinely call `message`, two replace `_ERRORMESSAGE`).
//! So every test here enters from Lua the way those addons do.
use benilla_ui::script::UiScript;
/// One shipped file into `s`, asserting it loaded clean.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// Fonts then BasicControls — the manifest's own order, and the whole dependency this file has.
fn basic_controls() -> UiScript {
let mut s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "BasicControls.xml");
s.set_screen_size(1024.0, 768.0);
s.resolve();
s
}
/// **`message("...")` puts its text in the dialog and shows it** — the whole contract, and the
/// reason the frame has to exist rather than the function being a stub that prints somewhere.
#[test]
fn message_shows_the_script_errors_dialog_carrying_its_text() {
let s = basic_controls();
assert!(
!s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap(),
"the dialog starts hidden (DialogBoxFrame's `hidden=true`)"
);
s.run(r#"message("Validation error: [nil]")"#).unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return ScriptErrors_Message:GetText()")
.unwrap(),
"Validation error: [nil]"
);
// The ref guard: a second message while the dialog is up does NOT overwrite the first.
s.run(r#"message("second")"#).unwrap();
assert_eq!(
s.eval::<String>("return ScriptErrors_Message:GetText()")
.unwrap(),
"Validation error: [nil]",
"`if not ScriptErrors:IsVisible()` — the first error is the one you keep"
);
// And OKAY hides it, which is the only way it closes.
s.run("ScriptErrorsButton:Click()").unwrap();
assert!(!s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `_ERRORMESSAGE` is the reference's own name for the body, it **returns its argument**, and it
/// is replaceable — which is the entire point for the two corpus addons that replace it.
#[test]
fn error_message_returns_its_argument_and_can_be_replaced() {
let s = basic_controls();
assert_eq!(
s.eval::<String>(r#"return _ERRORMESSAGE("boom")"#).unwrap(),
"boom",
"the ref returns `message` — a caller may chain on it"
);
// ImprovedErrorFrame's shape: swap the global, then `message` must route through the swap.
s.run(
r#"Captured = nil
_ERRORMESSAGE = function(m) Captured = m return m end
message("routed")"#,
)
.unwrap();
assert_eq!(s.eval::<String>("return Captured").unwrap(), "routed");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The default error handler is still ours, deliberately — BasicControls.xml drops the
/// reference's `seterrorhandler(_ERRORMESSAGE)` so a runtime fault stays visible to
/// `UiScript::errors()` (and therefore to the corpus harness) instead of becoming a modal.
/// The divergence is stated at the site; this is what pins it.
#[test]
fn the_default_error_handler_is_not_replaced_by_error_message() {
let s = basic_controls();
assert!(
!s.eval::<bool>("return geterrorhandler() == _ERRORMESSAGE")
.unwrap(),
"installing _ERRORMESSAGE as the default would blind every instrument in the arc"
);
// ...but the pair still works, which is what an addon's pcall wrapper actually calls.
s.run(r#"geterrorhandler()("reported")"#).unwrap();
assert!(
s.errors().iter().any(|e| e.contains("reported")),
"geterrorhandler()(msg) reaches the error channel: {:?}",
s.errors()
);
assert!(
!s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap(),
"and it does NOT pop the dialog"
);
}
/// `TEXT` is the identity function, and 26 corpus addons call it because shipped FrameXML does.
#[test]
fn text_is_the_identity_function_the_corpus_expects() {
let s = basic_controls();
assert_eq!(
s.eval::<String>(r#"return TEXT("Level")"#).unwrap(),
"Level"
);
assert_eq!(s.eval::<i64>("return TEXT(7)").unwrap(), 7);
}
/// The dialog kit an addon inherits from directly: `DialogBoxFrame` plus the three named textures.
/// The OKAY button must hide *the caller's* frame, and `$parent` must resolve against the caller's
/// name — `MyDialogButton`, never `DialogBoxFrameButton`.
#[test]
fn a_dialog_box_from_the_template_names_and_closes_the_callers_frame() {
let s = basic_controls();
s.run(
r#"MyDialog = CreateFrame("Frame", "MyDialog", UIParent, "DialogBoxFrame")
MyDialog:SetWidth(384) MyDialog:SetHeight(128)
MyDialog:Show()"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.eval::<bool>(r#"return getglobal("MyDialogButton") ~= nil"#)
.unwrap(),
"the caller's name won through $parent"
);
assert!(
s.eval::<bool>(r#"return getglobal("DialogBoxFrameButton") == nil"#)
.unwrap(),
"and the template's own name published nothing"
);
assert_eq!(
s.eval::<String>(r#"return getglobal("MyDialogButton"):GetText()"#)
.unwrap(),
"OKAY"
);
s.run("MyDialogButton:Click()").unwrap();
assert!(
!s.eval::<bool>("return MyDialog:IsVisible()").unwrap(),
"`this:GetParent():Hide()` closed the caller's frame, not the template's"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+319 -12
View File
@@ -1,15 +1,21 @@
//! 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 `UNIT_AURA`, so
//! these exercise the *button* handlers — the row/filter wiring, the dispel-tinted border, the
//! stack count, the GetTime()-based countdown, the warning flash, and the right-click cancel — the
//! way the reference's own `BuffButton_*` did, but over our Era `UnitAura` bindings.
//! 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, ScriptValue, UiScript};
use benilla_ui::script::{AuraState, ExtractedQuad, QuadContent, UiScript};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
@@ -61,13 +67,23 @@ fn aura(
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("UNIT_AURA", vec![ScriptValue::Str("player".into())]);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
}
/// One tick of the app's real order (OnUpdate → resolve → draw list), returning the quads.
@@ -289,8 +305,9 @@ fn right_clicking_a_cancelable_buff_queues_its_spell_cancel() {
s.take_cancel_aura_requests().is_empty(),
"nothing queued yet"
);
// The RightButtonUp handler -> CancelUnitBuff -> the app drains one CMSG_CANCEL_AURA by spell id.
s.eval::<()>("BenillaBuffButton_OnClick(BuffButton0)")
// 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());
@@ -374,9 +391,24 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
// 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.
s.set_auras("player", Some(vec![mark(0.0)])); // icon up, no duration joined yet
//
// **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!(!shown(&s, "BuffButton0Duration"), "no timer yet");
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!(
@@ -419,7 +451,7 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
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\"; BenillaBuffButtons_UpdatePositions()")
s.run("SHOW_BUFF_DURATIONS = \"0\"; BuffButtons_UpdatePositions()")
.unwrap();
frame(&mut s, 0.1);
assert!(!shown(&s, "BuffButton0Duration"), "no timer drawn");
@@ -435,10 +467,285 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
);
// And back: the text is re-written from the poll, not from anything it kept.
s.run("SHOW_BUFF_DURATIONS = \"1\"; BenillaBuffButtons_UpdatePositions()")
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 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 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 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: none of the three aura handlers is even REGISTERED on an enchant slot.
// This is what the reference's empty-element idiom is supposed to achieve and what our loader
// does not do, so it is asserted rather than assumed.
for handler in ["OnLoad", "OnEvent", "OnClick"] {
assert!(
s.eval::<bool>(&format!(
"return TempEnchant1:GetScript(\"{handler}\") == nil"
))
.unwrap(),
"an enchant slot must carry no {handler} — it is not a buff button"
);
}
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.
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"
);
// 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());
}
@@ -412,14 +412,17 @@ fn managed_positions_track_the_bottom_bar_stack() {
assert_eq!(bottom(&s, "ChatFrame1"), 85.0, "chat baseY");
// The always-on bottom multibars appear (0270): bottomEither for the bar, bottomLeft for chat.
s.run("BenillaMultiBarBottomLeft = { IsShown = function() return true end }; BenillaMultiBarBottomRight = BenillaMultiBarBottomLeft; UIParent_ManageFramePositions()")
// The bar stubs carry a no-op SetPoint: `MultiBarBottomLeft` and `ShapeshiftBarFrame` are
// themselves rows in UIPARENT_MANAGED_FRAME_POSITIONS, so since those frames wear their
// reference names the pass positions them as well as reading their visibility.
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 100.0, "60 + bottomEither 40");
assert_eq!(bottom(&s, "ChatFrame1"), 102.0, "85 + bottomLeft 17");
// The stance bar shows (the warrior at login): the pet term, plus chat's both-flags extra.
s.run("BenillaShapeshiftBarFrame = { IsShown = function() return true end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 140.0, "60 + 40 + pet 40");
@@ -430,7 +433,7 @@ fn managed_positions_track_the_bottom_bar_stack() {
);
// It hides again (a druid leaving forms is the live case): everything settles back.
s.run("BenillaShapeshiftBarFrame = { IsShown = function() return false end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 100.0);
@@ -165,16 +165,11 @@ fn shipped_character_frame_drives_end_to_end() {
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
);
assert!(!s
.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCharacterInfoOpen".into())],
@@ -182,18 +177,18 @@ fn shipped_character_frame_drives_end_to_end() {
);
assert_eq!(
s.eval::<String>("return BenillaCharacterNameText:GetText()")
s.eval::<String>("return CharacterNameText:GetText()")
.unwrap(),
"Benilla"
);
assert_eq!(
s.eval::<String>("return BenillaCharacterLevelText:GetText()")
s.eval::<String>("return CharacterLevelText:GetText()")
.unwrap(),
"Level 12 Night Elf Warrior"
);
// STR effective 15, posBuff 2 > 0 → green (ref PaperDollFrame_SetStats).
assert_eq!(
s.eval::<String>("return BenillaCharacterStatFrame1StatText:GetText()")
s.eval::<String>("return CharacterStatFrame1StatText:GetText()")
.unwrap(),
"|cff20ff2015|r"
);
@@ -202,12 +197,12 @@ fn shipped_character_frame_drives_end_to_end() {
// never a re-declared region in an instance, which this engine renders as a SECOND
// anchorless default-font FontString (the white/overlapping-label regression).
assert_eq!(
s.eval::<String>("return BenillaCharacterStatFrame1Label:GetText()")
s.eval::<String>("return CharacterStatFrame1Label:GetText()")
.unwrap(),
"Strength:"
);
assert_eq!(
s.eval::<String>("return BenillaCharacterAttackFrameLabel:GetText()")
s.eval::<String>("return CharacterAttackFrameLabel:GetText()")
.unwrap(),
"Melee Attack"
);
@@ -215,9 +210,9 @@ fn shipped_character_frame_drives_end_to_end() {
s.resolve();
// The template's label sits LEFT-anchored at its row's left edge.
assert_eq!(
s.eval::<f32>("return BenillaCharacterStatFrame1Label:GetLeft()")
s.eval::<f32>("return CharacterStatFrame1Label:GetLeft()")
.unwrap(),
s.eval::<f32>("return BenillaCharacterStatFrame1:GetLeft()")
s.eval::<f32>("return CharacterStatFrame1:GetLeft()")
.unwrap(),
);
// Exactly ONE region carries the label text, in the small gold font (GameFontNormalSmall:
@@ -250,19 +245,18 @@ fn shipped_character_frame_drives_end_to_end() {
);
// Armor (school 0) reads straight off UnitArmor's base.
assert_eq!(
s.eval::<String>("return BenillaCharacterArmorFrameStatText:GetText()")
s.eval::<String>("return CharacterArmorFrameStatText:GetText()")
.unwrap(),
"120"
);
// MagicResFrame1 = id 6 (arcane) — resistances[6] = 3.
assert_eq!(
s.eval::<String>("return BenillaMagicResText1:GetText()")
.unwrap(),
s.eval::<String>("return MagicResText1:GetText()").unwrap(),
"3"
);
// No ranged weapon in this fixture: the N/A fallback (ref PaperDollFrame_SetRangedAttack).
assert_eq!(
s.eval::<String>("return BenillaCharacterRangedAttackFrameStatText:GetText()")
s.eval::<String>("return CharacterRangedAttackFrameStatText:GetText()")
.unwrap(),
"N/A"
);
@@ -286,14 +280,10 @@ fn shipped_character_frame_drives_end_to_end() {
// Hovering an EMPTY slot (Neck, never populated) shows the slot-name tooltip fallback.
let neck_center = {
let l: f32 = s.eval("return BenillaCharacterNeckSlot:GetLeft()").unwrap();
let r: f32 = s
.eval("return BenillaCharacterNeckSlot:GetRight()")
.unwrap();
let t: f32 = s.eval("return BenillaCharacterNeckSlot:GetTop()").unwrap();
let b: f32 = s
.eval("return BenillaCharacterNeckSlot:GetBottom()")
.unwrap();
let l: f32 = s.eval("return CharacterNeckSlot:GetLeft()").unwrap();
let r: f32 = s.eval("return CharacterNeckSlot:GetRight()").unwrap();
let t: f32 = s.eval("return CharacterNeckSlot:GetTop()").unwrap();
let b: f32 = s.eval("return CharacterNeckSlot:GetBottom()").unwrap();
((l + r) * 0.5, (t + b) * 0.5)
};
s.mouse_move(neck_center.0, neck_center.1);
@@ -308,10 +298,10 @@ fn shipped_character_frame_drives_end_to_end() {
// siblings); a transcription that drops the flag renders the ~380px sentence as one
// unwrapped line (the director's tooltip max-width report).
let res_center = {
let l: f32 = s.eval("return BenillaMagicResFrame1:GetLeft()").unwrap();
let r: f32 = s.eval("return BenillaMagicResFrame1:GetRight()").unwrap();
let t: f32 = s.eval("return BenillaMagicResFrame1:GetTop()").unwrap();
let b: f32 = s.eval("return BenillaMagicResFrame1:GetBottom()").unwrap();
let l: f32 = s.eval("return MagicResFrame1:GetLeft()").unwrap();
let r: f32 = s.eval("return MagicResFrame1:GetRight()").unwrap();
let t: f32 = s.eval("return MagicResFrame1:GetTop()").unwrap();
let b: f32 = s.eval("return MagicResFrame1:GetBottom()").unwrap();
((l + r) * 0.5, (t + b) * 0.5)
};
s.mouse_move(res_center.0, res_center.1);
@@ -341,7 +331,7 @@ fn shipped_character_frame_drives_end_to_end() {
// Rotating the model pane advances the booth yaw from the ref's default 0.61 by +0.03/click and
// plays the rotate kit (ref UIParent.lua:1421-1442).
s.run("BenillaPaperDollModel_RotateRight(BenillaCharacterModelFrame)")
s.run("BenillaPaperDollModel_RotateRight(CharacterModelFrame)")
.unwrap();
assert!(
(s.paperdoll_yaw() - 0.64).abs() < 0.001,
@@ -354,12 +344,9 @@ fn shipped_character_frame_drives_end_to_end() {
);
// ToggleCharacter() again closes it through HideUIPanel, playing the close kit.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(s.errors().is_empty(), "close errors: {:?}", s.errors());
assert!(!s
.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCharacterInfoClose".into())],
@@ -368,11 +355,11 @@ fn shipped_character_frame_drives_end_to_end() {
}
/// Regression — the close button's z-order (the reported "missing red X"): the character window
/// carries no border art of its own; its whole frame-and-background IS `BenillaPaperDollFrame`'s
/// carries no border art of its own; its whole frame-and-background IS `PaperDollFrame`'s
/// BACKGROUND layer, a full-window page created AFTER the close button. Every frame defaults to
/// level 0, so within the window draw order is creation order (`order.rs`) — the page's art painted
/// over the earlier-created button and it vanished. The button's `OnLoad` raises its frame level
/// (the same idiom `BenillaCharacterNameFrame` uses), so its art must paint AFTER every one of the
/// (the same idiom `CharacterNameFrame` uses), so its art must paint AFTER every one of the
/// page's own quads. Checked on the real extracted draw order, not merely the level integer.
#[test]
fn close_button_draws_above_the_paper_doll_page() {
@@ -384,8 +371,7 @@ fn close_button_draws_above_the_paper_doll_page() {
load_xml(&s, "CharacterFrame.xml");
s.set_unit("player", Some(player_unit()));
// The window must be SHOWN for extract() to emit quads (hidden="true" by default).
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.resolve();
// extract() returns quads already sorted ascending by draw order, so index == paint order.
@@ -395,14 +381,14 @@ fn close_button_draws_above_the_paper_doll_page() {
// The window's page art draws first; its LAST quad is the top-most page pixel.
let page_last = quads
.iter()
.rposition(|q| owner(q).as_deref() == Some("BenillaPaperDollFrame"))
.rposition(|q| owner(q).as_deref() == Some("PaperDollFrame"))
.expect("the paper-doll page renders its background art");
// The close button's red-X normal texture must render at all …
let close_x = quads
.iter()
.position(|q| {
owner(q).as_deref() == Some("BenillaCharacterFrameCloseButton")
owner(q).as_deref() == Some("CharacterFrameCloseButton")
&& matches!(&q.content,
QuadContent::Texture { path: Some(p), .. } if p.contains("MinimizeButton-Up"))
})
@@ -430,11 +416,10 @@ fn level_line_survives_no_player_snapshot_yet() {
// No set_unit("player", ...) call at all — UnitLevel/UnitRace/UnitClass answer their absent
// shapes (0 / nil,nil / nil,nil, `unit.rs`'s own contract).
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return BenillaCharacterLevelText:GetText()")
s.eval::<String>("return CharacterLevelText:GetText()")
.unwrap(),
"Level 0 ? ?"
);
@@ -465,15 +450,14 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
s.set_inventory_slots(inventory_with_head_item());
// The window must be SHOWN for `extract()` to emit its quads below (hidden="true" by
// default) — the icon-dim check needs a real render pass, not just the engine-side state.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
s.resolve();
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
s.run(r#"BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, "LeftButton")"#)
s.run(r#"BenillaPaperDollSlot_OnClick(CharacterHeadSlot, "LeftButton")"#)
.unwrap();
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
@@ -495,7 +479,7 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
assert!(head_icon_dim, "the picked slot's icon dims");
// Clicking the SAME slot again cancels — mirrors the bag's own same-slot-cancel contract.
s.run(r#"BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, "LeftButton")"#)
s.run(r#"BenillaPaperDollSlot_OnClick(CharacterHeadSlot, "LeftButton")"#)
.unwrap();
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
@@ -529,17 +513,17 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
.unwrap();
s.set_container(0, Some(backpack_with_fitting_helm()));
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
s.run("PickupContainerItem(0, 1)").unwrap();
s.tick(0.0); // dispatches the queued CURSOR_UPDATE to every registered doll slot
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.eval::<bool>("return lockHighlighted['BenillaCharacterHeadSlot'] == true")
s.eval::<bool>("return lockHighlighted['CharacterHeadSlot'] == true")
.unwrap(),
"the fitting slot locks its highlight"
);
assert!(
s.eval::<bool>("return unlockHighlighted['BenillaCharacterNeckSlot'] == true")
s.eval::<bool>("return unlockHighlighted['CharacterNeckSlot'] == true")
.unwrap(),
"a non-fitting slot stays unhighlighted"
);
@@ -558,28 +542,19 @@ fn model_pane_click_auto_equips_a_held_bag_item() {
load_xml(&s, "CharacterFrame.xml");
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
s.resolve();
s.set_container(0, Some(backpack_with_fitting_helm()));
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
let center = {
let l: f32 = s
.eval("return BenillaCharacterModelFrame:GetLeft()")
.unwrap();
let r: f32 = s
.eval("return BenillaCharacterModelFrame:GetRight()")
.unwrap();
let t: f32 = s
.eval("return BenillaCharacterModelFrame:GetTop()")
.unwrap();
let b: f32 = s
.eval("return BenillaCharacterModelFrame:GetBottom()")
.unwrap();
let l: f32 = s.eval("return CharacterModelFrame:GetLeft()").unwrap();
let r: f32 = s.eval("return CharacterModelFrame:GetRight()").unwrap();
let t: f32 = s.eval("return CharacterModelFrame:GetTop()").unwrap();
let b: f32 = s.eval("return CharacterModelFrame:GetBottom()").unwrap();
((l + r) * 0.5, (t + b) * 0.5)
};
s.mouse_button(center.0, center.1, "LeftButton", true);
@@ -607,8 +582,7 @@ fn broken_equipped_item_tints_its_doll_slot_red() {
let mut inv = inventory_with_head_item();
inv[1].as_mut().unwrap().durability = Some((0, 40));
s.set_inventory_slots(inv);
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![ScriptValue::Str("player".into())],
@@ -756,21 +730,14 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
.unwrap()
};
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(
shown(&mut s, "BenillaPaperDollFrame"),
"opens on the doll page"
);
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(shown(&mut s, "PaperDollFrame"), "opens on the doll page");
// Tab to Skills — by point, the director's gesture.
let tab2 = text_center(&mut s, "Skills");
click(&mut s, tab2);
assert!(
shown(&mut s, "BenillaSkillFrame"),
"Skills tab shows the page"
);
assert!(!shown(&mut s, "BenillaPaperDollFrame"), "doll page yields");
assert!(shown(&mut s, "SkillFrame"), "Skills tab shows the page");
assert!(!shown(&mut s, "PaperDollFrame"), "doll page yields");
// Select a skill row — arms the detail pane (the report's precondition).
let row = text_center(&mut s, "Blacksmithing");
@@ -779,11 +746,11 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
s.eval::<i64>("return GetSelectedSkill()").unwrap() > 0,
"the row click selects"
);
assert!(shown(&mut s, "BenillaSkillDetailBar"), "detail bar arms");
assert!(shown(&mut s, "SkillDetailBar"), "detail bar arms");
// The description body renders through the SKILL_DESCRIPTION format (skillType is "" in every
// reachable 1.12 branch) — the nil-global half of the report, pinned.
assert_eq!(
s.eval::<String>("return BenillaSkillDetailDescriptionText:GetText()")
s.eval::<String>("return SkillDetailDescriptionText:GetText()")
.unwrap(),
"|cffffffff|r Working with metals.",
"the detail description renders via SKILL_DESCRIPTION"
@@ -793,10 +760,10 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
// name; accepting queues the CMSG_UNLEARN_SKILL intent BY SKILL ID and removes nothing
// locally (the server's SetSkill(id,0,0) round trip owns the removal).
assert!(
shown(&mut s, "BenillaSkillDetailUnlearnButton"),
shown(&mut s, "SkillDetailUnlearnButton"),
"the unlearn button shows for a profession"
);
s.run("BenillaSkillDetailUnlearnButton:Click()").unwrap();
s.run("SkillDetailUnlearnButton:Click()").unwrap();
assert!(
shown(&mut s, "StaticPopup1"),
"the UNLEARN_SKILL confirm opens"
@@ -823,7 +790,7 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
let defense = text_center(&mut s, "Defense");
click(&mut s, defense);
assert!(
!shown(&mut s, "BenillaSkillDetailUnlearnButton"),
!shown(&mut s, "SkillDetailUnlearnButton"),
"no unlearn button for Defense"
);
// Restore the profession selection so the round trip below leaves familiar state.
@@ -834,23 +801,23 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
let tab1 = text_center(&mut s, "Character");
assert_eq!(
s.hit_test_name(tab1.0, tab1.1).as_deref(),
Some("BenillaCharacterFrameTab1"),
Some("CharacterFrameTab1"),
"the Character tab OWNS its point while Skills is up (the wheel catcher must not)"
);
click(&mut s, tab1);
assert!(
shown(&mut s, "BenillaPaperDollFrame"),
shown(&mut s, "PaperDollFrame"),
"the Character tab switches back (the 2026-07-17 report)"
);
assert!(!shown(&mut s, "BenillaSkillFrame"), "Skills page yields");
assert!(!shown(&mut s, "SkillFrame"), "Skills page yields");
// Once more around — "flaky" only shows up on repetition.
let tab2 = text_center(&mut s, "Skills");
click(&mut s, tab2);
assert!(shown(&mut s, "BenillaSkillFrame"), "second trip to Skills");
assert!(shown(&mut s, "SkillFrame"), "second trip to Skills");
let tab1 = text_center(&mut s, "Character");
click(&mut s, tab1);
assert!(shown(&mut s, "BenillaPaperDollFrame"), "second trip back");
assert!(shown(&mut s, "PaperDollFrame"), "second trip back");
assert!(
s.errors().is_empty(),
@@ -883,12 +850,11 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
load_xml(&s, "CharacterFrame.xml");
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.resolve();
// The left arrow's centre, in screen points.
let btn = "BenillaCharacterModelFrameRotateLeftButton";
let btn = "CharacterModelFrameRotateLeftButton";
let (bx, by) = {
let l: f32 = s.eval(&format!("return {btn}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {btn}:GetRight()")).unwrap();
@@ -929,7 +895,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
);
// The right arrow spins the other way (held-RIGHT subtracts).
let rbtn = "BenillaCharacterModelFrameRotateRightButton";
let rbtn = "CharacterModelFrameRotateRightButton";
let (rx, ry) = {
let l: f32 = s.eval(&format!("return {rbtn}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {rbtn}:GetRight()")).unwrap();
@@ -996,14 +962,14 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
}
let selected = |s: &mut UiScript| {
s.eval::<i64>("return PanelTemplates_GetSelectedTab(BenillaCharacterFrame)")
s.eval::<i64>("return PanelTemplates_GetSelectedTab(CharacterFrame)")
.unwrap()
};
// A tab wears its "Active" (=Disabled) art exactly when it is the selected one — the visible
// half of the same fact, so a SetTab that updated the number without repainting fails here too.
let wearing_active_art = |s: &mut UiScript, tab: u32| {
s.eval::<bool>(&format!(
"return BenillaCharacterFrameTab{tab}MiddleDisabled:IsVisible()"
"return CharacterFrameTab{tab}MiddleDisabled:IsVisible()"
))
.unwrap()
};
@@ -1013,20 +979,18 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
};
// Open on Character (the `C` binding), then move to Skills the same way.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert_eq!(selected(&mut s), 1);
s.run(r#"ToggleCharacter("BenillaSkillFrame")"#).unwrap();
assert!(shown(&mut s, "BenillaSkillFrame"));
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
assert!(shown(&mut s, "SkillFrame"));
assert_eq!(selected(&mut s), 3, "the row follows a keybind to Skills");
assert!(wearing_active_art(&mut s, 3));
assert!(!wearing_active_art(&mut s, 1));
// THE REPORT: `C` from the Skills page. The page goes back to Character — and so must the row.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(shown(&mut s, "BenillaPaperDollFrame"));
assert!(!shown(&mut s, "BenillaSkillFrame"));
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(shown(&mut s, "PaperDollFrame"));
assert!(!shown(&mut s, "SkillFrame"));
assert_eq!(
selected(&mut s),
1,
@@ -1041,14 +1005,11 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
// Character tab is inert while the Character page is up. The window only closed because the row
// still said Skills: tab 1 was left enabled, the click reached `ToggleCharacter`, and the page
// it named was already visible — the HideUIPanel arm.
s.run("BenillaCharacterFrameTab1:Click()").unwrap();
s.run("CharacterFrameTab1:Click()").unwrap();
assert!(
shown(&mut s, "BenillaCharacterFrame"),
shown(&mut s, "CharacterFrame"),
"the selected tab is disabled — clicking it cannot close the window"
);
assert!(
shown(&mut s, "BenillaPaperDollFrame"),
"…or change the page"
);
assert!(shown(&mut s, "PaperDollFrame"), "…or change the page");
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
+162 -9
View File
@@ -274,14 +274,10 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
// Drag Fireball off its book button (press → past the 4px threshold → the payload is up).
let (l, r, t, b) = (
s.eval::<f32>("return BenillaSpellButton1:GetLeft()")
.unwrap(),
s.eval::<f32>("return BenillaSpellButton1:GetRight()")
.unwrap(),
s.eval::<f32>("return BenillaSpellButton1:GetTop()")
.unwrap(),
s.eval::<f32>("return BenillaSpellButton1:GetBottom()")
.unwrap(),
s.eval::<f32>("return SpellButton1:GetLeft()").unwrap(),
s.eval::<f32>("return SpellButton1:GetRight()").unwrap(),
s.eval::<f32>("return SpellButton1:GetTop()").unwrap(),
s.eval::<f32>("return SpellButton1:GetBottom()").unwrap(),
);
let (x1, y1) = ((l + r) * 0.5, (t + b) * 0.5);
s.mouse_button(x1, y1, "LeftButton", true);
@@ -338,7 +334,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
slots,
}),
);
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
@@ -348,3 +344,160 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── ChatTypeInfo: the addon-facing color table ────────────────────────────────────────────────
/// `ChatTypeInfo` carries the shipped default chat colors twice: once in `assets/ui/ChatFrame.xml`
/// for addons to read, and once in [`crate::ui_chat::default_color`] for our own feed to render.
/// Both are the same wow-re byte table (`chat-color-table.md`, the static registry at
/// `.rdata 0x804710`) — so this is the gate that makes the duplication safe: every kind we model
/// must agree to the byte, and the table's shape (`sticky`/`id`) must be the reference's.
#[test]
fn chat_type_info_matches_the_host_color_table() {
use crate::ui_chat::{default_color, ChatEventKind as K};
/// Each modeled kind and its `ChatTypeInfo` key — the reference's own spellings.
const PAIRS: &[(&str, K, i64)] = &[
("SAY", K::Say, 1),
("PARTY", K::Party, 2),
("RAID", K::Raid, 3),
("GUILD", K::Guild, 4),
("OFFICER", K::Officer, 5),
("YELL", K::Yell, 6),
("WHISPER", K::Whisper, 7),
("WHISPER_INFORM", K::WhisperInform, 8),
("EMOTE", K::Emote, 9),
("TEXT_EMOTE", K::TextEmote, 10),
("SYSTEM", K::System, 11),
("MONSTER_SAY", K::MonsterSay, 12),
("MONSTER_YELL", K::MonsterYell, 13),
("MONSTER_EMOTE", K::MonsterEmote, 14),
("MONSTER_WHISPER", K::MonsterWhisper, 27),
("CHANNEL", K::Channel, 15),
("CHANNEL_JOIN", K::ChannelJoin, 16),
("CHANNEL_LEAVE", K::ChannelLeave, 17),
("CHANNEL_NOTICE", K::ChannelNotice, 19),
("CHANNEL_NOTICE_USER", K::ChannelNoticeUser, 20),
("CHANNEL_LIST", K::ChannelList, 18),
("AFK", K::Afk, 21),
("DND", K::Dnd, 22),
("IGNORED", K::Ignored, 23),
("SKILL", K::Skill, 24),
("LOOT", K::Loot, 25),
("MONEY", K::Money, 87),
("COMBAT_XP_GAIN", K::CombatXpGain, 46),
("RAID_LEADER", K::RaidLeader, 88),
("RAID_WARNING", K::RaidWarning, 89),
("RAID_BOSS_EMOTE", K::RaidBossEmote, 91),
("BATTLEGROUND", K::Battleground, 93),
("BATTLEGROUND_LEADER", K::BattlegroundLeader, 94),
("BG_SYSTEM_NEUTRAL", K::BgSystemNeutral, 83),
("BG_SYSTEM_ALLIANCE", K::BgSystemAlliance, 84),
("BG_SYSTEM_HORDE", K::BgSystemHorde, 85),
];
let s = chat_frame();
for (name, kind, want_id) in PAIRS {
let (r, g, b, id): (f64, f64, f64, i64) = s
.eval(&format!(
r#"local i = ChatTypeInfo["{name}"] return i.r, i.g, i.b, i.id"#
))
.unwrap_or_else(|e| panic!(r#"ChatTypeInfo["{name}"]: {e}"#));
let got = [
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
];
assert_eq!(got, default_color(*kind), r#"ChatTypeInfo["{name}"] color"#);
// `id` is GetChatTypeIndex's 1-based registry slot, asserted EXACTLY rather than as a
// range: four of these ship the same FFDBB7 and a range check would let a transposition
// inside that family through with both colors still matching.
assert_eq!(id, *want_id, r#"ChatTypeInfo["{name}"].id"#);
}
}
/// The table's *shape*, transcribed rather than derived: the reference's 105 keys, its five sticky
/// types (the set `ui_chat::edit`'s `SendType::sticky` already quotes), the two keys FrameXML
/// declares that the engine's color registry does not contain, and the ten boot-seeded channel
/// extras. An addon reads `.sticky` and `.id` as often as it reads the color.
#[test]
fn chat_type_info_has_the_references_shape() {
let s = chat_frame();
let count: i64 = s
.eval("local n = 0 for _ in pairs(ChatTypeInfo) do n = n + 1 end return n")
.unwrap();
assert_eq!(count, 105, "the reference declares 105 keys");
let sticky: String = s
.eval(
"local t = {} for k, v in pairs(ChatTypeInfo) do if v.sticky == 1 then \
table.insert(t, k) end end table.sort(t) return table.concat(t, \" \")",
)
.unwrap();
assert_eq!(sticky, "BATTLEGROUND GUILD PARTY RAID SAY");
// REPLY and COMBAT_ERROR are declared by FrameXML and absent from the engine's 94-entry
// registry, so GetChatTypeIndex answers 0 for both — but their COLORS differ, which is the
// trap this asserts. Nothing ever writes COMBAT_ERROR. REPLY is written by hand inside the
// UPDATE_CHAT_COLOR handler, which mirrors WHISPER into it (ChatFrame.lua l.1357-1365), so
// its end state is WHISPER's FF80FF. Seeding both white reads as symmetric and is wrong.
for (name, want) in [
("REPLY", [255u8, 128, 255]),
("COMBAT_ERROR", [255, 255, 255]),
] {
let (id, r, g, b): (i64, f64, f64, f64) = s
.eval(&format!(
r#"local i = ChatTypeInfo["{name}"] return i.id, i.r, i.g, i.b"#
))
.unwrap();
assert_eq!(id, 0, r#""{name}".id — not in the engine's registry"#);
let got = [
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
];
assert_eq!(got, want, r#""{name}" color"#);
}
// The extras: CHANNEL1..CHANNEL10, indices 95..104, each the live CHANNEL entry's FFC0C0.
for n in 1..=10 {
let (id, r, g, b): (i64, f64, f64, f64) = s
.eval(&format!(
r#"local i = ChatTypeInfo["CHANNEL{n}"] return i.id, i.r, i.g, i.b"#
))
.unwrap();
assert_eq!(id, 94 + n, "CHANNEL{n}.id");
let rgb = [
(r * 255.0).round() as u8,
(g * 255.0).round() as u8,
(b * 255.0).round() as u8,
];
assert_eq!(rgb, [255, 192, 192], "CHANNEL{n} color");
}
}
/// **Every event name we fire is a key the reference's own `ChatTypeInfo` carries.**
/// `ChatFrame_OnEvent` recovers the type with `strsub(event, 10)` and indexes
/// `ChatTypeInfo[type]` with it — a name that misses that table is a name whose colour, id and
/// sticky flag an addon cannot look up, so this is the check that our `CHAT_MSG_*` spellings are
/// the reference's and not ours.
///
/// Swept over `ChatEventKind::ALL` rather than a second hand-written list, so a kind added without
/// a matching table key fails here instead of shipping a name nothing can resolve.
#[test]
fn fired_event_names_are_all_chat_type_info_keys() {
use crate::ui_chat::{event_name, ChatEventKind as K};
let s = chat_frame();
for &kind in K::ALL {
let name = event_name(kind);
let key = name
.strip_prefix("CHAT_MSG_")
.unwrap_or_else(|| panic!("{name}: every fired chat event is CHAT_MSG_-prefixed"));
let present: bool = s
.eval(&format!(r#"return ChatTypeInfo["{key}"] ~= nil"#))
.unwrap();
assert!(present, r#"{name} → ChatTypeInfo["{key}"] is missing"#);
}
}
@@ -0,0 +1,526 @@
//! The shipped `assets/ui/ColorPickerFrame.xml` + the dropdown's colour-swatch row, driven the way
//! the corpus drives them.
//!
//! Nothing benilla ships opens this window. Its consumers are third-party addons — 86 of the 218
//! reach `ColorPickerFrame`, and 64 of those files are copies of `Dewdrop-2.0.lua`, THE Ace2 menu
//! library, so its block below is not one addon's idiom but the path every colour option in every
//! Ace2 config menu takes. Every test here therefore enters from Lua exactly as they do; the
//! Dewdrop and AceConsole sequences are transcribed from corpus copies, cited per test.
use benilla_ui::script::{QuadContent, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The manifest prefix the picker needs: fonts, the panel manager (`ShowUIPanel`/`HideUIPanel`,
/// `UISpecialFrames`, the ESC chain), the shared widget kit (`GameMenuButtonTemplate`), then the
/// window. `UIDropDownMenu.xml` rides along because `CloseMenus` and the swatch row live there.
fn picker() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"ColorPickerFrame.xml",
] {
load_xml(&s, file);
}
s.set_screen_size(1024.0, 768.0);
s.resolve();
s
}
/// What the widget hands back after being *set* to `(r, g, b)` — the client's whole colour law, run
/// through the engine's own transcription of it rather than restated here.
///
/// It is emphatically **not** the identity, and not a simple quantize either: `SetColorRGB` rounds
/// half-up into bytes and stores HSV floats, while every read path floors, so 9.75 % of colours come
/// back a step low on one channel (wow-re `system/ui/scratch/colorselect-color-law.md`, exhaustive
/// over all 256³). `benilla_ui::script::colorselect`'s own tests pin that law against wow-re's
/// measured witnesses; these tests use it as a given so they stay about the Dewdrop plumbing.
fn after_round_trip(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
let mut cs = benilla_ui::widget::ColorSelectState::default();
cs.set_rgb(r, g, b);
cs.rgb_f64()
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The window's own API surface
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The four names addons address by string all exist, are the right kinds, and the two buttons'
/// `OnClick` is fetchable — which is not decoration: `AceConsole-2.0.lua` l.1402 does
/// `ColorPickerOkayButton:GetScript("OnClick")` and refuses to install its colour option at all if
/// `ColorPickerOkayButton` is nil.
#[test]
fn the_named_pieces_exist_and_their_scripts_are_fetchable() {
let s = picker();
assert!(s
.eval::<bool>("return ColorPickerFrame ~= nil and ColorPickerFrame.SetColorRGB ~= nil")
.unwrap());
assert!(s
.eval::<bool>("return OpacitySliderFrame ~= nil and OpacitySliderFrame.GetValue ~= nil")
.unwrap());
for button in ["ColorPickerOkayButton", "ColorPickerCancelButton"] {
assert!(
s.eval::<bool>(&format!("return {button} ~= nil")).unwrap(),
"{button} is missing"
);
assert!(
s.eval::<bool>(&format!(
r#"return type({button}:GetScript("OnClick")) == "function""#
))
.unwrap(),
"{button}'s OnClick is not fetchable"
);
}
// Born hidden, like the reference's `hidden="true"`.
assert!(!s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetColorRGB` paints the preview swatch through `OnColorSelect`, and `GetColorRGB` answers the
/// client's quantized colour. The swatch is the ONLY thing in the window that shows the colour, and
/// nothing but this handler ever writes it.
///
/// The assertion goes through `extract()` on purpose. `ColorSwatch:SetTexture(r, g, b)` is the
/// *solid-colour* form of SetTexture — it replaces the texture, it does not tint one — so
/// `GetVertexColor` still reads the `<Color>` the XML declared (1,1,1,1) and would have "passed"
/// this test while the swatch drew nothing. What the renderer receives is the only honest witness.
#[test]
fn set_color_rgb_paints_the_swatch_and_reads_back_the_widgets_colour() {
let mut s = picker();
s.run("ShowUIPanel(ColorPickerFrame)").unwrap();
s.run("ColorPickerFrame:SetColorRGB(0.2, 0.4, 0.8)")
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
let (r, g, b): (f64, f64, f64) = s.eval("return ColorPickerFrame:GetColorRGB()").unwrap();
assert_eq!((r, g, b), after_round_trip(0.2, 0.4, 0.8));
s.resolve();
let want = [r as f32, g as f32, b as f32];
let painted = s.extract().into_iter().any(|q| match &q.content {
QuadContent::Texture {
path: None,
color: Some(c),
..
} => [c[0], c[1], c[2]] == want && c[3] == 1.0,
_ => false,
});
assert!(
painted,
"no solid quad carrying the widget's colour {want:?} — the swatch is not painted"
);
}
/// `hasOpacity` is what decides whether the window has an opacity slider at all — and the window's
/// WIDTH with it. Both arms of the reference's `OnShow` (l.161-170).
#[test]
fn has_opacity_shows_the_slider_and_widens_the_window() {
let mut s = picker();
// Without it: no slider, the narrow window. (`GetWidth` reports the *resolved* rect, so the
// OnShow's SetWidth needs a resolve pass before it is readable — the same round-trip every
// geometry assertion in this file set makes.)
s.run("ColorPickerFrame.hasOpacity = nil ShowUIPanel(ColorPickerFrame)")
.unwrap();
s.resolve();
assert!(!s
.eval::<bool>("return OpacitySliderFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<f64>("return ColorPickerFrame:GetWidth()").unwrap(),
305.0
);
s.run("HideUIPanel(ColorPickerFrame)").unwrap();
// With it: the slider shows, seeded from `.opacity`, and the window is back to 365.
s.run("ColorPickerFrame.hasOpacity = 1 ColorPickerFrame.opacity = 0.25 ShowUIPanel(ColorPickerFrame)")
.unwrap();
s.resolve();
assert!(s
.eval::<bool>("return OpacitySliderFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<f64>("return OpacitySliderFrame:GetValue()")
.unwrap(),
0.25
);
assert_eq!(
s.eval::<f64>("return ColorPickerFrame:GetWidth()").unwrap(),
365.0
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Moving the slider runs `opacityFunc` on every change — the live-preview half of the contract,
/// and the reference's own `<OnValueChanged>` (l.146-152).
#[test]
fn the_opacity_slider_drives_opacity_func_on_every_change() {
let s = picker();
s.run(
r#"
seen = {}
ColorPickerFrame.opacityFunc = function()
table.insert(seen, OpacitySliderFrame:GetValue())
end
"#,
)
.unwrap();
s.run("OpacitySliderFrame:SetValue(0.5)").unwrap();
s.run("OpacitySliderFrame:SetValue(0.75)").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(s.eval::<usize>("return table.getn(seen)").unwrap(), 2);
assert_eq!(s.eval::<f64>("return seen[1]").unwrap(), 0.5);
assert_eq!(s.eval::<f64>("return seen[2]").unwrap(), 0.75);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Dewdrop-2.0 — the sequence 64 corpus files share
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// The `Dewdrop-2.0.lua` block verbatim in shape (a corpus copy, l.425-465): set `func` to a closure
/// that reads `GetColorRGB()` + `1 - OpacitySliderFrame:GetValue()` and calls the addon's setter
/// with the closed-over arg prefix, mirror it into `opacityFunc`, set `opacity = 1 - this.opacity`,
/// `SetColorRGB`, capture the old values into `cancelFunc`, `ShowUIPanel`.
///
/// Two things this proves beyond "it runs": the setter fires **at `SetColorRGB` time**, before the
/// window is even shown (the reference's own behaviour — `func` is a live preview, and it is why
/// `hasOpacity`/`opacity` are assigned before the colour), and clicking **Okay** commits with the
/// slider's final value.
fn dewdrop_open(s: &UiScript, r: f64, g: f64, b: f64, opacity: f64) {
s.run(&format!(
r#"
applied = {{}}
-- the addon's own setter, the `func` Dewdrop closes over
local func = function(a1, r, g, b, a)
table.insert(applied, {{ key = a1, r = r, g = g, b = b, a = a }})
end
local a1 = "bordercolor" -- Dewdrop's colorArg1
local hasOpacity = 1
local this = {{ r = {r}, g = {g}, b = {b}, opacity = {opacity}, hasOpacity = 1 }}
ColorPickerFrame.func = function()
if func then
local r, g, b = ColorPickerFrame:GetColorRGB()
local a = hasOpacity and 1 - OpacitySliderFrame:GetValue() or nil
func(a1, r, g, b, a)
end
end
ColorPickerFrame.hasOpacity = this.hasOpacity
ColorPickerFrame.opacityFunc = ColorPickerFrame.func
ColorPickerFrame.opacity = 1 - this.opacity
ColorPickerFrame:SetColorRGB(this.r, this.g, this.b)
local pr, pg, pb, pa = this.r, this.g, this.b, this.opacity
ColorPickerFrame.cancelFunc = function()
func(a1, pr, pg, pb, pa)
end
ShowUIPanel(ColorPickerFrame)
"#
))
.unwrap();
}
/// **Okay commits.** The Dewdrop sequence, then the OK button: the addon's setter receives the
/// quantized colour and the slider-derived alpha, and the window closes.
#[test]
fn the_dewdrop_sequence_commits_on_okay() {
let s = picker();
dewdrop_open(&s, 0.1, 0.5, 0.9, 0.25);
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The live preview has already run TWICE before the window is even on screen, and both are the
// reference's own: `SetColorRGB` fires `OnColorSelect` → `func()`, and then `OnShow`'s
// `OpacitySliderFrame:SetValue(this.opacity)` moves the slider off its `defaultValue="1"` and
// fires `OnValueChanged` → `opacityFunc()`, which Dewdrop has aliased to the same closure.
// Pinned rather than glossed: an addon whose `func` writes through to the server sees both.
assert_eq!(s.eval::<usize>("return table.getn(applied)").unwrap(), 2);
assert!(s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
// The window came up with the slider at `1 - opacity`, which is what OnShow seeded.
assert_eq!(
s.eval::<f64>("return OpacitySliderFrame:GetValue()")
.unwrap(),
0.75
);
// The player drags the opacity down, then accepts.
s.run("OpacitySliderFrame:SetValue(0.4)").unwrap();
s.run("ColorPickerOkayButton:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(!s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
// Last call wins: the addon holds the quantized colour and `1 - slider`.
let (key, r, g, b, a): (String, f64, f64, f64, f64) = s
.eval("local t = applied[table.getn(applied)] return t.key, t.r, t.g, t.b, t.a")
.unwrap();
assert_eq!(
key, "bordercolor",
"the closed-over arg prefix is preserved"
);
assert_eq!((r, g, b), after_round_trip(0.1, 0.5, 0.9));
// 1e-6, not exact: a Slider holds `f32` (the client's `CSimpleSlider` does too), so
// `1 - GetValue()` is `1 - f32(0.4)` = 0.59999999…, not the literal 0.6.
assert!(
(a - 0.6).abs() < 1e-6,
"alpha is 1 - the slider's 0.4, got {a}"
);
}
/// **Cancel restores.** The half people get wrong: Cancel must put back the colour the addon came
/// in with, and it does it by *calling `cancelFunc`* — the window has no memory of its own.
#[test]
fn cancel_restores_the_previous_colour_through_cancel_func() {
let s = picker();
dewdrop_open(&s, 0.1, 0.5, 0.9, 0.25);
// The player fiddles: a new colour previewed live, and the opacity moved.
s.run("ColorPickerFrame:SetColorRGB(1, 0, 0)").unwrap();
s.run("OpacitySliderFrame:SetValue(0.05)").unwrap();
let (mid_r, mid_a): (f64, f64) = s
.eval("local t = applied[table.getn(applied)] return t.r, t.a")
.unwrap();
assert_eq!(mid_r, 1.0, "the live preview really did go red");
assert!((mid_a - 0.95).abs() < 1e-9);
// …then backs out.
s.run("ColorPickerCancelButton:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(!s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
let (r, g, b, a): (f64, f64, f64, f64) = s
.eval("local t = applied[table.getn(applied)] return t.r, t.g, t.b, t.a")
.unwrap();
assert_eq!(
(r, g, b, a),
(0.1, 0.5, 0.9, 0.25),
"cancelFunc replays the addon's OWN pre-open values — raw, never through the widget"
);
}
/// ESC cancels too, and that is a deviation working as designed: the reference hooks `<OnKeyDown>`
/// on the frame, this engine routes the press through `ToggleGameMenu`'s chain (UiPanels.xml), and
/// the rung cancel-CLICKS rather than hiding — so the colour comes back and the game menu does not
/// open behind it.
#[test]
fn escape_cancels_rather_than_merely_hiding() {
let s = picker();
dewdrop_open(&s, 0.1, 0.5, 0.9, 0.25);
s.run("ColorPickerFrame:SetColorRGB(1, 0, 0)").unwrap();
s.run("ToggleGameMenu()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(!s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
let (r, g, b): (f64, f64, f64) = s
.eval("local t = applied[table.getn(applied)] return t.r, t.g, t.b")
.unwrap();
assert_eq!(
(r, g, b),
(0.1, 0.5, 0.9),
"ESC ran cancelFunc, not a bare Hide"
);
}
/// `AceConsole-2.0.lua` l.1402-1406, verbatim in shape: fetch the Okay button's own handler, replace
/// it with one that calls the original and then does the addon's own commit. If `GetScript` returned
/// nil, or `SetScript` did not take, the addon's arm would never run — and 20+ corpus addons ship
/// this library.
#[test]
fn ace_console_can_chain_the_okay_buttons_onclick() {
let s = picker();
s.run(
r#"
chained = {}
ColorPickerFrame.func = function() table.insert(chained, "original") end
if ColorPickerOkayButton then
local ColorPickerOkayButton_OnClick = ColorPickerOkayButton:GetScript("OnClick")
ColorPickerOkayButton:SetScript("OnClick", function()
if ColorPickerOkayButton_OnClick then
ColorPickerOkayButton_OnClick()
end
table.insert(chained, "ace")
end)
end
ShowUIPanel(ColorPickerFrame)
"#,
)
.unwrap();
s.run("ColorPickerOkayButton:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(s.eval::<usize>("return table.getn(chained)").unwrap(), 2);
assert_eq!(s.eval::<String>("return chained[1]").unwrap(), "original");
assert_eq!(s.eval::<String>("return chained[2]").unwrap(), "ace");
assert!(
!s.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap(),
"the original handler still hid the window"
);
}
/// ESC's other path: `CloseWindows` walks `UISpecialFrames`, and `ColorPickerFrame` is in it now
/// (decision 1206 declined to seed it only because we shipped no such frame). This is the plain
/// `Hide` the reference does on that path — no cancel — reached when something else closes the
/// world's windows.
#[test]
fn the_picker_is_a_uispecialframe_and_close_windows_puts_it_away() {
let s = picker();
let listed: bool = s
.eval(
r#"
for _, name in ipairs(UISpecialFrames) do
if name == "ColorPickerFrame" then return true end
end
return false
"#,
)
.unwrap();
assert!(listed, "the reference's own entry is seeded");
s.run("ShowUIPanel(ColorPickerFrame)").unwrap();
let found: bool = s.eval("return CloseWindows() ~= nil").unwrap();
assert!(found, "CloseWindows reports it closed something");
assert!(!s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The dropdown's colour-swatch row — the other half of the same feature
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// A menu row with `hasColorSwatch` shows its square tinted to `r/g/b`, and clicking the square is
/// what opens the picker — seeding `func`/`hasOpacity`/`opacity`/`cancelFunc`, the colour, AND
/// `previousValues`, which is captured here and nowhere else. Then Cancel gets it back.
///
/// The info table is the reference's own documented shape (`UIDropDownMenu.lua` l.114-125) and the
/// exact one FloatingChatFrame's colour rows use.
#[test]
fn a_dropdown_row_with_has_color_swatch_opens_the_picker_and_cancel_restores() {
let s = picker();
s.run(
r#"
restored = {}
picked = {}
local dd = CreateFrame("Frame", "TestColorDropDown", nil, "UIDropDownMenuTemplate")
UIDropDownMenu_Initialize(dd, function()
local info = {}
info.text = "Border Color"
info.hasColorSwatch = 1
info.r = 0.2
info.g = 0.4
info.b = 0.6
info.hasOpacity = 1
info.opacity = 0.3
info.notCheckable = 1
info.swatchFunc = function()
local r, g, b = ColorPickerFrame:GetColorRGB()
table.insert(picked, r .. "," .. g .. "," .. b)
end
info.opacityFunc = function() end
info.cancelFunc = function(previous)
table.insert(restored, previous.r .. "," .. previous.g .. "," .. previous.b .. "," .. previous.opacity)
end
UIDropDownMenu_AddButton(info)
end, "MENU")
ToggleDropDownMenu(1, nil, dd, "TestColorDropDown", 0, 0)
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The square is up and wears the row's colour.
assert!(s
.eval::<bool>("return DropDownList1Button1ColorSwatch:IsVisible()")
.unwrap());
let (r, g, b): (f64, f64, f64) = s
.eval("return DropDownList1Button1ColorSwatchNormalTexture:GetVertexColor()")
.unwrap();
assert!(
(r - 0.2).abs() < 1e-6 && (g - 0.4).abs() < 1e-6 && (b - 0.6).abs() < 1e-6,
"the swatch is tinted to info.r/g/b, got {r},{g},{b}"
);
// Clicking it opens the picker, seeded from the row.
s.run("DropDownList1Button1ColorSwatch:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"CloseMenus() shut the menu first, the ref's own first line"
);
assert_eq!(
s.eval::<f64>("return ColorPickerFrame.opacity").unwrap(),
0.3
);
assert!(s
.eval::<bool>("return OpacitySliderFrame:IsVisible()")
.unwrap());
let (pr, pg, pb): (f64, f64, f64) = s.eval("return ColorPickerFrame:GetColorRGB()").unwrap();
assert_eq!((pr, pg, pb), after_round_trip(0.2, 0.4, 0.6));
// …and swatchFunc became the picker's live-preview `func`, which already ran once.
assert_eq!(s.eval::<usize>("return table.getn(picked)").unwrap(), 1);
// Cancel hands `previousValues` — the table OpenColorPicker captured — to the addon.
s.run("ColorPickerCancelButton:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(
s.eval::<String>("return restored[1]").unwrap(),
"0.2,0.4,0.6,0.3",
"cancelFunc(previousValues) carries r/g/b/opacity straight off the row"
);
}
/// A row WITHOUT `hasColorSwatch` keeps its square hidden — the branch that stops every ordinary
/// menu row from sprouting a colour button.
#[test]
fn a_row_without_the_flag_has_no_swatch() {
let s = picker();
s.run(
r#"
local dd = CreateFrame("Frame", "TestPlainDropDown", nil, "UIDropDownMenuTemplate")
UIDropDownMenu_Initialize(dd, function()
local info = {}
info.text = "Just A Row"
info.notCheckable = 1
UIDropDownMenu_AddButton(info)
end, "MENU")
ToggleDropDownMenu(1, nil, dd, "TestPlainDropDown", 0, 0)
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(!s
.eval::<bool>("return DropDownList1Button1ColorSwatch:IsVisible()")
.unwrap());
}
@@ -28,7 +28,7 @@ fn load_xml(s: &UiScript, file: &str) {
}
/// The manifest order this file actually ships in: `UiPanels.xml` for `UIFrameFade`,
/// `UnitFrames.xml` for the `BenillaTargetFrame` the dots anchor to.
/// `UnitFrames.xml` for the `TargetFrame` the dots anchor to.
fn load_combo_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -69,11 +69,11 @@ fn settle(s: &mut UiScript) {
}
fn shown(s: &mut UiScript) -> bool {
s.eval("return BenillaComboFrame:IsShown()").unwrap()
s.eval("return ComboFrame:IsShown()").unwrap()
}
fn highlight_alpha(s: &mut UiScript, i: u32) -> f64 {
s.eval(&format!("return BenillaComboPoint{i}Highlight:GetAlpha()"))
s.eval(&format!("return ComboPoint{i}Highlight:GetAlpha()"))
.unwrap()
}
@@ -111,7 +111,7 @@ fn combo_frame_follows_the_point_count() {
.eval(
r#"
for i = 1, MAX_COMBO_POINTS do
if getglobal("BenillaComboPoint" .. i .. "Highlight"):GetAlpha() < 0.99 then
if getglobal("ComboPoint" .. i .. "Highlight"):GetAlpha() < 0.99 then
return false
end
end
@@ -141,13 +141,13 @@ fn a_repaint_at_the_same_count_does_not_re_flare() {
settle(&mut s);
// Both settled: highlights up, shines burned out.
let shine: f64 = s.eval("return BenillaComboPoint2Shine:GetAlpha()").unwrap();
let shine: f64 = s.eval("return ComboPoint2Shine:GetAlpha()").unwrap();
assert_eq!(shine, 0.0, "the shine fades back out");
// A re-select of the SAME unit at the same count: no new fade is queued for either dot.
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let fading: bool = s
.eval("return UIFrameIsFading(BenillaComboPoint1Highlight) ~= nil")
.eval("return UIFrameIsFading(ComboPoint1Highlight) ~= nil")
.unwrap();
assert!(!fading, "an unchanged count re-flares nothing");
assert!(
+42 -5
View File
@@ -5,8 +5,9 @@
//! here instead of touching `std::fs`.
//!
//! Until 1175 they read `concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui")` — the *build*
//! machine's source tree. On a player's machine those 61 files resolve to nothing and the client
//! boots with no interface at all: not a crash, just an empty screen and 61 log lines nobody sees.
//! machine's source tree. On a player's machine every one of those files resolves to nothing and
//! the client boots with no interface at all: not a crash, just an empty screen and one log line
//! per manifest entry that nobody sees.
//! `assets/ui` is our own content (MIT/Apache, not Blizzard's — the contract's hard rule is
//! untouched), so it can simply be part of the program.
//!
@@ -23,7 +24,7 @@
//!
//! ## Why not the asset server
//!
//! [`super::manifest::MANIFEST`] is a 61-entry **dependency-ordered** manifest loaded synchronously
//! [`super::manifest::MANIFEST`] is a **dependency-ordered** manifest loaded synchronously
//! at a known point (a template must exist before the frame that inherits it), and the asset server
//! is async. Serving these through it would mean re-deriving a dependency graph that a hand-ordered
//! list already encodes correctly. This stays a byte lookup.
@@ -34,8 +35,10 @@
//! *not* built here (1175 "Rejected"), but the shape leaves room for it: it would be one more arm
//! at the front of [`read`]. Building it now would be a mod system with no mods.
/// The shipped UI tree, compiled in: `benilla.toc` and the 61 files it names, ~2.8 MB — under
/// 2.5 % of the release binary, and what it buys is "the interface cannot be missing".
/// The shipped UI tree, compiled in: `benilla.toc` and every file it names, a few MB — a small
/// single-digit % of the release binary, and what it buys is "the interface cannot be missing".
/// (A hard file count lived here until it had silently drifted by four; the manifest is the
/// authority on how many there are, and `manifest::tests` is what keeps the two agreeing.)
static UI: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/ui");
/// The text of one shipped UI file, by path relative to `assets/ui` — `None` if we do not ship it.
@@ -63,6 +66,40 @@ fn read_source_tree(req: &str) -> Option<String> {
std::fs::read_to_string(dir.join(req)).ok()
}
/// A digest of the FrameXML **this process will actually load** — the manifest plus every file it
/// names, hashed in load order, as eight hex digits.
///
/// It exists because a number taken from the corpus harness is meaningless without it. In a dev
/// build [`read`] prefers the SOURCE TREE, so editing an `assets/ui` file changes what a survey
/// loads **with no rebuild** — and this repo's worktrees are routinely shared by several agents at
/// once. Three separate measurements this arc were taken across a moving tree and one of them
/// landed a wrong attribution in a decision record: 87/218 was credited to a single table when a
/// controlled A/B later put it at 75, with the other twelve belonging to a neighbour's uncommitted
/// files. The stamp does not prevent that; it makes it visible, which is all an instrument can do.
///
/// FNV-1a, no dependency, not cryptographic and not meant to be: the only question it answers is
/// *were these two runs looking at the same interface*.
pub(crate) fn digest() -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut eat = |bytes: &[u8]| {
for b in bytes {
h ^= u64::from(*b);
h = h.wrapping_mul(0x1000_0000_01b3);
}
};
eat(super::manifest::MANIFEST.as_bytes());
if let Some(toc) = read(super::manifest::MANIFEST) {
eat(toc.as_bytes());
}
for name in super::addons::Addon::builtin().toc.files {
eat(name.as_bytes());
if let Some(text) = read(&name) {
eat(text.as_bytes());
}
}
format!("{:08x}", h as u32)
}
/// Every shipped file's path, relative to `assets/ui`. The compiled-in set is the authority —
/// a dev build must not appear to ship a file that is only on this machine.
#[cfg(test)]
@@ -72,7 +72,7 @@ fn setup() -> UiScript {
/// `DELETE_ITEM_CONFIRM(name, quality)` the driver listens for.
fn pick_up_and_drop_in_world(s: &mut UiScript) {
s.set_container(0, Some(one_item_backpack()));
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
// Off past every frame (the bag sits bottom-right; off-screen negative is always clear).
s.mouse_button(-50.0, -50.0, "LeftButton", true);
@@ -153,7 +153,7 @@ fn delete_item_confirm_no_clears_without_destroying() {
"the clear's ITEM_LOCK_CHANGED repaints the bag (the stuck-darkened slot, 0218)"
);
assert!(
!s.eval::<bool>("local i = C_Container.GetContainerItemInfo(0, 1) return i.isLocked")
!s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the source slot reads unlocked again"
);
@@ -102,9 +102,7 @@ fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
s.set_screen_size(1024.0, 768.0);
load_room(&s);
let (x, y) = backpack_with_jerky(&mut s);
assert!(!s
.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return DressUpFrame:IsVisible()").unwrap());
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
@@ -112,8 +110,7 @@ fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
s.set_modifiers(false, false, false);
assert!(
s.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return DressUpFrame:IsVisible()").unwrap(),
"ctrl-click opened the dressing room"
);
assert_eq!(
@@ -145,11 +142,10 @@ fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"with chat closed, shift-click still opens the stack splitter"
);
s.run("BenillaStackSplitFrame:Hide()").unwrap();
s.run("StackSplitFrame:Hide()").unwrap();
// Chat open → the link, and no splitter.
assert!(s.focus_editbox("ChatFrameEditBox"));
@@ -164,8 +160,7 @@ fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
"the item's full escaped link landed in the chat box"
);
assert!(
!s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"with chat open the splitter must NOT open"
);
assert!(
@@ -197,8 +192,7 @@ fn ctrl_clicking_a_chat_link_previews_it() {
"a chat link's id reached TryOn through DressUpItemLink's own gsub"
);
assert!(
s.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return DressUpFrame:IsVisible()").unwrap(),
"and the room opened"
);
// The plain click still shows the link tooltip rather than the room (no modifier).
@@ -243,7 +237,7 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
// CTRL on the head slot → the room, wearing the helm.
s.set_modifiers(false, true, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
@@ -254,7 +248,7 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
// SHIFT with chat open → the link; the item is never picked up either way.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
@@ -299,7 +293,7 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
@@ -312,7 +306,7 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
// And ctrl on the same unresolved slot is inert too (DressUpItemLink's own nil guard).
s.set_modifiers(false, true, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
s.run("BenillaPaperDollSlot_OnClick(CharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert!(
@@ -330,7 +324,7 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
s.run("ShowUIPanel(BenillaDressUpFrame)").unwrap();
s.run("ShowUIPanel(DressUpFrame)").unwrap();
let _ = s.take_sounds();
let _ = s.take_dressup_intents();
s.resolve();
@@ -341,7 +335,7 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
"ref UIParent.lua:1422"
);
s.run("BenillaDressUpFrameResetButton:Click()").unwrap();
s.run("DressUpFrameResetButton:Click()").unwrap();
assert_eq!(s.take_dressup_intents(), vec![DressUpIntent::Dress]);
assert_eq!(
s.take_sounds(),
@@ -353,10 +347,10 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
let before = s.dressup_yaw();
let (x, y) = s
.eval::<(f32, f32)>(
"return (BenillaDressUpModelFrameRotateLeftButton:GetLeft() \
+ BenillaDressUpModelFrameRotateLeftButton:GetRight()) / 2, \
(BenillaDressUpModelFrameRotateLeftButton:GetTop() \
+ BenillaDressUpModelFrameRotateLeftButton:GetBottom()) / 2",
"return (DressUpModelFrameRotateLeftButton:GetLeft() \
+ DressUpModelFrameRotateLeftButton:GetRight()) / 2, \
(DressUpModelFrameRotateLeftButton:GetTop() \
+ DressUpModelFrameRotateLeftButton:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(x, y, "LeftButton", true);
@@ -368,7 +362,7 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
s.dressup_yaw()
);
s.run("HideUIPanel(BenillaDressUpFrame)").unwrap();
s.run("HideUIPanel(DressUpFrame)").unwrap();
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Close],
@@ -404,7 +404,7 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
s.resolve();
let dur_bottom: f32 = s.eval("return DurabilityFrame:GetBottom()").unwrap();
let watch_top: f32 = s.eval("return BenillaQuestWatchFrame:GetTop()").unwrap();
let watch_top: f32 = s.eval("return QuestWatchFrame:GetTop()").unwrap();
assert!(
(watch_top - dur_bottom).abs() <= 0.5,
"tracker top {watch_top} must sit flush under the durability bottom {dur_bottom}"
@@ -30,9 +30,7 @@ fn info_and_error_messages_stack_hold_and_expire() {
load_xml(&s, "ErrorsFrame.xml");
// Empty at load.
assert!(!s
.eval::<bool>("return BenillaErrorsFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
// A quest progress toast: yellow (ref UIErrorsFrame.lua:12), on the top line.
s.fire_event(
@@ -40,17 +38,15 @@ fn info_and_error_messages_stack_hold_and_expire() {
vec![ScriptValue::Str("Tough Wolf Meat: 2/8".into())],
);
assert!(s.errors().is_empty(), "info errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaErrorsFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return BenillaErrorsFrameLine1:GetText()")
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"Tough Wolf Meat: 2/8"
);
assert!(s
.eval::<bool>(
"local m = BenillaErrorsFrame.messages[1] return m.r == 1.0 and m.g == 1.0 and m.b == 0.0"
"local m = UIErrorsFrame.messages[1] return m.r == 1.0 and m.g == 1.0 and m.b == 0.0"
)
.unwrap());
@@ -61,35 +57,31 @@ fn info_and_error_messages_stack_hold_and_expire() {
vec![ScriptValue::Str("You are too far away!".into())],
);
assert_eq!(
s.eval::<String>("return BenillaErrorsFrameLine1:GetText()")
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"You are too far away!"
);
assert_eq!(
s.eval::<String>("return BenillaErrorsFrameLine2:GetText()")
s.eval::<String>("return UIErrorsFrameLine2:GetText()")
.unwrap(),
"Tough Wolf Meat: 2/8"
);
assert!(s
.eval::<bool>(
"local m = BenillaErrorsFrame.messages[1] return m.r == 1.0 and m.g == 0.1 and m.b == 0.1"
"local m = UIErrorsFrame.messages[1] return m.r == 1.0 and m.g == 0.1 and m.b == 0.1"
)
.unwrap());
// Hold 5 s (ref displayDuration), then the fade window, then gone — the frame hides once the
// last message expires.
s.tick(4.0);
assert!(s
.eval::<bool>("return BenillaErrorsFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
s.tick(2.5); // both past 5 s hold + 1 s fade
assert!(!s
.eval::<bool>("return BenillaErrorsFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "expiry errors: {:?}", s.errors());
}
/// **The toast must outrank an open panel window.** `BenillaErrorsFrame` is 512x60 at TOP (0,-122)
/// **The toast must outrank an open panel window.** `UIErrorsFrame` is 512x60 at TOP (0,-122)
/// and every left-slot panel is 384-wide at TOPLEFT (0,-104), so they overlap. The reference puts
/// this frame in HIGH (`UIErrorsFrame.xml` l.4) and benilla had dropped it, leaving it in the
/// default MEDIUM alongside the panels.
@@ -111,18 +103,14 @@ fn an_error_toast_draws_over_an_open_panel_window() {
load_xml(&s, "QuestLogFrame.xml");
// A left-slot panel open, and the toast raised after it — the order that must not decide.
s.eval::<()>("ShowUIPanel(BenillaQuestLogFrame)").unwrap();
s.eval::<()>("ShowUIPanel(QuestLogFrame)").unwrap();
s.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str("Out of range.".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaErrorsFrame:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestLogFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return UIErrorsFrame:IsVisible()").unwrap());
assert!(s.eval::<bool>("return QuestLogFrame:IsVisible()").unwrap());
s.resolve();
let quads = s.extract();
@@ -85,14 +85,14 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Pick up slot 1 onto the cursor.
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "cursor holds the picked item");
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"bag is open before ESC"
);
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaLootFrame\"")
s.eval::<bool>("return GetLeftFrame():GetName() == \"LootFrame\"")
.unwrap(),
"loot holds the left panel slot before ESC"
);
@@ -115,8 +115,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
"ESC vacated the panel slot"
);
assert!(
!s.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return LootFrame:IsVisible()").unwrap(),
"ESC hid the loot window"
);
assert!(
@@ -266,14 +265,11 @@ fn escape_closes_an_open_stack_split_frame() {
)
.unwrap();
s.set_modifiers(false, false, false);
assert!(s
.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap());
assert!(s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"ESC closed the split frame"
);
// ESC's unconditional cursor-clear (ToggleGameMenu's own first line) ran too — nothing was
@@ -445,3 +441,51 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`UISpecialFrames` — an addon's own ESC list** (decision 1206).
///
/// `tinsert(UISpecialFrames, "MyFrame")` is *the* 1.12 idiom for "ESC should close my window", and
/// 34 corpus call sites use it. With the table absent that line was
/// `bad argument #1 to 'tinsert' (table expected, got nil)` — 11 addons dead at load, the
/// second-largest wall in the survey.
///
/// The walk is the reference's own (`UIParent.lua` l.947-954, inside `CloseWindows`): a plain
/// `Hide`, never `HideUIPanel`, because these frames hold no panel slot.
#[test]
fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameMenuFrame.xml");
// The addon's three lines, verbatim in shape.
s.run(
r#"
MyAddonWindow = CreateFrame("Frame", "MyAddonWindow", UIParent)
MyAddonWindow:Show()
tinsert(UISpecialFrames, "MyAddonWindow")
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return MyAddonWindow:IsVisible()").unwrap());
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return MyAddonWindow:IsVisible()").unwrap(),
"ESC closed the addon's window"
);
// ...and it ATE the press: the menu must not also open, or ESC would close the window and
// raise the menu in one keystroke.
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"CloseAllWindows returning truthy is what stops the chain (UIParent.lua l.1491)"
);
// A name in the table that resolves to nothing is skipped, not an error — the reference's own
// `if (frame and frame:IsVisible())`.
s.run(r#"tinsert(UISpecialFrames, "NoSuchFrameAnywhere") ToggleGameMenu()"#)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -51,17 +51,17 @@ fn the_xp_bar_takes_the_mouse_and_explains_itself() {
// the mouse ahead of it.
let (x, y) = s
.eval::<(f64, f64)>(
"return BenillaExpBar:GetLeft() + BenillaExpBar:GetWidth() / 4, \
(BenillaExpBar:GetBottom() + BenillaExpBar:GetTop()) / 2",
"return MainMenuExpBar:GetLeft() + MainMenuExpBar:GetWidth() / 4, \
(MainMenuExpBar:GetBottom() + MainMenuExpBar:GetTop()) / 2",
)
.unwrap();
assert_eq!(
s.hit_test_name(x as f32, y as f32).as_deref(),
Some("BenillaExpBar"),
Some("MainMenuExpBar"),
"the XP strip must be mouse-enabled or the hover never fires"
);
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -120,15 +120,15 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
let ok: bool = s
.eval(
r#"
local tick, fill = BenillaExhaustionTick, BenillaExhaustionLevelFillBar
local expected = (1000 + 1400) / 10000 * BenillaExpBar:GetWidth()
local tick, fill = ExhaustionTick, ExhaustionLevelFillBar
local expected = (1000 + 1400) / 10000 * MainMenuExpBar:GetWidth()
local x = tick:GetCenter()
local r, g, b = BenillaExpBar:GetStatusBarColor()
local r, g, b = MainMenuExpBar:GetStatusBarColor()
-- fill's width via its RESOLVED edges: the authored size is 0 x 13, and the engine
-- derives the span from the TOPLEFT + runtime-TOPRIGHT anchor pair (layout.rs's
-- zero-size law), exactly as the real client does.
return tick:IsVisible() and fill:IsShown()
and math.abs((x - BenillaExpBar:GetLeft()) - expected) < 0.5
and math.abs((x - MainMenuExpBar:GetLeft()) - expected) < 0.5
and math.abs((fill:GetRight() - fill:GetLeft()) - expected) < 0.5
and r == 0.0 and math.abs(g - 0.39) < 0.001 and math.abs(b - 0.88) < 0.001
"#,
@@ -142,9 +142,9 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
let ok: bool = s
.eval(
r#"
local r, g, b = BenillaExpBar:GetStatusBarColor()
return not BenillaExhaustionTick:IsShown()
and not BenillaExhaustionLevelFillBar:IsShown()
local r, g, b = MainMenuExpBar:GetStatusBarColor()
return not ExhaustionTick:IsShown()
and not ExhaustionLevelFillBar:IsShown()
and GetXPExhaustion() == nil
and math.abs(r - 0.58) < 0.001 and g == 0.0 and math.abs(b - 0.55) < 0.001
"#,
@@ -186,9 +186,9 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
let ok: bool = s
.eval(
r#"
local r, g, b = BenillaExpBar:GetStatusBarColor()
return not BenillaExhaustionTick:IsShown()
and not BenillaExhaustionLevelFillBar:IsShown()
local r, g, b = MainMenuExpBar:GetStatusBarColor()
return not ExhaustionTick:IsShown()
and not ExhaustionLevelFillBar:IsShown()
and math.abs(g - 0.39) < 0.001
"#,
)
@@ -225,8 +225,8 @@ fn the_max_level_rail_replaces_the_xp_bar_at_60() {
let ok: bool = s
.eval(
r#"
return BenillaExpBar:IsShown() and not BenillaMaxLevelBar:IsShown()
and BenillaExhaustionTick:IsShown()
return MainMenuExpBar:IsShown() and not MainMenuBarMaxLevelBar:IsShown()
and ExhaustionTick:IsShown()
"#,
)
.unwrap();
@@ -239,8 +239,8 @@ fn the_max_level_rail_replaces_the_xp_bar_at_60() {
let ok: bool = s
.eval(
r#"
return not BenillaExpBar:IsShown() and BenillaMaxLevelBar:IsShown()
and not BenillaExhaustionTick:IsShown()
return not MainMenuExpBar:IsShown() and MainMenuBarMaxLevelBar:IsShown()
and not ExhaustionTick:IsShown()
"#,
)
.unwrap();
@@ -264,8 +264,9 @@ fn the_gryphons_outrank_the_bars_across_hide_show_cycles() {
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The relog shape: the strip hidden (a stale max-level rail), then re-shown; the rail's
// own arrival is ALWAYS a hidden→visible re-stamp (it is born hidden).
s.run("BenillaExpBar:Hide() BenillaExpBar:Show()").unwrap();
s.run("BenillaMaxLevelBar:Show()").unwrap();
s.run("MainMenuExpBar:Hide() MainMenuExpBar:Show()")
.unwrap();
s.run("MainMenuBarMaxLevelBar:Show()").unwrap();
s.resolve();
let quads = s.extract();
let z_max = |suffix: &str| {
@@ -298,19 +299,19 @@ fn the_xp_bar_numerals_show_on_hover() {
s.set_player_xp(1234, 5678);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
assert_eq!(
s.eval::<String>("return BenillaExpBarText:GetText()")
s.eval::<String>("return MainMenuBarExpText:GetText()")
.unwrap(),
"1234 / 5678"
);
assert!(s
.eval::<bool>("return BenillaExpBarText:IsShown()")
.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap());
s.run("BenillaExpBar_OnLeave(BenillaExpBar)").unwrap();
s.run("BenillaExpBar_OnLeave(MainMenuExpBar)").unwrap();
assert!(
!s.eval::<bool>("return BenillaExpBarText:IsShown()")
!s.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap(),
"leaving drops the lockShow refcount to 0 and hides the numerals"
);
@@ -330,16 +331,16 @@ fn the_rest_state_line_joins_the_held_open_tooltip() {
// 1.12's default posture (0661): detailed newbie tips ON.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.run("BenillaExpBar_OnEnter(BenillaExpBar)").unwrap();
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaExhaustionTick.timer").unwrap(),
s.eval::<f64>("return ExhaustionTick.timer").unwrap(),
1.0,
"the bar hover arms the tick's timer"
);
// Three OnUpdate ticks walk the timer 1 → 0.4 → −0.2 → fire (the ref's `< 0` edge).
for _ in 0..3 {
s.run("BenillaExhaustionTick_OnUpdate(0.6)").unwrap();
s.run("ExhaustionTick_OnUpdate(0.6)").unwrap();
}
let appended: String = s.eval("return GameTooltipTextLeft3:GetText()").unwrap();
assert!(
@@ -0,0 +1,360 @@
//! The shipped `assets/ui/ScrollTemplates.xml` faux-scroll kit, under the **reference's own
//! names** — `FauxScrollFrame_Update` / `_GetOffset` / `_SetOffset` / `_OnVerticalScroll` and
//! `ScrollFrame_OnLoad` (decision 1190: a name the shipped 1.12 UI defines is FrameXML's).
//!
//! These drive the kit the way an addon does — a bare instance of `FauxScrollFrameTemplate` with
//! rows of its own — rather than through one of our six owner windows, because the API is now a
//! public surface and its edges (a list that overflows, one that exactly fits, one that shrinks
//! under a scrolled offset, the shrink/widen tail, the return value) belong to it and not to any
//! window. The windows' own tests still cover their wiring.
//!
//! The last test is the one worth reading: it drives the reference's *whole* path — bar drag →
//! `SetVerticalScroll` → the frame's `<OnVerticalScroll>` → `FauxScrollFrame_OnVerticalScroll` —
//! on a real `<ScrollFrame>` with a scroll child, which is exactly the shape 27 corpus addons
//! write. It has to set the scroll child by hand (`SetScrollChild`) because our XML loader has no
//! `<ScrollChild>` element yet; that one missing element is the entire distance between this test
//! and an addon's list scrolling in-game, which is why it is pinned here rather than described.
use benilla_ui::script::UiScript;
/// One shipped file into `s`, asserting it loaded clean.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// A document written here in the test, loaded against the templates already registered — the
/// cross-file template registry is on the `Model`, so an inline doc reaches `ScrollTemplates.xml`'s
/// `FauxScrollFrameTemplate` exactly as `TrainerFrame.xml` does.
fn load_inline(s: &UiScript, xml: &str) {
let doc = benilla_ui::framexml::parse(xml).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
}
/// The kit plus a list an addon might own: five visible rows, a moving highlight, and a faux
/// scroll frame over them.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "ScrollTemplates.xml");
load_inline(
&s,
r#"<Ui>
<Frame name="TestList">
<Size><AbsDimension x="300" y="120"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Frames>
<Button name="TestRow1"><Size><AbsDimension x="300" y="16"/></Size></Button>
<Button name="TestRow2"><Size><AbsDimension x="300" y="16"/></Size></Button>
<Button name="TestRow3"><Size><AbsDimension x="300" y="16"/></Size></Button>
<Button name="TestRow4"><Size><AbsDimension x="300" y="16"/></Size></Button>
<Button name="TestRow5"><Size><AbsDimension x="300" y="16"/></Size></Button>
<Frame name="TestHighlight"><Size><AbsDimension x="300" y="16"/></Size></Frame>
<Frame name="TestScroll" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="290" y="80"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// `ScrollFrame_OnLoad` ran off the template: a fresh frame is at the top, with an empty range and
/// both arrows greyed. (The reference's own `UIPanelScrollFrameTemplate` OnLoad — the name we now
/// wear instead of a `Benilla*` one.)
#[test]
fn a_fresh_faux_frame_loads_at_the_top_with_no_range() {
let s = harness();
assert_eq!(s.eval::<i64>("return TestScroll.offset").unwrap(), 0);
let (lo, hi) = s
.eval::<(f64, f64)>("return TestScrollScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!((lo, hi), (0.0, 0.0));
assert!(
!s.eval::<bool>("return TestScrollScrollBarScrollUpButton:IsEnabled()")
.unwrap(),
"up arrow greyed at load (ref UIPanelTemplates.lua l.245-246)"
);
assert!(!s
.eval::<bool>("return TestScrollScrollBarScrollDownButton:IsEnabled()")
.unwrap());
}
/// **More rows than fit**: the bar comes up, its range is the overflow in pixels, its step is one
/// row, and `FauxScrollFrame_Update` returns the reference's `showScrollBar`.
#[test]
fn more_rows_than_fit_raise_the_bar_over_the_overflow_range() {
let s = harness();
let shown = s
.eval::<i64>("return FauxScrollFrame_Update(TestScroll, 12, 5, 16)")
.unwrap();
assert_eq!(shown, 1, "the ref returns 1 when the bar is up");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let (lo, hi) = s
.eval::<(f64, f64)>("return TestScrollScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!(
(lo, hi),
(0.0, 112.0),
"(12 - 5) rows * 16px — the value model is PIXELS (ScrollTemplates.xml's header)"
);
assert_eq!(
s.eval::<f64>("return TestScrollScrollBar:GetValueStep()")
.unwrap(),
16.0
);
assert!(s
.eval::<bool>("return TestScrollScrollBar:IsVisible()")
.unwrap());
assert!(
s.eval::<bool>("return TestScroll:IsVisible()").unwrap(),
"the frame itself too — the ref's own frame:Show(), which is what an addon reads back"
);
assert!(
!s.eval::<bool>("return TestScrollScrollBarScrollUpButton:IsEnabled()")
.unwrap(),
"at the top: up greyed"
);
assert!(s
.eval::<bool>("return TestScrollScrollBarScrollDownButton:IsEnabled()")
.unwrap());
}
/// **Exactly fitting** is the boundary the reference draws at `numItems > numToDisplay`: five rows
/// in five slots shows nothing, and there is no off-by-one that leaves a dead bar up.
#[test]
fn a_list_that_exactly_fits_shows_no_bar() {
let s = harness();
let shown = s
.eval::<Option<i64>>("return FauxScrollFrame_Update(TestScroll, 5, 5, 16)")
.unwrap();
assert_eq!(shown, None, "the ref returns nil when the bar stays down");
assert!(!s
.eval::<bool>("return TestScrollScrollBar:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return TestScroll:IsVisible()").unwrap());
let (_, hi) = s
.eval::<(f64, f64)>("return TestScrollScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!(hi, 0.0, "no overflow, no range");
// One more row and it appears — the same call, one item along.
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_Update(TestScroll, 6, 5, 16)")
.unwrap(),
1
);
assert!(s
.eval::<bool>("return TestScrollScrollBar:IsVisible()")
.unwrap());
}
/// **Fewer rows than fit**, arrived at by the list SHRINKING under a scrolled offset — the case
/// that leaves a window painting rows that are not there if the clamp is missing. The reference
/// clamps by setting the bar to 0; we clamp the offset itself, which is what the owner's repaint
/// reads back through `FauxScrollFrame_GetOffset`.
#[test]
fn a_shrinking_list_clamps_the_offset_back_into_range() {
let s = harness();
s.run("FauxScrollFrame_Update(TestScroll, 20, 5, 16)")
.unwrap();
s.run("FauxScrollFrame_SetOffset(TestScroll, 12)").unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
12
);
// The list drops to 8 rows: the deepest legal offset is now 3.
s.run("FauxScrollFrame_Update(TestScroll, 8, 5, 16)")
.unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
3,
"clamped to numItems - numToDisplay, not left at 12"
);
assert_eq!(
s.eval::<f64>("return TestScrollScrollBar:GetValue()")
.unwrap(),
48.0,
"and the thumb followed the clamp"
);
// Down to fewer than fit: offset 0, bar gone, and nothing left pointing past the end.
s.run("FauxScrollFrame_Update(TestScroll, 3, 5, 16)")
.unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
0
);
assert!(!s
.eval::<bool>("return TestScrollScrollBar:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's **shrink/widen tail** — the six trailing arguments 28 corpus addons pass and
/// benilla's own windows never do. Rows narrow when the bar takes the gutter and widen back when
/// it leaves (ref UIPanelTemplates.lua l.205-223).
#[test]
fn the_shrink_widen_tail_resizes_the_rows_and_the_highlight() {
let s = harness();
s.run(
"FauxScrollFrame_Update(TestScroll, 12, 5, 16, \"TestRow\", 280, 300, TestHighlight, 276, 296)",
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
for i in 1..=5 {
assert_eq!(
s.eval::<f64>(&format!("return TestRow{i}:GetWidth()"))
.unwrap(),
280.0,
"row {i} narrowed for the bar"
);
}
assert_eq!(
s.eval::<f64>("return TestHighlight:GetWidth()").unwrap(),
276.0
);
// The list fits again: every row goes back to the wide measurement.
s.run(
"FauxScrollFrame_Update(TestScroll, 4, 5, 16, \"TestRow\", 280, 300, TestHighlight, 276, 296)",
)
.unwrap();
for i in 1..=5 {
assert_eq!(
s.eval::<f64>(&format!("return TestRow{i}:GetWidth()"))
.unwrap(),
300.0,
"row {i} widened back"
);
}
assert_eq!(
s.eval::<f64>("return TestHighlight:GetWidth()").unwrap(),
296.0
);
}
/// Dragging the bar moves the offset a whole row at a time and repaints the owner's list — the
/// value model in one test (pixels on the bar, rows in the offset, `floor(v/step + 0.5)` between).
#[test]
fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
let s = harness();
s.run(
"TestRepaints = 0 TestScroll.updateFunc = function() TestRepaints = TestRepaints + 1 end",
)
.unwrap();
s.run("FauxScrollFrame_Update(TestScroll, 12, 5, 16)")
.unwrap();
let before = s.eval::<i64>("return TestRepaints").unwrap();
s.run("TestScrollScrollBar:SetValue(48)").unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
3
);
assert_eq!(
s.eval::<i64>("return TestRepaints").unwrap(),
before + 1,
"exactly one repaint per row the drag crossed"
);
// A sub-row nudge rounds to the nearest row and, landing on the same one, repaints nothing.
s.run("TestScrollScrollBar:SetValue(51)").unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
3
);
assert_eq!(s.eval::<i64>("return TestRepaints").unwrap(), before + 1);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The addon path, end to end** — the reference's wiring rather than ours: a real `<ScrollFrame>`
/// on `FauxScrollFrameTemplate` whose `<OnVerticalScroll>` calls `FauxScrollFrame_OnVerticalScroll`,
/// driven by a drag of the shared bar.
///
/// The `SetScrollChild` line is the gap, made visible: the reference's template declares that child
/// as `<ScrollChild><Frame name="$parentScrollChildFrame">`, our XML loader has no `<ScrollChild>`
/// element, and `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]` — which is computed
/// from that child. Without it the range is 0, the handler fires with `arg1 = 0` and an addon's list
/// never leaves the top. With it, the whole reference path runs.
#[test]
fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
let mut s = harness();
load_inline(
&s,
r#"<Ui>
<ScrollFrame name="AddonScroll" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="290" y="80"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(16, AddonScroll_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<Frame name="AddonScrollChildFrame">
<Size><AbsDimension x="290" y="192"/></Size>
</Frame>
</Ui>"#,
);
s.run("AddonRepaints = 0 function AddonScroll_Update() AddonRepaints = AddonRepaints + 1 end")
.unwrap();
// The instance's own tag wins over the template's on inherit, so this really is a ScrollFrame.
assert!(
s.eval::<bool>("return type(AddonScroll.SetVerticalScroll) == 'function'")
.unwrap(),
"<ScrollFrame inherits=\"FauxScrollFrameTemplate\"> is a ScrollFrame"
);
s.run("AddonScroll:SetScrollChild(AddonScrollChildFrame)")
.unwrap();
s.resolve();
s.run("AddonScroll:UpdateScrollChildRect()").unwrap();
assert_eq!(
s.eval::<f64>("return AddonScroll:GetVerticalScrollRange()")
.unwrap(),
112.0,
"192px of rows in an 80px window — the same overflow the bar's range carries"
);
s.run("FauxScrollFrame_Update(AddonScroll, 12, 5, 16)")
.unwrap();
s.run("AddonScrollScrollBar:SetValue(48)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(AddonScroll)")
.unwrap(),
3,
"the bar's value reached the frame's OnVerticalScroll and became a row offset"
);
assert!(
s.eval::<i64>("return AddonRepaints").unwrap() >= 1,
"and the addon's own update function ran"
);
}
@@ -82,19 +82,17 @@ fn push(s: &mut UiScript, state: SocialState, event: &str) {
fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
let s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
assert!(s.eval::<bool>("return FriendsFrame:IsVisible()").unwrap());
assert!(s
.eval::<bool>("return BenillaFriendsFrame:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaFriendsListFrame:IsVisible()")
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameTitleText:GetText()")
s.eval::<String>("return FriendsFrameTitleText:GetText()")
.unwrap(),
"Friends List"
);
assert_eq!(
s.eval::<i64>("return BenillaFriendsFrameTab3.isDisabled or 0")
s.eval::<i64>("return FriendsFrameTab3.isDisabled or 0")
.unwrap(),
1,
"the guild tab is disabled, not absent"
@@ -104,22 +102,20 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
// ref's compact TabButtonTemplate (16px). Getting this wrong is invisible to every behaviour
// assertion — it only shows up as the wrong-looking window.
assert_eq!(
s.eval::<f64>("return BenillaFriendsFrameTab1Left:GetWidth()")
s.eval::<f64>("return FriendsFrameTab1Left:GetWidth()")
.unwrap(),
20.0,
"the window tab strip keeps the big tab art"
);
assert_eq!(
s.eval::<f64>("return BenillaFriendsFrameToggleTab1Left:GetWidth()")
s.eval::<f64>("return FriendsFrameToggleTab1Left:GetWidth()")
.unwrap(),
16.0,
"the in-panel toggle pair is the compact tab art"
);
// …and toggling the same tab again closes the window (the ref's own second branch).
s.run("ToggleFriendsFrame(1)").unwrap();
assert!(!s
.eval::<bool>("return BenillaFriendsFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return FriendsFrame:IsVisible()").unwrap());
}
/// A friend row shows name/zone/status on its top line and "Level N Class" underneath; an
@@ -146,27 +142,27 @@ fn friend_rows_render_the_online_and_offline_templates() {
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameFriendButton1NameLocation:GetText()")
s.eval::<String>("return FriendsFrameFriendButton1NameLocation:GetText()")
.unwrap(),
"Onerogue |cffffffff- Elwynn Forest|r <AFK>"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameFriendButton1Info:GetText()")
s.eval::<String>("return FriendsFrameFriendButton1Info:GetText()")
.unwrap(),
"Level 60 Rogue"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameFriendButton2NameLocation:GetText()")
s.eval::<String>("return FriendsFrameFriendButton2NameLocation:GetText()")
.unwrap(),
"|cff999999Twomage - Offline|r"
);
assert!(
s.eval::<bool>("return BenillaFriendsFrameFriendButton2:IsVisible()")
s.eval::<bool>("return FriendsFrameFriendButton2:IsVisible()")
.unwrap(),
"both rows shown"
);
assert!(
!s.eval::<bool>("return BenillaFriendsFrameFriendButton3:IsVisible()")
!s.eval::<bool>("return FriendsFrameFriendButton3:IsVisible()")
.unwrap(),
"rows past the list are hidden"
);
@@ -182,10 +178,8 @@ fn the_friend_buttons_follow_the_selection() {
push(&mut s, SocialState::default(), "FRIENDLIST_UPDATE");
for button in ["SendMessage", "GroupInvite", "RemoveFriend"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaFriendsFrame{button}Button:IsEnabled()"
))
.unwrap(),
!s.eval::<bool>(&format!("return FriendsFrame{button}Button:IsEnabled()"))
.unwrap(),
"{button} is disabled with no friends"
);
}
@@ -200,12 +194,12 @@ fn the_friend_buttons_follow_the_selection() {
"FRIENDLIST_UPDATE",
);
assert!(
s.eval::<bool>("return BenillaFriendsFrameRemoveFriendButton:IsEnabled()")
s.eval::<bool>("return FriendsFrameRemoveFriendButton:IsEnabled()")
.unwrap(),
"an offline friend can still be removed"
);
assert!(
!s.eval::<bool>("return BenillaFriendsFrameSendMessageButton:IsEnabled()")
!s.eval::<bool>("return FriendsFrameSendMessageButton:IsEnabled()")
.unwrap(),
"…but not whispered"
);
@@ -231,19 +225,19 @@ fn the_friend_buttons_queue_their_verbs() {
);
let _ = s.take_social_requests();
s.run("BenillaFriendsFrame_RemoveFriend()").unwrap();
s.run("FriendsFrame_RemoveFriend()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::RemoveFriendIndex(2)]
);
s.run("BenillaFriendsFrame_GroupInvite()").unwrap();
s.run("FriendsFrame_GroupInvite()").unwrap();
assert!(s.take_party_requests().iter().any(|r| matches!(
r,
benilla_ui::script::PartyRequest::InviteName(n) if n == "Twomage"
)));
s.run("BenillaFriendsFrame_SendMessage()").unwrap();
s.run("FriendsFrame_SendMessage()").unwrap();
assert_eq!(s.take_tell_requests(), vec!["Twomage".to_string()]);
}
@@ -263,32 +257,32 @@ fn the_ignore_list_is_the_other_half_of_tab_one() {
"IGNORELIST_UPDATE",
);
s.run("BenillaFriendsFrameToggleTab2:Click()").unwrap();
s.run("FriendsFrameToggleTab2:Click()").unwrap();
assert!(s
.eval::<bool>("return BenillaIgnoreListFrame:IsVisible()")
.eval::<bool>("return IgnoreListFrame:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaFriendsListFrame:IsVisible()")
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameTitleText:GetText()")
s.eval::<String>("return FriendsFrameTitleText:GetText()")
.unwrap(),
"Ignore List"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameIgnoreButton1Name:GetText()")
s.eval::<String>("return FriendsFrameIgnoreButton1Name:GetText()")
.unwrap(),
"Spammer"
);
assert_eq!(
s.eval::<String>("return BenillaFriendsFrameIgnoreButton2Name:GetText()")
s.eval::<String>("return FriendsFrameIgnoreButton2Name:GetText()")
.unwrap(),
"Ninja"
);
// Remove Player un-ignores the selected name.
let _ = s.take_social_requests();
s.run("BenillaFriendsFrame_UnIgnore()").unwrap();
s.run("FriendsFrame_UnIgnore()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::DelIgnore("Spammer".to_string())]
@@ -296,13 +290,13 @@ fn the_ignore_list_is_the_other_half_of_tab_one() {
// Back to the friends half — through the IGNORE frame's own Friends tab, since the friends
// frame's copy is hidden with it (the ref ships the pair twice for exactly this reason).
s.run("BenillaIgnoreFrameToggleTab1:Click()").unwrap();
s.run("IgnoreFrameToggleTab1:Click()").unwrap();
assert!(s
.eval::<bool>("return BenillaFriendsListFrame:IsVisible()")
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
s.run("ShowIgnorePanel()").unwrap();
assert!(s
.eval::<bool>("return BenillaIgnoreListFrame:IsVisible()")
.eval::<bool>("return IgnoreListFrame:IsVisible()")
.unwrap());
}
@@ -330,34 +324,31 @@ fn who_rows_fill_their_columns() {
"WHO_LIST_UPDATE",
);
assert!(s
.eval::<bool>("return BenillaWhoFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return WhoFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Name:GetText()")
s.eval::<String>("return WhoFrameButton1Name:GetText()")
.unwrap(),
"Tigole"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Level:GetText()")
s.eval::<String>("return WhoFrameButton1Level:GetText()")
.unwrap(),
"40"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Class:GetText()")
s.eval::<String>("return WhoFrameButton1Class:GetText()")
.unwrap(),
"Rogue",
"class, not race — the API returns race first but the column is class"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Variable:GetText()")
s.eval::<String>("return WhoFrameButton1Variable:GetText()")
.unwrap(),
"Westfall",
"the variable column defaults to Zone (dropdown entry 1)"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameTotals:GetText()")
.unwrap(),
s.eval::<String>("return WhoFrameTotals:GetText()").unwrap(),
"1 Person Found ",
"singular template for one hit"
);
@@ -386,16 +377,16 @@ fn the_who_dropdown_switches_the_variable_column() {
);
let _ = s.take_social_requests();
s.run("UIDropDownMenu_SetSelectedID(BenillaWhoFrameDropDown, 2); BenillaWhoList_Update()")
s.run("UIDropDownMenu_SetSelectedID(WhoFrameDropDown, 2); WhoList_Update()")
.unwrap();
assert_eq!(
s.eval::<String>("return BenillaWhoFrameButton1Variable:GetText()")
s.eval::<String>("return WhoFrameButton1Variable:GetText()")
.unwrap(),
"Legacy of Steel"
);
// A column header sorts by its own key.
s.run("BenillaWhoFrameColumnHeader3:Click()").unwrap();
s.run("WhoFrameColumnHeader3:Click()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::SortWho("level".to_string())]
@@ -430,17 +421,17 @@ fn the_who_buttons_need_a_selected_row() {
"WHO_LIST_UPDATE",
);
assert!(
!s.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
.unwrap(),
"nothing selected yet"
);
s.run("BenillaWhoFrameButton2:Click()").unwrap();
s.run("WhoFrameButton2:Click()").unwrap();
assert!(s
.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
.unwrap());
let _ = s.take_social_requests();
s.run("BenillaWhoFrameAddFriendButton:Click()").unwrap();
s.run("WhoFrameAddFriendButton:Click()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::AddFriend("Furor".to_string())]
@@ -456,7 +447,7 @@ fn the_who_buttons_need_a_selected_row() {
"WHO_LIST_UPDATE",
);
assert!(
!s.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
.unwrap(),
"a fresh answer drops the old selection"
);
@@ -474,7 +465,7 @@ fn showing_the_who_frame_claims_the_next_answer() {
.contains(&SocialRequest::SetWhoToUi(true)),
"showing the frame routes results to it"
);
s.run("HideUIPanel(BenillaFriendsFrame)").unwrap();
s.run("HideUIPanel(FriendsFrame)").unwrap();
assert!(
s.take_social_requests()
.contains(&SocialRequest::SetWhoToUi(false)),
@@ -488,9 +479,9 @@ fn the_who_edit_box_sends_its_filter() {
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
let _ = s.take_social_requests();
s.run("BenillaWhoFrameEditBox:SetText(\"z-\\\"Elwynn Forest\\\" 1-10\")")
s.run("WhoFrameEditBox:SetText(\"z-\\\"Elwynn Forest\\\" 1-10\")")
.unwrap();
s.run("BenillaWhoFrameEditBox_OnEnterPressed()").unwrap();
s.run("WhoFrameEditBox_OnEnterPressed()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::Who("z-\"Elwynn Forest\" 1-10".to_string())]
@@ -526,9 +517,7 @@ fn the_slash_bodies_match_the_reference() {
// A bare `/who` opens the Who panel and sends the default filter, which it also shows.
s.run("BenillaSlashWho(\"\")").unwrap();
assert!(s
.eval::<bool>("return BenillaWhoFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return WhoFrame:IsVisible()").unwrap());
let requests = s.take_social_requests();
let sent = requests
.iter()
@@ -542,7 +531,7 @@ fn the_slash_bodies_match_the_reference() {
"the default filter is zone-scoped: {sent}"
);
assert_eq!(
s.eval::<String>("return BenillaWhoFrameEditBox:GetText()")
s.eval::<String>("return WhoFrameEditBox:GetText()")
.unwrap(),
sent,
"and the edit box shows what was sent"
@@ -555,7 +544,7 @@ fn the_slash_bodies_match_the_reference() {
fn add_friend_without_a_target_opens_the_name_dialog() {
let mut s = setup();
s.run("ToggleFriendsFrame(1)").unwrap();
s.run("BenillaFriendsFrameAddFriendButton:Click()").unwrap();
s.run("FriendsFrameAddFriendButton:Click()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
@@ -623,8 +612,7 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
"WHO_LIST_UPDATE",
);
s.run("BenillaWhoFrameButton1:Click(\"RightButton\")")
.unwrap();
s.run("WhoFrameButton1:Click(\"RightButton\")").unwrap();
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the menu opens"
@@ -637,7 +625,7 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
);
// …and a right-click must NOT also select the row (the ref's two branches are exclusive).
assert!(
!s.eval::<bool>("return BenillaWhoFrameAddFriendButton:IsEnabled()")
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled()")
.unwrap(),
"right-click does not select"
);
@@ -672,7 +660,7 @@ fn right_clicking_an_offline_friend_opens_nothing() {
},
"FRIENDLIST_UPDATE",
);
s.run("BenillaFriendsFrameFriendButton1:Click(\"RightButton\")")
s.run("FriendsFrameFriendButton1:Click(\"RightButton\")")
.unwrap();
assert!(
!s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
@@ -700,7 +688,7 @@ fn selecting_a_row_reads_back_in_the_same_tick() {
);
let _ = s.take_social_requests();
s.run("BenillaFriendsFrameFriendButton2:Click()").unwrap();
s.run("FriendsFrameFriendButton2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return GetSelectedFriend()").unwrap(),
2,
@@ -795,7 +783,7 @@ fn the_window_geometry_matches_the_reference_framexml() {
// separate <HighlightTexture> block the scrape attributes to the same element).
"WhoFrameColumnHeaderTemplate/Right",
// The three faux-scroll frames: the ref decorates each with two UI-Character-ScrollBar
// trough textures. benilla's BenillaFauxScrollFrameTemplate draws its own complete bar
// trough textures. benilla's FauxScrollFrameTemplate draws its own complete bar
// (decisions 0247/0250/0251), so the ref's loose art would double it.
"FriendsFrameFriendsScrollFrame",
"FriendsFrameIgnoreScrollFrame",
@@ -12,12 +12,17 @@ use benilla_ui::script::{
ContainerSlot, ContainerState, LootRow, LootState, SessionRequest, SoundRequest, UiScript,
};
/// The engine the menu needs behind it: fonts, the panel manager + popup engine, and the menu.
/// `extra` adds the files a given test wants in the way (a bag, a loot window).
/// The engine the menu needs behind it: fonts, the panel manager + popup engine, the shared widget
/// kit, and the menu. `extra` adds the files a given test wants in the way (a bag, a loot window).
///
/// `UIPanelTemplates.xml` is not optional and never was in production: `GameMenuButtonTemplate`
/// lives there (the reference's own file for it) since the colour picker needed it from above
/// GameMenuFrame.xml's deliberately-LAST seat in the manifest. Without it every rung comes out
/// sizeless and the ladder geometry below reads nil — which is exactly how the move was caught.
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = ["Fonts.xml", "UiPanels.xml"]
let files: Vec<&str> = ["Fonts.xml", "UiPanels.xml", "UIPanelTemplates.xml"]
.into_iter()
.chain(extra.iter().copied())
.chain(std::iter::once("GameMenuFrame.xml"))
@@ -104,20 +109,16 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The three entries with nothing behind them — AddOns, Edit Mode, Support — are DISABLED (the
/// pending idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry).
/// Everything else in the ladder is live. **Macros left this list in decision 0983**, when the
/// macro window landed behind it.
/// The two entries with nothing behind them — Edit Mode, Support — are DISABLED (the pending
/// idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry). Everything
/// else in the ladder is live. **Macros left this list in decision 0983**, when the macro window
/// landed behind it; **AddOns left it in 1197**, when `AddonList.xml` did.
#[test]
fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
let s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
for name in [
"GameMenuButtonAddOns",
"GameMenuButtonEditMode",
"GameMenuButtonSupport",
] {
for name in ["GameMenuButtonEditMode", "GameMenuButtonSupport"] {
assert!(
!s.eval::<bool>(&format!("return {name}:IsEnabled()"))
.unwrap(),
@@ -126,6 +127,7 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
}
for name in [
"GameMenuButtonOptions",
"GameMenuButtonAddOns",
"GameMenuButtonMacros",
"GameMenuButtonLogout",
"GameMenuButtonQuit",
@@ -137,7 +139,8 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
"{name} is live"
);
}
// The pending labels read the era strings (AddOns through the ADDONS global).
// The labels read the era strings (AddOns through the ADDONS global — live now, and its
// label still has to come from the string rather than a literal).
assert_eq!(
s.eval::<String>("return GameMenuButtonAddOns:GetText()")
.unwrap(),
@@ -256,7 +259,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
}));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s
.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaLootFrame\"")
.eval::<bool>("return GetLeftFrame():GetName() == \"LootFrame\"")
.unwrap());
s.run("ToggleGameMenu(1)").unwrap();
@@ -280,19 +283,16 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
);
// The refusal itself: a panel asked to show while the menu is up simply doesn't.
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(
!s.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return LootFrame:IsVisible()").unwrap(),
"ShowUIPanel refuses a left-area panel behind the menu"
);
// …and works again the moment the menu goes down.
s.run("HideUIPanel(GameMenuFrame)").unwrap();
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
assert!(s
.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap());
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -569,10 +569,9 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
]);
s.run("ToggleWorldMap()").unwrap();
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(
!s.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return LootFrame:IsVisible()").unwrap(),
"a left-area panel must not open behind the full-screen map"
);
@@ -497,19 +497,22 @@ fn managed_positions_engage_for_the_bare_frame_name() {
assert_eq!(bottom(&s), 60.0, "baseY");
// The always-on bottom multibars (0270) appear.
s.run("BenillaMultiBarBottomLeft = { IsShown = function() return true end }; BenillaMultiBarBottomRight = BenillaMultiBarBottomLeft; UIParent_ManageFramePositions()")
// The bar stubs carry a no-op SetPoint: `MultiBarBottomLeft` and `ShapeshiftBarFrame` are
// themselves rows in UIPARENT_MANAGED_FRAME_POSITIONS, so since those frames wear their
// reference names the pass positions them as well as reading their visibility.
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 102.0, "60 + bottomEither 42 — the row engaged");
// The stance bar shows on top of them.
s.run("BenillaShapeshiftBarFrame = { IsShown = function() return true end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 144.0, "60 + 42 + pet 42");
// And it settles back when the stance bar hides.
s.run("BenillaShapeshiftBarFrame = { IsShown = function() return false end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 102.0, "back to the multibar-only stack");
@@ -578,7 +578,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
s.mouse_wheel(wx, wy, -1.0);
assert!(s.errors().is_empty(), "wheel over a name: {:?}", s.errors());
assert_eq!(
s.eval::<f64>(&format!("return BenillaFauxScrollFrame_GetOffset({SF})"))
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap(),
1.0
);
@@ -605,7 +605,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
};
s.mouse_wheel(cx, cy, -1.0);
assert_eq!(
s.eval::<f64>(&format!("return BenillaFauxScrollFrame_GetOffset({SF})"))
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap(),
2.0
);
@@ -615,7 +615,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
s.run(&format!("{ROW}2Key1Button:Click()")).unwrap();
s.mouse_wheel(wx, wy, -1.0);
assert_eq!(
s.eval::<f64>(&format!("return BenillaFauxScrollFrame_GetOffset({SF})"))
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap(),
2.0,
"armed: the wheel must not scroll"
+17 -23
View File
@@ -132,20 +132,16 @@ fn shipped_loot_frame_drives_end_to_end() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Shown at the left slot; three rows visible (coin + 2 items), row 4 hidden.
assert!(s
.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
let vis: (bool, bool, bool, bool) = s
.eval(
"return BenillaLootButton1:IsVisible(), BenillaLootButton2:IsVisible(),\n\
BenillaLootButton3:IsVisible(), BenillaLootButton4:IsVisible()",
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible(), LootButton4:IsVisible()",
)
.unwrap();
assert_eq!(vis, (true, true, true, false), "coin + 2 items, 4th hidden");
// Only 3 items ⇒ no pager.
assert!(!s
.eval::<bool>("return BenillaLootDownButton:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return LootDownButton:IsVisible()").unwrap());
s.resolve();
let quads = s.extract();
@@ -226,8 +222,8 @@ fn shipped_loot_frame_drives_end_to_end() {
s.fire_event("LOOT_UPDATE", vec![]);
let vis2: (bool, bool, bool) = s
.eval(
"return BenillaLootButton1:IsVisible(), BenillaLootButton2:IsVisible(),\n\
BenillaLootButton3:IsVisible()",
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible()",
)
.unwrap();
assert_eq!(vis2, (true, true, false), "the coin row cleared → two rows");
@@ -238,9 +234,7 @@ fn shipped_loot_frame_drives_end_to_end() {
s.take_loot_close(),
"closing the window releases the loot (OnHide → CloseLoot)"
);
assert!(!s
.eval::<bool>("return BenillaLootFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"HideUIPanel vacated the left slot"
@@ -370,27 +364,27 @@ fn shipped_loot_frame_pages_five_items() {
// Page 1: 3 rows (the pager spends the 4th slot), Down shown, Up hidden.
let page1: (bool, bool, bool, bool) = s
.eval(
"return BenillaLootButton1:IsVisible(), BenillaLootButton2:IsVisible(),\n\
BenillaLootButton3:IsVisible(), BenillaLootButton4:IsVisible()",
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible(), LootButton4:IsVisible()",
)
.unwrap();
assert_eq!(page1, (true, true, true, false), "page 1 shows 3 rows");
let pager1: (bool, bool) = s
.eval("return BenillaLootUpButton:IsVisible(), BenillaLootDownButton:IsVisible()")
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.unwrap();
assert_eq!(pager1, (false, true), "page 1: Up hidden, Down shown");
// Page down → page 2: 2 rows, Up shown, Down hidden.
s.run("BenillaLootFrame_PageDown()").unwrap();
s.run("LootFrame_PageDown()").unwrap();
let page2: (bool, bool, bool) = s
.eval(
"return BenillaLootButton1:IsVisible(), BenillaLootButton2:IsVisible(),\n\
BenillaLootButton3:IsVisible()",
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible()",
)
.unwrap();
assert_eq!(page2, (true, true, false), "page 2 shows the last 2 rows");
let pager2: (bool, bool) = s
.eval("return BenillaLootUpButton:IsVisible(), BenillaLootDownButton:IsVisible()")
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.unwrap();
assert_eq!(pager2, (true, false), "page 2: Up shown, Down hidden");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -414,7 +408,7 @@ fn shipped_loot_pushed_to_center_by_merchant() {
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaLootFrame\"")
s.eval::<bool>("return GetLeftFrame():GetName() == \"LootFrame\"")
.unwrap(),
"loot took the empty left slot"
);
@@ -438,12 +432,12 @@ fn shipped_loot_pushed_to_center_by_merchant() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == \"BenillaLootFrame\"")
s.eval::<bool>("return GetCenterFrame():GetName() == \"LootFrame\"")
.unwrap(),
"loot was pushed to center, not replaced"
);
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaMerchantFrame\"")
s.eval::<bool>("return GetLeftFrame():GetName() == \"MerchantFrame\"")
.unwrap(),
"merchant took the left slot loot vacated"
);
+75 -110
View File
@@ -1,129 +1,76 @@
//! The shipped FrameXML manifest and the three ways it is loaded.
//! **benilla's own interface** — its manifest, and the boot split that loads it in two phases.
//!
//! Split out of `ui_script/mod.rs` as its own concern: the loader that walks an ordered manifest.
//! The interesting thing here is the **seam at index 0** — see [`load_default_ui`].
//! The *how* of loading any interface lives in [`super::addons`]: an [`Addon`] is a name, a parsed
//! `.toc`, and a source its files come from, and ours is simply the one whose source is the
//! compiled-in tree (decision 1184). What is specific to us, and therefore still here, is the
//! **seam at index 0** — see [`load_default_ui`].
//!
//! The manifest itself is not here. It is [`MANIFEST`] — `assets/ui/benilla.toc`, an ordinary
//! addon manifest read by the ordinary `.toc` parser ([`benilla_ui::toc`]), exactly as a
//! third-party addon's will be (decision 1178). Until then it was a hand-ordered `&[&str]` in
//! this file, which meant our own interface loaded by a private door and the addon path was
//! untested by construction.
//! The manifest itself is not here either. It is [`MANIFEST`] — `assets/ui/benilla.toc`, an
//! ordinary addon manifest read by the ordinary `.toc` parser ([`benilla_ui::toc`]), exactly as a
//! third-party addon's is (decision 1178). Until then it was a hand-ordered `&[&str]` in this file,
//! which meant our own interface loaded by a private door and the addon path was untested by
//! construction.
use bevy::prelude::*;
use benilla_ui::script::UiScript;
use benilla_ui::toc::Toc;
use super::content;
use super::addons::Addon;
/// The built-in interface's manifest, relative to `assets/ui` — parsed for its ordered file list
/// by [`manifest_files`]. Its `## Interface:`/`## Title:` directives are what `GetAddOnInfo` will
/// read once the AddOn API lands (1178 step 4); nothing consumes them yet.
/// The built-in interface's manifest, relative to `assets/ui`. Its `## Interface:`/`## Title:`
/// directives are what `GetAddOnInfo` will read once the AddOn API lands (1178 step 4); nothing
/// consumes them yet.
pub(super) const MANIFEST: &str = "benilla.toc";
/// The manifest's file list, in load order.
///
/// Re-read and re-parsed per call rather than cached: it is two calls per process (the font
/// registry at startup, the rest at world entry) over a 3 kB file, and a dev build prefers the
/// copy on disk ([`content`]) — so editing `benilla.toc` costs no recompile, same as editing any
/// FrameXML file it names.
///
/// A missing or empty manifest is an interface-less client, which is why it is `error!` and not a
/// silent empty list; [`tests::the_manifest_lists_every_shipped_file_and_nothing_else`] is what
/// stops it ever reaching a run.
/// The built-in manifest's file list, in load order — a convenience over [`Addon::builtin`] for the
/// tests and the content sweep, which want the names rather than a loader.
#[cfg(test)]
pub(super) fn manifest_files() -> Vec<String> {
let Some(text) = content::read(MANIFEST) else {
error!("ui_script: {MANIFEST} is not in the shipped UI — no interface will load");
return Vec::new();
};
let files = Toc::parse(&text).files;
if files.is_empty() {
error!("ui_script: {MANIFEST} lists no files — no interface will load");
}
files
Addon::builtin().toc.files
}
/// Load a slice of the manifest into the VM, in the order given. Returns per-file errors.
/// Run decision 0272's load-time `UIParent_ManageFramePositions()` pass.
///
/// `bootstrap_positions` runs decision 0272's load-time `UIParent_ManageFramePositions()` pass —
/// only meaningful once the frames that table names exist, so the font-registry-only load
/// Only meaningful once the frames that table names exist, so the font-registry-only load
/// ([`load_font_registry`]) skips it. It is defined in `UIParent.xml`, which is in the deferred
/// half; calling it after `Fonts.xml` alone is a nil-global error, not a no-op.
fn load_ui_files(script: &UiScript, files: &[String], bootstrap_positions: bool) -> Vec<String> {
let mut failures = Vec::new();
// Provider for FrameXML/Lua references: try the path as given and by basename (Blizzard-style
// backslash paths, dir-relative). The *content* comes from `super::content` — the shipped tree
// is compiled into the binary (1175), so this resolves on a machine that has never seen our
// source; the two-try shape is a property of FrameXML references and stays here.
let provider = |req: &str| -> Option<String> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
content::read(&norm).or_else(|| content::read(base))
};
for file in files {
let text = match content::read(file) {
Some(t) => t,
None => {
error!("ui_script: {file} is not in the shipped UI");
continue;
}
};
let doc = match benilla_ui::framexml::parse(&text) {
Ok(d) => d,
Err(e) => {
error!("ui_script: parsing {file}: {e}");
continue;
}
};
let report = benilla_ui::loader::load(script, &doc, &provider);
for w in &report.warnings {
warn!("ui_script({file}): {w}");
}
for e in &report.errors {
error!("ui_script({file}): {e}");
failures.push(format!("{file}: {e}"));
}
info!(
"ui_script: {file} loaded ({} frames materialized)",
report.frames
);
///
/// The ref applies `UIPARENT_MANAGED_FRAME_POSITIONS` once at load, then re-fires from the bottom
/// bars' OnShow/OnHide. Every frame the table names exists by the time this runs, so this is that
/// load-time application; the stance bar's show/hide handles the rest at runtime.
fn bootstrap_positions(script: &UiScript) -> Vec<String> {
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
return vec![format!("managed-positions bootstrap: {e}")];
}
// The managed positions' startup pass (decision 0272): the ref applies
// UIPARENT_MANAGED_FRAME_POSITIONS once at load, then re-fires from the bottom bars'
// OnShow/OnHide. Every frame the table names now exists, so this is that load-time
// application; the stance bar's show/hide handles the rest at runtime.
if bootstrap_positions {
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
failures.push(format!("managed-positions bootstrap: {e}"));
}
}
failures
Vec::new()
}
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader,
/// resolving any `<Include>`/`<Script file=>` references against the same content store. This is
/// our content (MIT/Apache), committed and **compiled into the binary** ([`super::content`],
/// decision 1175) — a dev build still prefers the copy on disk, so editing a FrameXML file costs no
/// recompile. Textures (`Interface\…`) still resolve at render through the MPQ `sprite_texture`
/// path; the loader only needs the XML/Lua text.
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader.
/// This is our content (MIT/Apache), committed and **compiled into the binary**
/// ([`super::content`], decision 1175); a dev build still prefers the copy on disk, so editing a
/// FrameXML file costs no recompile. Textures (`Interface\…`) still resolve at render through the
/// MPQ `sprite_texture` path; the loader only needs the XML/Lua text.
///
/// Returns every loader error, tagged `"<file>: <error>"` — the app ignores the value (each is
/// already logged as it happens) and [`shipped_xml_tests`] asserts it empty. Before that assertion
/// a broken entry — a bad file name, a frame that collides with a later window's, a template
/// referenced before its definer — reached a real run with nothing but a log line. Capture runs
/// cannot cover it either: they skip this function entirely unless `WOW_CAPTURE_UI=1`.
/// Returns every loader error, tagged `"<Addon>/<file>: <error>"` — the app ignores the value (each
/// is already logged as it happens) and [`shipped_xml_tests`] asserts it empty. Before that
/// assertion a broken entry — a bad file name, a frame that collides with a later window's, a
/// template referenced before its definer — reached a real run with nothing but a log line. Capture
/// runs cannot cover it either: they skip this function entirely unless `WOW_CAPTURE_UI=1`.
///
/// **Split across the boot boundary (1051).** `Fonts.xml` — the manifest's first entry, zero frames
/// materialized — is the font-object registry the glyph atlas bakes its plan from, and our native
/// glue screens share that one atlas, so it must exist before the login screen. Everything after it
/// is in-game UI and loads at world entry ([`load_ingame_ui`]). This whole-manifest entry point
/// stays for the tests, which assert over the complete shipped set — production now loads in two
/// phases, so this has no non-test caller.
#[cfg(test)]
/// phases, so production has no caller for the whole-manifest form — the tests do, and so does
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
load_ui_files(script, &manifest_files(), true)
let builtin = Addon::builtin();
let mut failures = builtin.load_files(script, &builtin.toc.files);
failures.extend(bootstrap_positions(script));
failures
}
/// The font-object registry alone (`Fonts.xml`), loaded at `Startup` — see [`load_default_ui`].
@@ -133,18 +80,36 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
/// (`GameFontNormalMed1` 13, `OptionsFontHighlightMedium` 14, `OptionsFontHighlightHuge` 20) are
/// un-outlined and their heights are already declared here, so they add nothing to the bake plan.
pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
let files = manifest_files();
load_ui_files(script, files.get(..1).unwrap_or_default(), false)
let builtin = Addon::builtin();
builtin.load_files(script, builtin.toc.files.get(..1).unwrap_or_default())
}
/// The in-game UI — everything after the font registry — loaded on entering the world.
/// The in-game UI — everything after the font registry — loaded on entering the world, and then
/// **every third-party addon** ([`super::addons`], decision 1184).
///
/// The reference does the same at `CGGameUI::Initialize 0x48fbf0`, reached only from world entry
/// (`0x401570` ← `0x46c236`); its glue screens run GlueXML with their own `GlueFonts.xml` registry,
/// which is why the reference has no equivalent of our shared-atlas coupling (wow-5875-re, 1051).
pub(crate) fn load_ingame_ui(script: &UiScript) -> Vec<String> {
let files = manifest_files();
load_ui_files(script, files.get(1..).unwrap_or_default(), true)
/// (`0x401570` ← `0x46c236`), and loads its addons from that same function (`0x4900a3` →
/// `0x51f600`); its glue screens run GlueXML with their own `GlueFonts.xml` registry, which is why
/// the reference has no equivalent of our shared-atlas coupling (wow-5875-re, 1051).
///
/// Addons load **after** the built-in interface, not interleaved with it: an addon may reference
/// our templates and globals (that is the point of 1178's seam), and nothing of ours may depend on
/// an addon.
///
/// `identity` is `(realm, character)`, which names this character's AddOn enable-state file — the
/// reference keys `AddOns.txt` per character too. `None` (no pick yet, a capture) means every
/// discovered addon is enabled, the same answer an absent file gives.
pub(crate) fn load_ingame_ui(
script: &mut UiScript,
identity: Option<&(String, String)>,
) -> Vec<String> {
let builtin = Addon::builtin();
let mut failures = builtin.load_files(script, builtin.toc.files.get(1..).unwrap_or_default());
failures.extend(bootstrap_positions(script));
// `&mut` from here down: each addon's `ADDON_LOADED` fires as that addon finishes, which is
// the reference's own interleaving (`0x51f5ad`, per addon) rather than a batch at the end.
failures.extend(super::addons::load_third_party(script, identity));
failures
}
#[cfg(test)]
@@ -157,7 +122,7 @@ mod tests {
/// the boot boundary (1051) — into the glue screens' phase, or out of the atlas bake plan.
#[test]
fn the_manifest_is_a_toc_that_starts_with_the_font_registry() {
let toc = Toc::parse(&content::read(MANIFEST).expect("benilla.toc is shipped"));
let toc = Addon::builtin().toc;
assert_eq!(toc.interface_versions(), vec![11200]);
assert_eq!(toc.directive("Title"), Some("benilla"));
assert_eq!(
@@ -169,14 +134,14 @@ mod tests {
/// The manifest and `assets/ui` describe the same interface, both ways.
///
/// An entry naming a file we do not ship is 61 log lines and an empty screen (what
/// An entry naming a file we do not ship is a log line per entry and an empty screen (what
/// `content::tests::every_manifest_entry_is_compiled_in` catches). The other direction is the
/// one nothing caught before: a FrameXML file added to `assets/ui` and never listed here is
/// simply never loaded, and the symptom is a window that does not exist rather than an error.
#[test]
fn the_manifest_lists_every_shipped_file_and_nothing_else() {
let mut listed = manifest_files();
let mut shipped: Vec<String> = content::shipped_files()
let mut shipped: Vec<String> = super::super::content::shipped_files()
.filter(|f| f.ends_with(".xml"))
.map(str::to_owned)
.collect();
@@ -111,9 +111,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
// The window is shown (ShowUIPanel put it on the left slot), both row icons rendered, a price
// + the purse line painted.
assert!(s
.eval::<bool>("return BenillaMerchantFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return MerchantFrame:IsVisible()").unwrap());
s.resolve();
let quads = s.extract();
assert!(has_icon(&quads, "INV_Drink_18"), "row 1 icon visible");
@@ -961,13 +959,17 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
// Merchant page: the buyback slot shows the MOST RECENT sale; the repair pair is up and the
// all-button enabled (cost 76 > 0); the buyback page art is down.
assert!(s
.eval::<bool>("return BenillaMerchantBuyBackItemName:GetText() == 'Cracked Sword'")
.eval::<bool>("return MerchantBuyBackItemName:GetText() == 'Cracked Sword'")
.unwrap());
assert!(s
.eval::<bool>("return BenillaMerchantRepairAllButton:IsShown() and BenillaMerchantRepairAllButton:IsEnabled()")
.eval::<bool>(
"return MerchantRepairAllButton:IsShown() and MerchantRepairAllButton:IsEnabled()"
)
.unwrap());
assert!(s
.eval::<bool>("return not BenillaMerchantItem11:IsShown() and BenillaBuybackFrameTopLeft:IsShown() == nil")
.eval::<bool>(
"return not MerchantItem11:IsShown() and BuybackFrameTopLeft:IsShown() == nil"
)
.unwrap());
// The slot click buys back the most recent sale (slot 2 of 2).
@@ -984,30 +986,30 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
s.run("BenillaMerchantFrameTab_OnClick(2)").unwrap();
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaMerchantNameText:GetText() == 'Merchant Buyback'")
.eval::<bool>("return MerchantNameText:GetText() == 'Merchant Buyback'")
.unwrap());
assert!(s
.eval::<bool>(
"return BenillaMerchantItem1Name:GetText() == 'Bandit Cloak' \
and BenillaMerchantItem2Name:GetText() == 'Cracked Sword'",
"return MerchantItem1Name:GetText() == 'Bandit Cloak' \
and MerchantItem2Name:GetText() == 'Cracked Sword'",
)
.unwrap());
assert!(s
.eval::<bool>(
"return BenillaBuybackFrameTopLeft:IsShown() == 1 \
and not BenillaMerchantBuyBackItem:IsShown() \
and not BenillaMerchantRepairAllButton:IsShown()",
"return BuybackFrameTopLeft:IsShown() == 1 \
and not MerchantBuyBackItem:IsShown() \
and not MerchantRepairAllButton:IsShown()",
)
.unwrap());
assert!(s
.eval::<bool>(
"return BenillaMerchantFrameTab2LeftDisabled:IsShown() == 1 \
and BenillaMerchantFrameTab1LeftDisabled:IsShown() == nil",
"return MerchantFrameTab2LeftDisabled:IsShown() == 1 \
and MerchantFrameTab1LeftDisabled:IsShown() == nil",
)
.unwrap());
// A row click on the buyback page buys that slot back.
s.run("BenillaMerchantItem_OnClick(BenillaMerchantItem1, 'LeftButton')")
s.run("BenillaMerchantItem_OnClick(MerchantItem1, 'LeftButton')")
.unwrap();
assert_eq!(s.take_merchant_buybacks(), vec![1]);
@@ -1015,8 +1017,8 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
s.run("BenillaMerchantFrameTab_OnClick(1)").unwrap();
assert!(s
.eval::<bool>(
"return BenillaMerchantItem1Name:GetText() == 'Refreshing Spring Water' \
and BenillaBuybackFrameTopLeft:IsShown() == nil",
"return MerchantItem1Name:GetText() == 'Refreshing Spring Water' \
and BuybackFrameTopLeft:IsShown() == nil",
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1056,13 +1058,13 @@ fn merchant_tabs_fit_their_labels() {
// Tab width = text + 2×20 end slices; the middle slices carry exactly the text width.
let (w1, w2): (f64, f64) = s
.eval("return BenillaMerchantFrameTab1:GetWidth(), BenillaMerchantFrameTab2:GetWidth()")
.eval("return MerchantFrameTab1:GetWidth(), MerchantFrameTab2:GetWidth()")
.unwrap();
assert_eq!((w1, w2), (98.0, 92.0), "text + 40, not the fixed 115");
let (m1, m2): (f64, f64) = s
.eval(
"return BenillaMerchantFrameTab1MiddleDisabled:GetWidth(), \
BenillaMerchantFrameTab2Middle:GetWidth()",
"return MerchantFrameTab1MiddleDisabled:GetWidth(), \
MerchantFrameTab2Middle:GetWidth()",
)
.unwrap();
assert_eq!((m1, m2), (58.0, 52.0), "middle slices stretch to the text");
@@ -1116,8 +1118,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
assert_eq!(s.ui_cursor(), None, "no override before any hover");
// Row 1 hover: OnEnter remembers the row, the frame's OnUpdate arms the coin — affordable → Buy.
s.run("BenillaMerchantItem_OnEnter(BenillaMerchantItem1)")
.unwrap();
s.run("BenillaMerchantItem_OnEnter(MerchantItem1)").unwrap();
s.tick(0.016);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert_eq!(
@@ -1137,14 +1138,12 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
s.set_modifiers(false, false, false);
// Leaving clears the override (OnLeave ResetCursor + the itemHover poll stops).
s.run("BenillaMerchantItem_OnLeave(BenillaMerchantItem1)")
.unwrap();
s.run("BenillaMerchantItem_OnLeave(MerchantItem1)").unwrap();
s.tick(0.016);
assert_eq!(s.ui_cursor(), None, "leaving the row resets the cursor");
// Row 2 hover: 100c against a 50c purse → the grayed UnableBuy.
s.run("BenillaMerchantItem_OnEnter(BenillaMerchantItem2)")
.unwrap();
s.run("BenillaMerchantItem_OnEnter(MerchantItem2)").unwrap();
s.tick(0.016);
assert_eq!(
s.ui_cursor(),
@@ -1,5 +1,5 @@
//! The shipped `assets/ui/MicroMenu.xml` — the eight micro buttons in the main bar's right-hand
//! recess — loaded behind `ActionBar.xml` (their anchor target `BenillaActionBarArtFrame` and the
//! recess — loaded behind `ActionBar.xml` (their anchor target `MainMenuBarArtFrame` and the
//! `BenillaActionBarArt_SeatAbove` seating helper) into a bare engine.
//!
//! What these guard, in order: the row's ref geometry (29×58 at art-frame BOTTOMLEFT +(552,2), a
@@ -174,7 +174,7 @@ fn a_micro_button_pushes_while_its_panel_is_open() {
let s = harness();
s.run(
r#"
local f = CreateFrame("Frame", "BenillaCharacterFrame")
local f = CreateFrame("Frame", "CharacterFrame")
f:SetPoint("CENTER", 0, 0); f:SetSize(100, 100); f:Hide()
"#,
)
@@ -187,11 +187,11 @@ fn a_micro_button_pushes_while_its_panel_is_open() {
s.run("UpdateMicroButtons()").unwrap();
assert_eq!(state(&s), "NORMAL", "closed panel ⇒ button up");
s.run("BenillaCharacterFrame:Show(); UpdateMicroButtons()")
s.run("CharacterFrame:Show(); UpdateMicroButtons()")
.unwrap();
assert_eq!(state(&s), "PUSHED", "open panel ⇒ button held down");
s.run("BenillaCharacterFrame:Hide(); UpdateMicroButtons()")
s.run("CharacterFrame:Hide(); UpdateMicroButtons()")
.unwrap();
assert_eq!(state(&s), "NORMAL", "closed again ⇒ button pops back");
+188 -3
View File
@@ -26,13 +26,18 @@ use crate::ui_unit::UnitFeed;
use benilla_assets::LockRecover;
use benilla_world::schedule::WorldStage;
/// The addon folder: discovery, manifests, the enable file, and the load walk. `pub(crate)`
/// because the AddOns screens read the same folder without a VM (decision 1196).
pub(crate) mod addons;
mod content;
mod extract;
mod input;
mod manifest;
// The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`.
#[cfg(test)]
// `load_default_ui` is no longer test-only: the addon harness (1188 phase 6) loads the whole
// shipped interface under each surveyed addon, because roughly half of what an addon calls is
// FrameXML's Lua rather than the engine's (decision 1190).
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::{load_font_registry, load_ingame_ui};
@@ -170,6 +175,12 @@ impl Default for UiClock {
/// mid-chunk on a missing `EditBox:SetTextColor` every single frame and nothing ever said so
/// (the /w caret bug). A chunk failure is always an app or engine defect; this is the mandatory
/// form for any run whose `Result` isn't otherwise consumed.
/// The FrameXML digest of the interface this process loads — see [`content::digest`]. Re-exported
/// so the corpus harness can stamp every report with the tree it measured.
pub(crate) fn framexml_digest() -> String {
content::digest()
}
pub(crate) fn run_or_warn(script: &benilla_ui::script::UiScript, chunk: &str) {
if let Err(e) = script.run(chunk) {
warn!("ui_script: chunk failed: {e}");
@@ -239,6 +250,7 @@ impl Plugin for UiScriptPlugin {
.init_resource::<CursorPayloadHeld>()
.init_resource::<UiClock>()
.init_resource::<IngameUiLoaded>()
.init_resource::<AddOnIdentity>()
// After `AssetSet::Open` so the patch chain exists at boot: the VM's first load is
// the real `GlobalStrings.lua` (the reference's own FrameXML order), which the
// cast-fail display (0427) resolves its messages from.
@@ -249,6 +261,17 @@ impl Plugin for UiScriptPlugin {
OnEnter(crate::char_select::ClientState::InWorld),
load_ingame_ui_on_world_entry,
)
// The whole shutdown tail — events then writes, in the reference's order. See
// [`shutdown_ui_state`]; the two edges here are its five roots as far as our session
// has them.
.add_systems(
OnExit(crate::char_select::ClientState::InWorld),
shutdown_on_session_end,
)
.add_systems(Update, shutdown_on_exit)
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
// `drive_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
@@ -364,7 +387,20 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
};
let _ = load_ingame_ui(&script);
// The character whose AddOn enable state applies. Resolved before the load because the
// enable file gates which addons run at all.
let identity = world
.get_resource::<crate::char_select::Roster>()
.and_then(crate::ui_macro::identity);
// The realm name goes in BEFORE the UI loads, because `GetRealmName()` is read at addon file
// scope — `MyAddonDB[GetRealmName()] = …` is the corpus idiom, and 24 addons stop on it
// (decision 1195). The roster carries the auth realm-list entry this session connected to.
let realm = world
.get_resource::<crate::char_select::Roster>()
.and_then(|r| r.realm.as_ref().map(|r| r.name.clone()))
.unwrap_or_default();
script.set_realm_name(&realm);
let _ = load_ingame_ui(&mut script, identity.as_ref());
// The Minimap widget was born a moment ago with `MinimapState::default()`; seed its two live
// zoom indices from the persisted CVars now, before anything reads them — the reference's own
// minimap reset path copying each CVar object's int into its live index (decision 1131). Once
@@ -376,11 +412,128 @@ fn load_ingame_ui_on_world_entry(world: &mut World) {
// any consumer reads them — then `VARIABLES_LOADED`. That is the reference's own load order
// (`AddOn_Load 0x51f240` steps 2 → 4 → 6, decision 1128); reversing it means the defaults
// always win and nothing can ever be remembered.
crate::ui_saved::load_saved_variables(&mut script);
finish_ui_load(&mut script);
world.insert_non_send_resource(script);
world.insert_resource(AddOnIdentity(identity));
world.resource_mut::<IngameUiLoaded>().0 = true;
}
/// `GetFramerate()`'s host half: push a **smoothed** frames-per-second into the VM each frame
/// (decision 1195).
///
/// Smoothed, not `1.0 / delta`, for the reason the reference smooths it too: 71 corpus addons put
/// this number on a panel, and a raw per-frame reciprocal reads as a flickering three-digit mess
/// that nobody can take a value off. The pole is a one-second time constant — fast enough that a
/// real stall is visible within a frame or two, slow enough to read.
///
/// `Time<Real>` deliberately, the same choice `drive_script` documents: this is a *frame rate*, and
/// a virtual clock that clamps its delta would report a rate the machine is not achieving.
fn feed_framerate(
script: Option<NonSendMut<UiScript>>,
time: Res<Time<bevy::time::Real>>,
mut smoothed: Local<f64>,
) {
let Some(mut script) = script else {
return;
};
let dt = time.delta_secs_f64();
if dt <= 0.0 {
return; // the first frame, and any paused one — nothing to average in
}
let instant = 1.0 / dt;
// One-pole IIR with a 1 s time constant, frame-rate independent.
let alpha = (dt / 1.0).min(1.0);
*smoothed = if *smoothed <= 0.0 {
instant
} else {
*smoothed + alpha * (instant - *smoothed)
};
script.set_framerate(*smoothed);
}
/// The character the loaded AddOn enable state belongs to, remembered so the shutdown write goes
/// back to the file it came from — the roster's pick can be gone by then.
#[derive(Resource, Default)]
pub(crate) struct AddOnIdentity(pub(crate) Option<(String, String)>);
/// **The UI shutdown, in the reference's own order** — `0x490bd0`, whose ordered tail wow-5875-re
/// carves as (`system/ui/ui.md`):
///
/// > `PLAYER_LEAVING_WORLD` (273) → **`PLAYER_LOGOUT`** (271, `0x490c2a`) → `layout-cache.txt` →
/// > **the flat saved file** (`0x490c7e`) → **the per-addon files** (`0x490c83`) → `AddOns.txt` →
/// > destroy the Lua state
///
/// **`PLAYER_LOGOUT` fires before any write, and that is the point**: it is an addon's last chance
/// to mutate a saved global, so a handler that stores "where I left off" runs while the write is
/// still ahead of it. Firing it after would make the event useless and the bug invisible.
///
/// One function, called from every root, because the steps are ordered *against each other* —
/// three independent Bevy systems on one state edge cannot express that, and until this landed the
/// flat write and the `AddOns.txt` write were exactly that.
///
/// **There is no autosave**, deliberately: the reference has none (decision 1128, and
/// `ds:0xb4b3f4` has three references image-wide). These are a handful of scalars a player toggles
/// a few times a session, and every file is written whole from the live globals.
pub(crate) fn shutdown_ui_state(script: &mut UiScript, identity: Option<&(String, String)>) {
script.fire_event("PLAYER_LEAVING_WORLD", vec![]);
script.fire_event("PLAYER_LOGOUT", vec![]);
crate::ui_saved::save(script);
addons::save_addon_variables(script, identity);
addons::save_enable_state(script, identity);
}
/// `OnExit(InWorld)`: a `/logout` back to the glue, or a disconnect — two of the reference's five
/// roots.
fn shutdown_on_session_end(script: Option<NonSendMut<UiScript>>, id: Res<AddOnIdentity>) {
if let Some(mut script) = script {
shutdown_ui_state(&mut script, id.0.as_ref());
}
}
/// `AppExit`: quitting the client — the quit / application-exit roots. Reads the message rather
/// than a state edge because a quit from in-world never leaves `InWorld`.
fn shutdown_on_exit(
script: Option<NonSendMut<UiScript>>,
id: Res<AddOnIdentity>,
mut exits: MessageReader<AppExit>,
) {
if exits.read().next().is_none() {
return;
}
if let Some(mut script) = script {
shutdown_ui_state(&mut script, id.0.as_ref());
}
}
/// The UI-init sequence's ordered tail, once every file — ours and every addon's — has loaded:
/// the saved-variables chunk and `VARIABLES_LOADED`, then `PLAYER_LOGIN`.
///
/// **The order is the reference's**, byte-verified in wow-5875-re (`system/ui/ui.md`, and the
/// cascade in `system/ui/scratch/mail-pending-countdown.md`). Inside `UI_Init 0x48fbf0`, in
/// straight-line address order:
///
/// | | |
/// |---|---|
/// | `0x4900a3` → `0x51f600` | load every non-LoadOnDemand addon — each fires its own **`ADDON_LOADED`** (429, `0x51f5ad`) |
/// | `0x4900b2` → `0x4913b0` | read the flat saved file, fire **`VARIABLES_LOADED`** (430) |
/// | `0x490168` → `0x4908c0` | the world-enter cascade: **`PLAYER_LOGIN`** (`0x49094b`, `0x10e`) then **`PLAYER_ENTERING_WORLD`** (`0x490965`, `0x110`) |
///
/// So every non-LoD addon's `ADDON_LOADED` precedes `VARIABLES_LOADED`, which precedes
/// `PLAYER_LOGIN`. It is one function rather than three inline calls because that sequence is the
/// mechanism — an addon restores state on `ADDON_LOADED` and expects the saved chunk to have run,
/// and a window that waits on `PLAYER_LOGIN` expects both — so it is worth being able to assert.
///
/// **`PLAYER_LOGIN` is the conditional one; `PLAYER_ENTERING_WORLD` is not.** The cascade fires
/// the former only when `[0xb4e260]` is set, and only the FrameXML-loader path sets it, clearing
/// it immediately after — so it means "the UI came up", once, which is exactly the
/// [`IngameUiLoaded`] latch this sits behind. `PLAYER_ENTERING_WORLD` keeps its own per-entry
/// latch in [`crate::ui_unit`] and still lands after this, since it waits on the self descriptor
/// arriving over the wire.
pub(crate) fn finish_ui_load(script: &mut UiScript) {
crate::ui_saved::load_saved_variables(script);
script.fire_event("PLAYER_LOGIN", vec![]);
}
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
@@ -716,6 +869,30 @@ mod panel_tests;
#[cfg(test)]
mod merchant_tests;
#[cfg(test)]
mod money_frame_tests;
#[cfg(test)]
mod faux_scroll_tests;
/// The reference's shared widget kit (UIPanelTemplates.xml + OptionsFrameTemplates.xml), driven
/// through `CreateFrame`'s fourth argument the way an addon drives it — decision 1203's queue.
#[cfg(test)]
mod panel_template_tests;
/// The reference's BasicControls.xml — TEXT/message/_ERRORMESSAGE and the ScriptErrors dialog,
/// none of which benilla itself calls: every test enters from Lua the way an addon does.
#[cfg(test)]
mod basic_controls_tests;
#[cfg(test)]
mod color_picker_tests;
/// `UIParent.xml`'s loose addon-facing helpers (`MouseIsOver` and kin) — the panel and ESC halves
/// of that file live in `panel_tests` / `escape_tests`.
#[cfg(test)]
mod uiparent_tests;
#[cfg(test)]
mod trainer_tests;
@@ -745,9 +922,17 @@ mod tooltip_anchor_tests;
#[cfg(test)]
mod tooltip_compare_tests;
/// `GameTooltipTemplate` as an ADDON sees it — the corpus's most-wanted template, driven through
/// `inherits=` and `CreateFrame` the way the 27 addons that name it do.
#[cfg(test)]
mod tooltip_template_tests;
#[cfg(test)]
mod escape_tests;
#[cfg(test)]
mod addon_list_tests;
#[cfg(test)]
mod game_menu_tests;
@@ -0,0 +1,367 @@
//! The shipped `assets/ui/MoneyFrame.xml` — the reference's `MoneyFrameTemplate` /
//! `SmallMoneyFrameTemplate` kit and the `MoneyTypeInfo` table behind it (decision 1190: a name
//! the shipped 1.12 UI defines is FrameXML's to transcribe).
//!
//! Nothing benilla ships consumes this file yet — our own eight coin displays run on
//! MerchantFrame.xml's `BenillaMoney_*` slot kit, and MoneyFrame.xml's header is where the two are
//! reconciled — so these tests ARE its only driver. That makes them the contract: they exercise it
//! the way an addon does (declare a frame on the template, point it at a type, change the type,
//! read the coins back), because that is who calls it.
//!
//! What they guard: the type switch actually rewiring where the number comes from and whether the
//! coins take the mouse; the collapse rule per type (`collapse`, `showSmallerCoins`, `fixedWidth`);
//! the denomination split; `MoneyFrame_Update`'s by-name entry point over a STATIC frame; and
//! `SetMoneyFrameColor` recolouring the digits and not the icons.
use benilla_ui::script::{QuadContent, UiScript};
/// One shipped file into `s`, asserting it loaded clean.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The kit plus the two frames an addon would declare: one on each template.
fn harness(money: u64) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_money(money);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<Frame name="TestPurse" inherits="SmallMoneyFrameTemplate">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Frame>
<Frame name="TestBigPurse" inherits="MoneyFrameTemplate">
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-40"/></Offset></Anchor></Anchors>
</Frame>
</Ui>"#,
)
.unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// The three coins' text, gold → silver → copper.
fn coins(s: &UiScript, frame: &str) -> (String, String, String) {
let one = |d: &str| {
s.eval::<String>(&format!("return {frame}{d}ButtonText:GetText() or ''"))
.unwrap()
};
(one("Gold"), one("Silver"), one("Copper"))
}
/// Which of the three coins are showing, gold → silver → copper.
fn shown(s: &UiScript, frame: &str) -> (bool, bool, bool) {
let one = |d: &str| {
s.eval::<bool>(&format!("return {frame}{d}Button:IsShown()"))
.unwrap()
};
(one("Gold"), one("Silver"), one("Copper"))
}
/// A frame on either template loads as type PLAYER showing the purse, split into denominations —
/// the reference's `MoneyFrame_OnLoad` / `SmallMoneyFrame_OnLoad`, both of which end in
/// `MoneyFrame_SetType("PLAYER")`.
#[test]
fn a_money_frame_loads_as_the_player_purse_and_splits_the_denominations() {
let s = harness(12_345); // 1g 23s 45c
assert_eq!(
s.eval::<String>("return TestPurse.moneyType").unwrap(),
"PLAYER"
);
assert_eq!(
coins(&s, "TestPurse"),
("1".into(), "23".into(), "45".into())
);
assert_eq!(
coins(&s, "TestBigPurse"),
("1".into(), "23".into(), "45".into())
);
assert_eq!(
s.eval::<i64>("return TestPurse.staticMoney").unwrap(),
12_345,
"the frame remembers what it is displaying (ref l.212)"
);
// `small` is what picks the 13px coin over the 19px one — the only difference between the two
// OnLoads, and the reason both templates exist.
assert_eq!(s.eval::<i64>("return TestPurse.small").unwrap(), 1);
assert!(s.eval::<bool>("return TestBigPurse.small == nil").unwrap());
}
/// **`MoneyFrame_SetType` switching a frame between types** — the verb 11 corpus addons call, and
/// the one that has to rewire three things at once: where the number comes from, whether the coins
/// take the mouse (`canPickup`), and how they collapse.
#[test]
fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
let s = harness(12_345);
// PLAYER: canPickup, so all three coins are mouse-enabled.
for d in ["Gold", "Silver", "Copper"] {
assert!(
s.eval::<bool>(&format!("return TestPurse{d}Button:IsMouseEnabled()"))
.unwrap(),
"PLAYER has canPickup = 1, so {d} takes the mouse"
);
}
// → STATIC: the number now comes from the frame's own staticMoney, and the coins go inert.
s.run("TestPurse.staticMoney = 7 this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return TestPurse.moneyType").unwrap(),
"STATIC"
);
assert_eq!(coins(&s, "TestPurse"), ("0".into(), "0".into(), "7".into()));
for d in ["Gold", "Silver", "Copper"] {
assert!(
!s.eval::<bool>(&format!("return TestPurse{d}Button:IsMouseEnabled()"))
.unwrap(),
"STATIC has no canPickup, so {d} does not take the mouse"
);
}
// STATIC collapses and does NOT showSmallerCoins: 7 copper is copper alone.
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
// → back to PLAYER: the purse returns, and so does the mouse.
s.run("this = TestPurse MoneyFrame_SetType(\"PLAYER\") this = nil")
.unwrap();
assert_eq!(
coins(&s, "TestPurse"),
("1".into(), "23".into(), "45".into())
);
assert!(s
.eval::<bool>("return TestPurseGoldButton:IsMouseEnabled()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **collapse rule**, which is the whole reason `MoneyTypeInfo` has fields beyond `UpdateFunc`.
/// PLAYER carries `showSmallerCoins = "Backpack"`, so a round 5 gold still shows all three coins;
/// STATIC does not, so the same amount is gold alone.
#[test]
fn showsmallercoins_is_what_keeps_the_zero_coins_visible() {
let s = harness(50_000); // exactly 5g
assert_eq!(coins(&s, "TestPurse"), ("5".into(), "0".into(), "0".into()));
assert_eq!(
shown(&s, "TestPurse"),
(true, true, true),
"PLAYER showSmallerCoins = \"Backpack\" keeps the zero silver and copper (ref l.30)"
);
s.run(
"TestPurse.staticMoney = 50000 this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil",
)
.unwrap();
assert_eq!(
shown(&s, "TestPurse"),
(true, false, false),
"STATIC collapses with no showSmallerCoins — 5g is gold alone (ref l.32-38)"
);
// A pure-copper amount under a collapsing type: the leading zeros go, the copper stays.
s.run("TestPurse.staticMoney = 42 this = TestPurse MoneyFrame_UpdateMoney() this = nil")
.unwrap();
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `MoneyFrame_Update(frameName, money)` — the family's one **by-name** entry point, and how an
/// addon paints a STATIC frame it just filled without going through an event. It is also where the
/// frame's own width is recomputed from the coins that survived the collapse.
#[test]
fn update_paints_a_static_frame_by_name_and_resizes_it() {
let s = harness(0);
s.run("this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil")
.unwrap();
s.run("MoneyFrame_Update(\"TestPurse\", 20304)").unwrap(); // 2g 3s 4c
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(coins(&s, "TestPurse"), ("2".into(), "3".into(), "4".into()));
assert_eq!(shown(&s, "TestPurse"), (true, true, true));
assert_eq!(
s.eval::<i64>("return TestPurse.staticMoney").unwrap(),
20_304
);
// Every coin is its digits + a 13px icon (MONEY_ICON_WIDTH_SMALL, since this frame is `small`),
// and the frame is the surviving coins packed with the -4px MONEY_BUTTON_SPACING gap. Headless
// there is no font atlas, so the digit width is 0 and the arithmetic is exactly the icons:
// 13 (base) + 13 + 13 + 13 - (-4) - (-4) = 60.
assert_eq!(
s.eval::<f64>("return TestPurseGoldButton:GetWidth()")
.unwrap(),
13.0
);
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 60.0);
// Collapse away two denominations and the frame shrinks to match: 13 + 13 = 26.
s.run("MoneyFrame_Update(\"TestPurse\", 9)").unwrap();
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 26.0);
}
/// The large template uses the 19px icon and its own spacing — the same painter, the other size
/// constants (`MONEY_ICON_WIDTH` / `MONEY_BUTTON_SPACING`), selected purely by `this.small`.
#[test]
fn the_large_template_measures_with_the_nineteen_pixel_icon() {
let s = harness(20_304);
assert_eq!(
s.eval::<f64>("return TestBigPurseGoldButton:GetWidth()")
.unwrap(),
19.0
);
assert_eq!(
s.eval::<f64>("return TestBigPurse:GetWidth()").unwrap(),
19.0 * 4.0 + 8.0,
"19 base + three 19px coins, packed with two -4px gaps"
);
assert_eq!(
s.eval::<i64>("return MONEY_ICON_WIDTH").unwrap(),
19,
"the reference's own constants ship with the kit — addons read them"
);
assert_eq!(s.eval::<i64>("return COPPER_PER_GOLD").unwrap(), 10_000);
}
/// An unknown type is refused rather than half-applied — the reference's `MoneyFrame_SetType`
/// guard (l.144-147). Its `message()` diagnostic is BasicControls.xml's, which we have not
/// transcribed, so the call is guarded; what must hold either way is that the frame keeps the type
/// it had.
#[test]
fn an_unknown_money_type_leaves_the_frame_on_the_one_it_had() {
let s = harness(12_345);
s.run("this = TestPurse MoneyFrame_SetType(\"NOT_A_TYPE\") this = nil")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return TestPurse.moneyType").unwrap(),
"PLAYER",
"the bad SetType returned before touching info/moneyType"
);
assert_eq!(
coins(&s, "TestPurse"),
("1".into(), "23".into(), "45".into())
);
}
/// `SetMoneyFrameColor` recolours the **digits**, not the coin icons — how the reference reddens a
/// price you cannot afford (l.348-356). Read off the extracted quads, which is where "the digits
/// and not the icons" is actually observable.
#[test]
fn set_money_frame_color_recolours_the_digits_and_not_the_icons() {
let mut s = harness(12_345); // 1g 23s 45c — three distinguishable digit strings
s.run("SetMoneyFrameColor(\"TestPurse\", 1.0, 0.1, 0.1)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
// Both frames show the same purse, so there are six digit quads; exactly the three belonging to
// the frame we named are red — which also pins that `SetMoneyFrameColor` is scoped by name and
// does not reach a sibling money frame.
let digit_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color: Some(c),
..
} if t == "1" || t == "23" || t == "45" => Some(*c),
_ => None,
})
.collect();
assert_eq!(digit_colors.len(), 6, "two frames * three denominations");
let reddened = |c: &[f32; 4]| {
(c[0] - 1.0).abs() < 0.01 && (c[1] - 0.1).abs() < 0.01 && (c[2] - 0.1).abs() < 0.01
};
assert_eq!(
digit_colors.iter().filter(|c| reddened(c)).count(),
3,
"only TestPurse's digits reddened, got {digit_colors:?}"
);
// The coin icons keep their own texture untinted — the half of the reference's contract that a
// "recolour the whole frame" shortcut would break.
let icon_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("UI-MoneyIcons") => Some(color.unwrap_or([1.0; 4])),
_ => None,
})
.collect();
assert_eq!(icon_colors.len(), 6, "six coin icons drew");
for c in &icon_colors {
assert!(
c[1] > 0.9 && c[2] > 0.9,
"the coin art is untinted, got {c:?}"
);
}
}
/// `MoneyTypeInfo` is read directly by addons, so its shape is part of the contract — all seven
/// reference types, each with an `UpdateFunc`, and the two we cannot source yet answering 0 rather
/// than erroring (the header's `GetSendMailMoney`/`GetSendMailCOD` gap).
#[test]
fn the_money_type_table_carries_all_seven_reference_types() {
let s = harness(12_345);
for t in [
"PLAYER",
"STATIC",
"AUCTION",
"PLAYER_TRADE",
"TARGET_TRADE",
"SEND_MAIL",
"SEND_MAIL_COD",
] {
assert!(
s.eval::<bool>(&format!(
"return MoneyTypeInfo[\"{t}\"] ~= nil and type(MoneyTypeInfo[\"{t}\"].UpdateFunc) == 'function'"
))
.unwrap(),
"MoneyTypeInfo[\"{t}\"] with an UpdateFunc"
);
}
// The two whose engine getters we have not built answer 0 instead of nil-calling.
for t in ["SEND_MAIL", "SEND_MAIL_COD"] {
s.run(&format!(
"this = TestPurse MoneyFrame_SetType(\"{t}\") this = nil"
))
.unwrap();
assert!(
s.errors().is_empty(),
"{t}: script errors: {:?}",
s.errors()
);
assert_eq!(
s.eval::<i64>("return TestPurse.staticMoney").unwrap(),
0,
"{t} reads 0 while its engine getter is missing"
);
}
}
@@ -205,9 +205,15 @@ fn shipped_stance_bar_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Cooldown.xml + ActionBar.xml first: StanceBar.xml anchors to BenillaActionBar and calls
// Cooldown.xml + ActionBar.xml first: StanceBar.xml anchors to MainMenuBar and calls
// CooldownFrame_SetTimer / BENILLA_FALLBACK_ICON (the runtime load order).
load_action_bar(&s);
// MultiBars.xml too, and it is load-bearing for the geometry below: the stance bar's OnShow
// re-fires UIParent_ManageFramePositions, whose `ShapeshiftBarFrame` row computes the y as
// baseY 0 + bottomLeft 45 — the same +45 the XML anchor carries statically, but only while
// the always-on bottom multibar (0270) exists to raise the flag. Without this file the pass
// sees no bottom bar, drops the 45 and the bar sits on MainMenuBar's top edge.
load_xml(&s, "MultiBars.xml");
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/StanceBar.xml"),
)
@@ -226,7 +232,7 @@ fn shipped_stance_bar_drives_end_to_end() {
// No forms pushed (a mage): the frame is hidden.
assert!(!s
.eval::<bool>("return BenillaShapeshiftBarFrame:IsShown()")
.eval::<bool>("return ShapeshiftBarFrame:IsShown()")
.unwrap());
// A warrior's two stances: battle active, defensive known but not castable.
@@ -253,7 +259,7 @@ fn shipped_stance_bar_drives_end_to_end() {
s.resolve();
let quads = s.extract();
// Geometry: the stance frame's BOTTOMLEFT = BenillaActionBar (1024×53, screen-bottom
// Geometry: the stance frame's BOTTOMLEFT = MainMenuBar (1024×53, screen-bottom
// centered ⇒ left edge 0) TOPLEFT +(30,45) = (30,98); button 1 at frame BOTTOMLEFT +(11,3),
// 30×30 ⇒ x[41,71] y[101,131]; button 2 chains +7 ⇒ left 78.
let icon = |quads: &[benilla_ui::script::ExtractedQuad], path: &str| {
@@ -272,10 +278,10 @@ fn shipped_stance_bar_drives_end_to_end() {
// The active form's checked ring; the not-castable grey on button 2's icon.
assert!(s
.eval::<bool>("return BenillaShapeshiftButton1:GetChecked()")
.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaShapeshiftButton2:GetChecked()")
.eval::<bool>("return ShapeshiftButton2:GetChecked()")
.unwrap());
let grey = quads.iter().find_map(|q| match &q.content {
QuadContent::Texture {
@@ -309,12 +315,12 @@ fn shipped_stance_bar_drives_end_to_end() {
// pre-OnClick toggle (ref ShapeshiftButtonTemplate), leaving checked to the isActive
// repaint: button 2 stays unchecked until the server actually shifts us…
assert!(
!s.eval::<bool>("return BenillaShapeshiftButton2:GetChecked()")
!s.eval::<bool>("return ShapeshiftButton2:GetChecked()")
.unwrap(),
"a clicked non-active form must stay unchecked until the form byte confirms"
);
assert!(s
.eval::<bool>("return BenillaShapeshiftButton1:GetChecked()")
.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap());
// …and clicking the ACTIVE form (the director's warrior bug: Battle Stance must not
@@ -324,7 +330,7 @@ fn shipped_stance_bar_drives_end_to_end() {
s.mouse_button(56.0, 116.0, "LeftButton", false);
assert_eq!(s.take_shapeshift_casts(), vec![2457]);
assert!(
s.eval::<bool>("return BenillaShapeshiftButton1:GetChecked()")
s.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap(),
"clicking the active stance must not untoggle its checked ring"
);
@@ -357,7 +363,7 @@ fn shipped_stance_bar_drives_end_to_end() {
s.mouse_button(56.0, 116.0, "LeftButton", false);
let _ = s.take_shapeshift_casts();
assert!(
s.eval::<bool>("return BenillaShapeshiftButton1:GetChecked()")
s.eval::<bool>("return ShapeshiftButton1:GetChecked()")
.unwrap(),
"and the active form stays lit through either"
);
@@ -366,7 +372,7 @@ fn shipped_stance_bar_drives_end_to_end() {
s.set_shapeshift_forms(vec![]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(!s
.eval::<bool>("return BenillaShapeshiftBarFrame:IsShown()")
.eval::<bool>("return ShapeshiftBarFrame:IsShown()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -432,22 +438,22 @@ fn multibar_hover_renders_the_buttons_own_action() {
};
assert_eq!(
hover_name(&s, "BenillaActionButton1").as_deref(),
hover_name(&s, "ActionButton1").as_deref(),
Some("Spell 100"),
"the main bar still reads its own slot"
);
assert_eq!(
hover_name(&s, "BenillaMultiBarBottomLeftButton1").as_deref(),
hover_name(&s, "MultiBarBottomLeftButton1").as_deref(),
Some("Spell 200"),
"BottomLeft button 1 is action 61 — not main slot 1 (the spell below it)"
);
assert_eq!(
hover_name(&s, "BenillaMultiBarBottomRightButton1").as_deref(),
hover_name(&s, "MultiBarBottomRightButton1").as_deref(),
Some("Spell 300"),
"BottomRight button 1 is action 49"
);
assert_eq!(
hover_name(&s, "BenillaMultiBarBottomLeftButton2").as_deref(),
hover_name(&s, "MultiBarBottomLeftButton2").as_deref(),
Some("Spell 400"),
"an occupied multibar slot over an EMPTY main slot still renders (the no-tooltip half)"
);
@@ -456,7 +462,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
hover_name(&s, "BenillaMultiBarBottomLeftButton1").as_deref(),
hover_name(&s, "MultiBarBottomLeftButton1").as_deref(),
Some("Spell 200"),
"a bonus page never re-pages a multibar hover"
);
@@ -2027,7 +2027,7 @@ fn the_action_bars_page_locks_the_real_bar() {
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
s.run("BenillaActionButton_OnDragStart(BenillaActionButton1)")
s.run("BenillaActionButton_OnDragStart(ActionButton1)")
.unwrap();
assert!(
s.cursor_payload().is_none(),
@@ -2037,7 +2037,7 @@ fn the_action_bars_page_locks_the_real_bar() {
// Defaults walks it back to ActionBar.xml's own assignment, and the bar drags again.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(s.eval::<String>("return LOCK_ACTIONBAR").unwrap(), "0");
s.run("BenillaActionButton_OnDragStart(BenillaActionButton1)")
s.run("BenillaActionButton_OnDragStart(ActionButton1)")
.unwrap();
assert!(s.cursor_payload().is_some(), "unlocked again");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -2419,7 +2419,8 @@ fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
// The bar needs a real span before it decides anything about its numerals (its update bails
// on valueMax == 0 and hides the strip instead).
s.set_player_xp(1000, 10000);
s.run("BenillaExpBar_Update(BenillaExpBar)").unwrap();
s.run("this = MainMenuExpBar; MainMenuExpBar_Update()")
.unwrap();
// Shipped default: off, so the numerals only show while hovered.
assert_eq!(
s.eval::<String>("return GetCVar(\"statusBarText\")")
@@ -2427,7 +2428,7 @@ fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
"0"
);
assert!(!s
.eval::<bool>("return BenillaExpBarText:IsShown()")
.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
@@ -2446,7 +2447,7 @@ fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
// No repaint, no XP tick — only the CVAR_UPDATE the third argument queued.
s.tick(0.0);
assert!(
s.eval::<bool>("return BenillaExpBarText:IsShown()")
s.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap(),
"the watcher woke on the click, not on the next value change"
);
@@ -2456,7 +2457,7 @@ fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
.unwrap();
s.tick(0.0);
assert!(!s
.eval::<bool>("return BenillaExpBarText:IsShown()")
.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -0,0 +1,471 @@
//! The shipped `assets/ui/UIPanelTemplates.xml` + `assets/ui/OptionsFrameTemplates.xml` — the
//! reference's SHARED widget kit, driven the way an addon drives it.
//!
//! **These are not tests of a window.** Nothing benilla ships instantiates a single template in
//! either file: their only consumer is a third-party addon writing
//! `CreateFrame(kind, name, parent, "SomeTemplate")`, which decision 1203 made work and which
//! `addon_harness` then ranked — 69 corpus call sites on `UICheckButtonTemplate` alone, 185 sites
//! in total naming templates we had never declared. So every test here goes in through
//! `CreateFrame`'s fourth argument, from Lua, with a caller-chosen name, because that is the entire
//! surface these files exist to serve.
//!
//! What they guard, in the order the harness ranked them:
//!
//! - **The name.** `CreateFrame("CheckButton", "MyCheck", UIParent, "UICheckButtonTemplate")` must
//! publish `MyCheckText`, never `UICheckButtonTemplateText` — `getglobal(this:GetName().."Text")`
//! is the next line every one of those 69 sites writes. 1203 pinned the *mechanism*; this pins it
//! through the real templates, including the two-level `$parent` case
//! (`MyScrollScrollBarScrollUpButton`).
//! - **The art.** Each state texture in those files carries the reference's `inherits=` *and* the
//! art that `inherits=` resolves to, because our loader does not expand `inherits=` in
//! state-texture position (the deviation is stated at the head of `UIPanelTemplates.xml`). The
//! extract assertions below are that deviation's falsifier: strip the inline `file=` back to the
//! reference's `inherits=`-only form and every checkbox and panel button in the corpus goes
//! invisible with nothing erroring.
//! - **The behaviour.** `SetChecked`/`GetChecked` across a click, the close button hiding its
//! parent, the edit box round-tripping text, the scroll frame's range reaching its bar.
//! - **The names are 1.12's.** The last test re-reads both files against
//! `reference/1.12-globals.tsv` — a template we invent under a plausible-looking name is a name
//! an addon can never have meant.
use benilla_ui::script::UiScript;
/// One shipped file into `s`, asserting it loaded clean.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(ui_dir().join(file)).unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// The manifest prefix these two files sit on: fonts, `UIParent` (the parent every addon passes),
/// `HideUIPanel` (the close button's OnClick), the tooltip (the options widgets' hover) and the
/// scroll kit (`ScrollFrame_OnLoad`), then the two files under test in manifest order.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UIParent.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"OptionsFrameTemplates.xml",
] {
load_xml(&s, file);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// Every texture path the resolved frame tree actually draws.
fn drawn_textures(s: &mut UiScript) -> Vec<String> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
benilla_ui::script::QuadContent::Texture { path: Some(p), .. } => Some(p),
_ => None,
})
.collect()
}
/// **The test this whole file exists for.** An addon's own line, verbatim, and then the global it
/// reads back on the next line.
///
/// The failure this pins is not "no checkbox": it is a checkbox whose label region is called
/// `UICheckButtonTemplateText`, so `getglobal(this:GetName().."Text")` is nil, so the addon's
/// `SetText` dies — with the `CreateFrame` itself having succeeded and returned an object.
#[test]
fn a_check_button_from_the_template_names_its_label_after_the_caller() {
let s = harness();
s.run(r#"MyCheck = CreateFrame("CheckButton", "MyCheck", UIParent, "UICheckButtonTemplate")"#)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>(r#"return getglobal("MyCheckText") ~= nil"#)
.unwrap(),
"the label publishes under the CALLER's name — this is the line an addon writes next"
);
assert!(
s.eval::<bool>(r#"return getglobal("UICheckButtonTemplateText") == nil"#)
.unwrap(),
"and never under the template's"
);
// The 69-call-site idiom, end to end.
s.run(r#"getglobal("MyCheckText"):SetText("Show my thing")"#)
.unwrap();
assert_eq!(
s.eval::<String>(r#"return getglobal("MyCheckText"):GetText()"#)
.unwrap(),
"Show my thing"
);
assert_eq!(
s.eval::<(f64, f64)>("return MyCheck:GetWidth(), MyCheck:GetHeight()")
.unwrap(),
(32.0, 32.0),
"the template's own 32x32"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The checkbox **paints**, and it paints the reference's art.
///
/// This is the falsifier for `UIPanelTemplates.xml`'s stated deviation: the reference declares its
/// state textures with `inherits=` alone, our loader does not expand `inherits=` there, and a
/// transcription that copied the reference literally would create a frame with no state textures at
/// all — no error, no warning, 69 invisible checkboxes.
#[test]
fn the_templated_check_button_draws_the_reference_checkbox_art() {
let mut s = harness();
s.run(
r#"MyCheck = CreateFrame("CheckButton", "MyCheck", UIParent, "UICheckButtonTemplate")
MyCheck:SetPoint("TOPLEFT", 20, -20)"#,
)
.unwrap();
assert!(
s.eval::<bool>("return MyCheck:GetNormalTexture() ~= nil")
.unwrap(),
"the state-texture slot exists at all"
);
let drawn = drawn_textures(&mut s);
assert!(
drawn
.iter()
.any(|p| p == r"Interface\Buttons\UI-CheckBox-Up"),
"the unchecked box is on screen: {drawn:?}"
);
assert!(
!drawn
.iter()
.any(|p| p == r"Interface\Buttons\UI-CheckBox-Check"),
"and the tick is not, until it is checked"
);
s.run("MyCheck:SetChecked(1)").unwrap();
let drawn = drawn_textures(&mut s);
assert!(
drawn
.iter()
.any(|p| p == r"Interface\Buttons\UI-CheckBox-Check"),
"checked draws the tick: {drawn:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A click toggles, and the addon's `OnClick` sees the NEW state — the widget contract every
/// options panel in the corpus is written against (`if this:GetChecked() then …`).
#[test]
fn a_templated_check_button_toggles_before_its_on_click_runs() {
let s = harness();
s.run(
r#"MyCheck = CreateFrame("CheckButton", "MyCheck", UIParent, "UICheckButtonTemplate")
MySeen = {}
MyCheck:SetScript("OnClick", function()
table.insert(MySeen, this:GetChecked() and 1 or 0)
end)"#,
)
.unwrap();
assert!(
!s.eval::<bool>("return MyCheck:GetChecked() and true or false")
.unwrap(),
"a fresh box is unchecked"
);
s.run("MyCheck:Click()").unwrap();
assert!(s
.eval::<bool>("return MyCheck:GetChecked() and true or false")
.unwrap());
s.run("MyCheck:Click()").unwrap();
assert!(!s
.eval::<bool>("return MyCheck:GetChecked() and true or false")
.unwrap());
assert_eq!(
s.eval::<Vec<i64>>("return MySeen").unwrap(),
vec![1, 0],
"the handler read the post-toggle state both times, not the pre-toggle one"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UIPanelButtonTemplate` — 19 corpus call sites, and the shared original of nine private copies
/// in our own `assets/ui`. Its label is a `<ButtonText>` rather than a layer FontString, so the
/// addon idiom is `btn:SetText(...)` and the published global is still `$parentText`.
#[test]
fn a_panel_button_from_the_template_labels_and_paints() {
let mut s = harness();
s.run(
r#"MyBtn = CreateFrame("Button", "MyBtn", UIParent, "UIPanelButtonTemplate")
MyBtn:SetWidth(90) MyBtn:SetHeight(22)
MyBtn:SetPoint("TOPLEFT", 20, -20)
MyBtn:SetText("Okay")"#,
)
.unwrap();
assert_eq!(
s.eval::<String>(r#"return getglobal("MyBtnText"):GetText()"#)
.unwrap(),
"Okay",
"<ButtonText name=\"$parentText\"> publishes against the caller"
);
assert!(s
.eval::<bool>(r#"return getglobal("UIPanelButtonTemplateText") == nil"#)
.unwrap());
let drawn = drawn_textures(&mut s);
assert!(
drawn
.iter()
.any(|p| p == r"Interface\Buttons\UI-Panel-Button-Up"),
"the button face is on screen: {drawn:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UIPanelCloseButton` — 8 call sites across 4 corpus addons, and the one template here whose
/// whole point is its script: `HideUIPanel(this:GetParent())`, resolved at click time against
/// `UiPanels.xml`.
#[test]
fn the_templated_close_button_hides_the_frame_it_sits_on() {
let s = harness();
s.run(
r#"MyPanel = CreateFrame("Frame", "MyPanel", UIParent)
MyPanel:SetWidth(200) MyPanel:SetHeight(100)
MyPanel:SetPoint("CENTER")
MyPanel:Show()
MyClose = CreateFrame("Button", "MyClose", MyPanel, "UIPanelCloseButton")
MyClose:SetPoint("TOPRIGHT")"#,
)
.unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return MyClose:GetWidth(), MyClose:GetHeight()")
.unwrap(),
(32.0, 32.0)
);
assert!(s.eval::<bool>("return MyPanel:IsShown()").unwrap());
s.run("MyClose:Click()").unwrap();
assert!(
!s.eval::<bool>("return MyPanel:IsShown()").unwrap(),
"the template's own OnClick reached HideUIPanel"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `InputBoxTemplate` — 5 call sites across 3 corpus addons. Its three border slices are `<Layers>`
/// regions (so they publish by name), and its direct-child `<FontString>` is the box's *text*
/// region, not a layer.
#[test]
fn an_input_box_from_the_template_carries_its_border_and_takes_text() {
let mut s = harness();
s.run(
r#"MyBox = CreateFrame("EditBox", "MyBox", UIParent, "InputBoxTemplate")
MyBox:SetWidth(120) MyBox:SetHeight(20)
MyBox:SetPoint("TOPLEFT", 20, -20)
MyBox:SetText("hello")"#,
)
.unwrap();
for slice in ["Left", "Right", "Middle"] {
assert!(
s.eval::<bool>(&format!(r#"return getglobal("MyBox{slice}") ~= nil"#))
.unwrap(),
"the {slice} border slice publishes against the caller's name"
);
}
assert_eq!(s.eval::<String>("return MyBox:GetText()").unwrap(), "hello");
let drawn = drawn_textures(&mut s);
assert!(
drawn
.iter()
.any(|p| p == r"Interface\Common\Common-Input-Border"),
"the input border draws: {drawn:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UIPanelScrollFrameTemplate` — 6 call sites across 2 corpus addons, and the template with the
/// deepest naming problem: its scroll bar's arrow buttons are `$parentScrollUpButton` inside
/// `$parentScrollBar`, so a caller named `MyScroll` must reach
/// `MyScrollScrollBarScrollUpButton` through **two** levels of `$parent` — which is precisely what
/// `ScrollFrame_OnLoad` getglobals on the way past.
///
/// The `UpdateScrollChildRect` leg is also the falsifier for this file's one Lua divergence:
/// `ScrollFrame_OnScrollRangeChanged` reaches the thumb through `GetThumbTexture()` rather than the
/// reference's `getglobal(bar:GetName().."ThumbTexture")`, because our loader never publishes a
/// named `<ThumbTexture>` as a global. If that divergence were reverted, this call would raise on a
/// nil index and `s.errors()` would not be empty.
#[test]
fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
let mut s = harness();
s.run(
r#"MyScroll = CreateFrame("ScrollFrame", "MyScroll", UIParent, "UIPanelScrollFrameTemplate")
MyScroll:SetWidth(290) MyScroll:SetHeight(80)
MyScroll:SetPoint("TOPLEFT", 20, -20)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
for child in [
"ScrollBar",
"ScrollBarScrollUpButton",
"ScrollBarScrollDownButton",
] {
assert!(
s.eval::<bool>(&format!(r#"return getglobal("MyScroll{child}") ~= nil"#))
.unwrap(),
"MyScroll{child} — the caller's name won through every $parent level"
);
}
// ScrollFrame_OnLoad ran off the template: empty range, both arrows greyed, offset 0.
assert_eq!(
s.eval::<(f64, f64)>("return MyScrollScrollBar:GetMinMaxValues()")
.unwrap(),
(0.0, 0.0)
);
assert!(!s
.eval::<bool>("return MyScrollScrollBarScrollUpButton:IsEnabled()")
.unwrap());
assert_eq!(s.eval::<i64>("return MyScroll.offset").unwrap(), 0);
// A scroll child taller than the window, then the range change the reference's own handler
// consumes — the leg that touches the thumb.
s.run(
r#"MyScrollChild = CreateFrame("Frame", "MyScrollChild", MyScroll)
MyScrollChild:SetWidth(290) MyScrollChild:SetHeight(192)
MyScroll:SetScrollChild(MyScrollChild)"#,
)
.unwrap();
s.resolve();
s.run("MyScroll:UpdateScrollChildRect()").unwrap();
assert!(
s.errors().is_empty(),
"ScrollFrame_OnScrollRangeChanged ran clean: {:?}",
s.errors()
);
assert_eq!(
s.eval::<(f64, f64)>("return MyScrollScrollBar:GetMinMaxValues()")
.unwrap(),
(0.0, 112.0),
"192px of content in an 80px window — the bar's range is the overflow"
);
assert!(
s.eval::<bool>("return MyScrollScrollBarScrollDownButton:IsEnabled()")
.unwrap(),
"there is somewhere to scroll to, so the down arrow woke"
);
// And the frame's own <OnVerticalScroll> seats the bar and re-enables the up arrow.
s.run("MyScroll:SetVerticalScroll(48)").unwrap();
assert_eq!(
s.eval::<f64>("return MyScrollScrollBar:GetValue()")
.unwrap(),
48.0
);
assert!(s
.eval::<bool>("return MyScrollScrollBarScrollUpButton:IsEnabled()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UIOptionsCheckButtonTemplate` — 8 call sites, all in one addon's options panel, and a
/// two-generation chain: it overrides the size of `OptionsCheckButtonTemplate`, which adds the hit
/// rect and the click sound to `UICheckButtonTemplate`. The chain has to resolve through
/// `CreateFrame`'s fourth argument the same way it resolves through XML `inherits=`.
#[test]
fn the_options_check_button_resolves_its_whole_inheritance_chain() {
let mut s = harness();
s.run(
r#"MyOpt = CreateFrame("CheckButton", "MyOpt", UIParent, "UIOptionsCheckButtonTemplate")"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<(f64, f64)>("return MyOpt:GetWidth(), MyOpt:GetHeight()")
.unwrap(),
(26.0, 26.0),
"UIOptionsFrame.xml's own size override, the LAST link in the chain"
);
let (_, right, _, _) = s
.eval::<(f64, f64, f64, f64)>("return MyOpt:GetHitRectInsets()")
.unwrap();
assert_eq!(
right, -100.0,
"OptionsCheckButtonTemplate's label-catching hit rect, the MIDDLE link"
);
assert!(
s.eval::<bool>(r#"return getglobal("MyOptText") ~= nil"#)
.unwrap(),
"and UICheckButtonTemplate's label, the ROOT link — still named after the caller"
);
// The middle link's OnClick is the reference's option-toggle sound pair.
let _ = s.take_sounds();
s.run("MyOpt:Click()").unwrap();
assert!(
!s.take_sounds().is_empty(),
"clicking an options checkbox makes the reference's toggle sound"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **No invented names.** Every `virtual="true"` template these two files declare is a name the
/// real 1.12.1 client has, read from `reference/1.12-globals.tsv`.
///
/// Scoped to these two files on purpose: the rest of `assets/ui` is full of deliberately
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `AddonListPanelButtonTemplate`,
/// `OptionsRedButtonTemplate`), and a whole-tree sweep would be asserting something else. These
/// two files make the opposite claim — *these are the reference's own names, which is why an addon
/// can find them* — so that claim is the one worth gating. A template renamed to something
/// plausible-but-absent here is a template no addon will ever name.
#[test]
fn every_template_these_files_declare_is_a_real_1_12_name() {
let tsv =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../reference/1.12-globals.tsv");
let text = std::fs::read_to_string(&tsv).unwrap_or_else(|e| {
panic!(
"reading {}: {e} — regenerate with scripts/gen-reference-globals.py",
tsv.display()
)
});
let known: std::collections::HashSet<&str> = text
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').next())
.collect();
let mut checked = 0;
for file in ["UIPanelTemplates.xml", "OptionsFrameTemplates.xml"] {
let src = std::fs::read_to_string(ui_dir().join(file)).unwrap();
let doc = benilla_ui::framexml::parse(&src).unwrap();
for item in &doc.items {
let benilla_ui::framexml::TopLevel::Template(el) = item else {
continue;
};
let name = el.name().expect("every template here is named");
assert!(
known.contains(name),
"{file} declares '{name}', which is not a 1.12 global — \
a template the reference does not have is one no addon can name"
);
checked += 1;
}
}
assert!(
checked >= 18,
"only {checked} templates swept — the sweep, not the files, is what broke"
);
}
+109 -45
View File
@@ -100,11 +100,9 @@ fn shipped_gossip_frame_drives_end_to_end() {
// The window is shown (ShowUIPanel put it on the left slot), the greeting painted, the vendor
// row's icon rendered.
assert!(s
.eval::<bool>("return BenillaGossipFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return GossipFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return BenillaGossipGreetingText:GetText()")
s.eval::<String>("return GossipGreetingText:GetText()")
.unwrap(),
"Greetings, traveler. How may I help you?"
);
@@ -112,8 +110,8 @@ fn shipped_gossip_frame_drives_end_to_end() {
// so the shared pool starts filling from option 1 at row 1 — decision 0088 §3).
let states: (bool, bool, bool, bool) = s
.eval(
"return BenillaGossipRow1:IsVisible(), BenillaGossipRow1:IsEnabled(),\n\
BenillaGossipRow2:IsEnabled(), BenillaGossipRow3:IsVisible()",
"return GossipRow1:IsVisible(), GossipRow1:IsEnabled(),\n\
GossipRow2:IsEnabled(), GossipRow3:IsVisible()",
)
.unwrap();
assert_eq!(
@@ -212,9 +210,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
// through HideUIPanel, which vacates the left slot.
s.run("BenillaGossipCloseButton_OnClick()").unwrap();
assert!(s.take_gossip_close());
assert!(!s
.eval::<bool>("return BenillaGossipFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return GossipFrame:IsVisible()").unwrap());
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"HideUIPanel vacated the left slot"
@@ -271,10 +267,10 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
bool,
) = s
.eval(
"return BenillaGossipRow1Label:GetText(), BenillaGossipRow1:IsVisible(),\n\
BenillaGossipRow2Label:GetText(), BenillaGossipRow2:IsVisible(),\n\
BenillaGossipRow3Label:GetText(), BenillaGossipRow3:IsVisible(),\n\
BenillaGossipRow4:IsVisible()",
"return GossipRow1Label:GetText(), GossipRow1:IsVisible(),\n\
GossipRow2Label:GetText(), GossipRow2:IsVisible(),\n\
GossipRow3Label:GetText(), GossipRow3:IsVisible(),\n\
GossipRow4:IsVisible()",
)
.unwrap();
assert_eq!((r1_text.as_str(), r1_vis), ("Report to Goldshire", true));
@@ -325,8 +321,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
// Click quest row 2 (available, "A Threat Within") → SelectGossipQuest(2) queues the seam's
// 1-based quest-row position; the app maps it to the quest id + guid and sends
// CMSG_QUESTGIVER_QUERY_QUEST (decision 0088 §3).
s.run("BenillaGossipRow_OnClick(BenillaGossipRow2)")
.unwrap();
s.run("BenillaGossipRow_OnClick(GossipRow2)").unwrap();
assert_eq!(s.take_gossip_quest_selects(), vec![2]);
assert!(
s.take_gossip_selects().is_empty(),
@@ -412,8 +407,8 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"return BenillaGossipRow{i}:GetTop(), BenillaGossipRow{i}:GetBottom(), \
BenillaGossipRow{i}Label:GetStringHeight()"
"return GossipRow{i}:GetTop(), GossipRow{i}:GetBottom(), \
GossipRow{i}Label:GetStringHeight()"
))
.unwrap()
};
@@ -509,8 +504,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaGossipFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
"gossip opened onto the (empty) left slot"
);
s.resolve();
@@ -540,13 +534,10 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
// Gossip is hidden (both pushable=0 ⇒ SetLeftFrame replaced it); merchant now holds the left
// slot at the same anchor.
assert!(
!s.eval::<bool>("return BenillaGossipFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
"opening merchant replaced gossip at the left slot"
);
assert!(s
.eval::<bool>("return BenillaMerchantFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return MerchantFrame:IsVisible()").unwrap());
s.resolve();
let win = frame_rect(&s.extract(), 384.0, 512.0);
assert_eq!(
@@ -555,7 +546,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
"merchant took the left slot gossip vacated"
);
assert!(s
.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaMerchantFrame\"")
.eval::<bool>("return GetLeftFrame():GetName() == \"MerchantFrame\"")
.unwrap());
// Merchant's own close vacates the slot entirely.
@@ -632,7 +623,7 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
// frame), so the stand-in hides itself first too.
s.run(
r#"
local loot = CreateFrame("Frame", "BenillaLootFrame")
local loot = CreateFrame("Frame", "LootFrame")
loot:Hide()
loot:SetSize(50, 50)
ShowUIPanel(loot)
@@ -641,7 +632,7 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaLootFrame\"")
s.eval::<bool>("return GetLeftFrame():GetName() == \"LootFrame\"")
.unwrap(),
"loot took the empty left slot"
);
@@ -670,12 +661,12 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == \"BenillaLootFrame\"")
s.eval::<bool>("return GetCenterFrame():GetName() == \"LootFrame\"")
.unwrap(),
"loot was pushed to center, not replaced"
);
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaMerchantFrame\"")
s.eval::<bool>("return GetLeftFrame():GetName() == \"MerchantFrame\"")
.unwrap(),
"merchant took the left slot loot vacated"
);
@@ -734,7 +725,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
}));
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s
.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaGossipFrame\"")
.eval::<bool>("return GetLeftFrame():GetName() == \"GossipFrame\"")
.unwrap());
// The option is picked; SMSG_SHOW_BANK lands and the app both opens the bank AND clears
@@ -753,15 +744,12 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaGossipFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
"the gossip menu is gone once the vault is up (bank_first={bank_first})"
);
assert!(s
.eval::<bool>("return BenillaBankFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return BankFrame:IsVisible()").unwrap());
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaBankFrame\"")
s.eval::<bool>("return GetLeftFrame():GetName() == \"BankFrame\"")
.unwrap(),
"the bank ends at the LEFT slot, not parked at center (bank_first={bank_first})"
);
@@ -833,8 +821,8 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// The child grew past the 334 px frame, so there is a range to scroll …
let (child_h, range): (f32, f32) = s
.eval(
"return BenillaGossipGreetingScrollChild:GetHeight(), \
BenillaGossipGreetingScroll:GetVerticalScrollRange()",
"return GossipGreetingScrollChild:GetHeight(), \
GossipGreetingScroll:GetVerticalScrollRange()",
)
.unwrap();
assert!(
@@ -844,7 +832,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// … and the bar is up for it (it stays hidden when everything fits — the fit case is covered by
// `shipped_gossip_frame_drives_end_to_end`'s two-option menu).
assert!(
s.eval::<bool>("return BenillaGossipGreetingScrollBar:IsVisible()")
s.eval::<bool>("return GossipGreetingScrollBar:IsVisible()")
.unwrap(),
"the scrollbar shows once the menu overflows"
);
@@ -855,9 +843,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// out with: it must be the scroll frame's own rect, and rows past the bottom must be entirely
// outside it (nothing of them survives the clip).
let (frame_top, frame_bottom): (f32, f32) = s
.eval(
"return BenillaGossipGreetingScroll:GetTop(), BenillaGossipGreetingScroll:GetBottom()",
)
.eval("return GossipGreetingScroll:GetTop(), GossipGreetingScroll:GetBottom()")
.unwrap();
let painted_rows =
|s: &mut UiScript| -> Vec<(benilla_ui::layout::Rect, benilla_ui::layout::Rect)> {
@@ -890,9 +876,9 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// Scrolling pans the content up under that clip by exactly the scroll amount. Rects are y-up
// (`ExtractedQuad::rect`), so "up" means row 1's top EDGE VALUE grows as it slides off the top.
let first_top =
|s: &mut UiScript| -> f32 { s.eval::<f32>("return BenillaGossipRow1:GetTop()").unwrap() };
|s: &mut UiScript| -> f32 { s.eval::<f32>("return GossipRow1:GetTop()").unwrap() };
let before = first_top(&mut s);
s.run("BenillaScroll_Step(BenillaGossipGreetingScroll, 100)")
s.run("BenillaScroll_Step(GossipGreetingScroll, 100)")
.unwrap();
s.resolve();
let after = first_top(&mut s);
@@ -902,7 +888,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
);
// The bar followed the scroll (SyncBar off OnVerticalScroll), and the rows are still clipped.
assert_eq!(
s.eval::<f32>("return BenillaGossipGreetingScrollBar:GetValue()")
s.eval::<f32>("return GossipGreetingScrollBar:GetValue()")
.unwrap(),
100.0,
"the bar seats at the scroll offset"
@@ -915,3 +901,81 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── the panel manager as an ADDON reaches it (1206's audit, finished) ─────────────────────────
/// **An addon's own window, registered in `UIPanelWindows`, gets the left slot** — the same slot,
/// at the same anchor, as one of ours.
///
/// `UIPanelWindows["MyFrame"] = { area = "left", pushable = 1 }` is the 1.12 way an addon asks to
/// be a panel rather than a floating box, and **20 corpus addons write that line**. The table has
/// been present since the manager landed, so nothing would have raised — but a table an addon can
/// write to and a manager that honours what it wrote are different claims, and only the second one
/// is worth anything (1203's lesson: a capability absent without a failure is the quiet kind).
///
/// The assertion is deliberately the *same* rect the shipped panels are asserted at
/// (`(0.0, 664.0)` for a 384x512 at 1024x768), so an addon's window is not merely "somewhere" —
/// it is where a client window would be.
#[test]
fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "GossipFrame.xml");
// The addon's three lines, in the order an addon writes them.
s.run(
r#"AddonPanel = CreateFrame("Frame", "AddonPanel", UIParent)
AddonPanel:SetWidth(384) AddonPanel:SetHeight(512)
AddonPanel:Hide()
UIPanelWindows["AddonPanel"] = { area = "left", pushable = 1 }
ShowUIPanel(AddonPanel)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return AddonPanel:IsVisible()").unwrap(),
"ShowUIPanel showed the addon's frame"
);
s.resolve();
let win = frame_rect(&s.extract(), 384.0, 512.0);
assert_eq!(
(win.left, win.top),
(0.0, 664.0),
"the addon's panel is at the left slot, where a client panel would be"
);
// ...and it takes part in the pushable arbitration like one of ours. The reference's own
// branch (UIParent.lua l.725-741): left is the addon at pushable=1, nothing in center, and a
// pushable=0 window arrives — `leftInfo.pushable > info.pushable`, so the addon's frame is
// MOVED TO CENTER and the client window takes left. Both stay visible.
//
// Worth spelling out because the obvious expectation is the opposite one, and it is what this
// test asserted first: "a pushable=0 window replaces what is holding the slot" is only true
// when BOTH are pushable=0 (l.728-731). Getting this wrong for an addon means either losing
// its window or leaving two panels on top of each other.
s.set_gossip(Some(GossipMenu {
greeting: Some("Well met.".into()),
quests: Vec::new(),
options: Vec::new(),
}));
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return GossipFrame:IsVisible()").unwrap());
assert!(
s.eval::<bool>("return AddonPanel:IsVisible()").unwrap(),
"the addon's pushable=1 panel was PUSHED, not closed"
);
s.resolve();
assert!(
s.extract().iter().any(|q| q
.rect
.is_some_and(|r| (r.left - 384.0).abs() < 0.5 && (r.top - 664.0).abs() < 0.5)),
"and it is at the center slot (UIParent TOPLEFT +384, -104)"
);
// HideUIPanel on an addon frame that is not currently in a slot must be a no-op, not an error.
s.run("HideUIPanel(AddonPanel)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -39,7 +39,7 @@ fn harness(extra: &[&str]) -> UiScript {
s
}
/// The meter's own paint quad (its `BenillaPerformanceBar` texture) and where it sits in the
/// The meter's own paint quad (its `MainMenuBarPerformanceBar` texture) and where it sits in the
/// painter's order.
fn bar_quad(s: &mut UiScript) -> (usize, [f32; 4], Option<[f32; 4]>) {
s.resolve();
@@ -71,8 +71,8 @@ fn the_meter_sits_in_the_bar_recess_the_reference_leaves_for_it() {
let (left, bottom, w, h) = s
.eval::<(f64, f64, f64, f64)>(
"return BenillaPerformanceBarFrame:GetLeft(), BenillaPerformanceBarFrame:GetBottom(), \
BenillaPerformanceBarFrame:GetWidth(), BenillaPerformanceBarFrame:GetHeight()",
"return MainMenuBarPerformanceBarFrame:GetLeft(), MainMenuBarPerformanceBarFrame:GetBottom(), \
MainMenuBarPerformanceBarFrame:GetWidth(), MainMenuBarPerformanceBarFrame:GetHeight()",
)
.unwrap();
assert_eq!((w, h), (16.0, 64.0), "frame size");
@@ -115,12 +115,12 @@ fn the_meter_paints_under_the_bar_art_it_shows_through() {
// And the hover button is above everything, the ref's own HIGH-strata split: the LOW frame
// beneath the art can't take the mouse itself.
assert_eq!(
s.eval::<String>("return BenillaPerformanceBarButton:GetFrameStrata()")
s.eval::<String>("return MainMenuBarPerformanceBarFrameButton:GetFrameStrata()")
.unwrap(),
"HIGH"
);
assert_eq!(
s.eval::<String>("return BenillaPerformanceBarFrame:GetFrameStrata()")
s.eval::<String>("return MainMenuBarPerformanceBarFrame:GetFrameStrata()")
.unwrap(),
"LOW",
"the parent's LOW must not have been dragged up by its HIGH child"
@@ -240,11 +240,11 @@ fn hovering_the_meter_shows_the_live_latency() {
assert_eq!(
s.hit_test_name(789.0, 20.0).as_deref(),
Some("BenillaPerformanceBarButton"),
Some("MainMenuBarPerformanceBarFrameButton"),
"the button over the meter takes the mouse"
);
s.run("BenillaPerformanceBar_OnEnter(BenillaPerformanceBarButton)")
s.run("BenillaPerformanceBar_OnEnter(MainMenuBarPerformanceBarFrameButton)")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
@@ -311,7 +311,10 @@ fn hovering_the_meter_shows_the_live_latency() {
#[test]
fn the_meter_adds_its_two_frames_to_the_bar() {
let s = harness(&[]);
for name in ["BenillaPerformanceBarFrame", "BenillaPerformanceBarButton"] {
for name in [
"MainMenuBarPerformanceBarFrame",
"MainMenuBarPerformanceBarFrameButton",
] {
assert!(
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
"{name} must exist"
@@ -319,9 +322,9 @@ fn the_meter_adds_its_two_frames_to_the_bar() {
}
// The button is the frame's child (the ref's `parent=` attribute, expressed as nesting).
assert_eq!(
s.eval::<String>("return BenillaPerformanceBarButton:GetParent():GetName()")
s.eval::<String>("return MainMenuBarPerformanceBarFrameButton:GetParent():GetName()")
.unwrap(),
"BenillaPerformanceBarFrame"
"MainMenuBarPerformanceBarFrame"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -23,7 +23,7 @@ fn load_xml(s: &UiScript, file: &str) {
/// The pet bar's own load prerequisites, in manifest order: UiPanels (`SetDesaturation`, the
/// disabled-bar grey), UIParent (the managed bottom stack its OnShow/OnHide re-fires), Cooldown
/// (`CooldownFrame_SetTimer`), ActionBar (the `BenillaActionBar` anchor target) — then the bar.
/// (`CooldownFrame_SetTimer`), ActionBar (the `MainMenuBar` anchor target) — then the bar.
fn load_pet_bar(s: &UiScript) {
for file in [
"UiPanels.xml",
@@ -190,8 +190,8 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"4 emitters x 8 trail stars, on the one autocasting slot"
);
// Geometry, quoted from the ref: the bar's TOPLEFT is BenillaActionBar's BOTTOMLEFT +(36,97),
// and BenillaActionBar is the 1024x53 frame at screen BOTTOM ⇒ its BOTTOMLEFT is (0,0), so the
// Geometry, quoted from the ref: the bar's TOPLEFT is MainMenuBar's BOTTOMLEFT +(36,97),
// and MainMenuBar is the 1024x53 frame at screen BOTTOM ⇒ its BOTTOMLEFT is (0,0), so the
// frame spans y[54,97]. Button 1 sits at the frame's own BOTTOMLEFT +(36,2) ⇒ x[72,102]
// y[56,86] — the same 72 the reference lands on (its PETACTIONBAR_XPOS 36 + the button's 36).
let attack =
@@ -284,7 +284,7 @@ fn a_click_drops_the_ring_and_the_repaint_restores_it() {
declare_token_strings(&s);
let checked = |s: &UiScript| {
s.eval::<bool>("return BenillaPetActionButton1:GetChecked()")
s.eval::<bool>("return PetActionButton1:GetChecked()")
.unwrap()
};
@@ -440,11 +440,9 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
s.resolve();
// Slot 5 is empty, so its button is hidden — until the grid comes up.
assert!(!s
.eval::<bool>("return BenillaPetActionButton5:IsShown()")
.unwrap());
assert!(!s.eval::<bool>("return PetActionButton5:IsShown()").unwrap());
s.run("BenillaPetActionButton_OnDragStart(BenillaPetActionButton4)")
s.run("this = PetActionButton4; PetActionButton_OnDragStart()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
@@ -455,17 +453,15 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
s.tick(0.01);
s.resolve();
assert_eq!(
s.eval::<i64>("return BenillaPetActionBarFrame.showgrid")
.unwrap(),
s.eval::<i64>("return PetActionBarFrame.showgrid").unwrap(),
1
);
assert!(
s.eval::<bool>("return BenillaPetActionButton5:IsShown()")
.unwrap(),
s.eval::<bool>("return PetActionButton5:IsShown()").unwrap(),
"the grid reveals the empty slots to drop onto"
);
s.run("BenillaPetActionButton_OnReceiveDrag(BenillaPetActionButton5)")
s.run("this = PetActionButton5; PetActionButton_OnReceiveDrag()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
@@ -475,8 +471,7 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
);
s.tick(0.01);
assert_eq!(
s.eval::<i64>("return BenillaPetActionBarFrame.showgrid")
.unwrap(),
s.eval::<i64>("return PetActionBarFrame.showgrid").unwrap(),
0,
"the grid goes down with the payload"
);
@@ -498,7 +493,7 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
s.resolve();
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
s.run("BenillaPetActionButton_OnDragStart(BenillaPetActionButton4)")
s.run("this = PetActionButton4; PetActionButton_OnDragStart()")
.unwrap();
assert!(
s.take_pet_set_actions().is_empty(),
@@ -508,7 +503,7 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
// Shift-click is still the way through, and then the drop is refused too while locked.
s.set_modifiers(true, false, false);
s.run("BenillaPetActionButton_OnClick(BenillaPetActionButton4, \"LeftButton\")")
s.run("this = PetActionButton4; PetActionButton_OnClick(\"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
@@ -516,7 +511,7 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]],
"shift-click picked it up despite the lock"
);
s.run("BenillaPetActionButton_OnReceiveDrag(BenillaPetActionButton5)")
s.run("this = PetActionButton5; PetActionButton_OnReceiveDrag()")
.unwrap();
assert!(
s.take_pet_set_actions().is_empty(),
@@ -524,7 +519,7 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
);
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
s.run("BenillaPetActionButton_OnReceiveDrag(BenillaPetActionButton5)")
s.run("this = PetActionButton5; PetActionButton_OnReceiveDrag()")
.unwrap();
assert_eq!(
s.take_pet_set_actions(),
@@ -548,7 +543,8 @@ fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
s.run(
"IsShiftKeyDown = function() return 1 end \
BenillaPetActionButton_OnClick(BenillaPetActionButton4, 'RightButton')",
this = PetActionButton4 \
PetActionButton_OnClick('RightButton')",
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -588,12 +584,12 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
s.resolve();
let state = |s: &UiScript| {
s.eval::<String>("return BenillaPetActionButton1:GetButtonState()")
s.eval::<String>("return PetActionButton1:GetButtonState()")
.unwrap()
};
// Down shows the pushed art and fires nothing (the ref's runOnUp shape).
s.run("BenillaPetActionButtonDown(1)").unwrap();
s.run("PetActionButtonDown(1)").unwrap();
assert_eq!(state(&s), "PUSHED");
assert!(
s.take_pet_actions().is_empty(),
@@ -603,7 +599,7 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
// Up releases the art and casts — slot 1 is Attack and the view says the order is live, so a
// LEFT CLICK here would call it off instead (a possess bar's case; see the header). The key
// has no such fork.
s.run("BenillaPetActionButtonUp(1)").unwrap();
s.run("PetActionButtonUp(1)").unwrap();
assert_eq!(state(&s), "NORMAL");
assert_eq!(s.take_pet_actions(), vec![1]);
assert_eq!(
@@ -614,20 +610,20 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
// The state guard is the whole re-entrancy story: an up with nothing pushed does nothing
// (a focus-stolen release, a stuck-latch sweep), and a second down does not re-fire.
s.run("BenillaPetActionButtonUp(1)").unwrap();
s.run("PetActionButtonUp(1)").unwrap();
assert!(
s.take_pet_actions().is_empty(),
"an unmatched release fires nothing"
);
s.run("BenillaPetActionButtonDown(1) BenillaPetActionButtonDown(1)")
s.run("PetActionButtonDown(1) PetActionButtonDown(1)")
.unwrap();
assert_eq!(state(&s), "PUSHED");
s.run("BenillaPetActionButtonUp(1)").unwrap();
s.run("PetActionButtonUp(1)").unwrap();
assert_eq!(s.take_pet_actions(), vec![1], "one press, one cast");
// An EMPTY slot is inert through the same path (CastPetAction's own guard): slot 2 of the
// hunter bar carries no name, and its button is hidden.
s.run("BenillaPetActionButtonDown(2) BenillaPetActionButtonUp(2)")
s.run("PetActionButtonDown(2) PetActionButtonUp(2)")
.unwrap();
assert!(
s.take_pet_actions().is_empty(),
@@ -1,5 +1,5 @@
//! The shipped pet frame, driven end to end (decision 0990) — `UnitFrames.xml`'s
//! `BenillaPetFrame` over synthetic `"pet"` snapshots and the events the app's feed fires.
//! `PetFrame` over synthetic `"pet"` snapshots and the events the app's feed fires.
//!
//! The frame's whole job is to be right about **which unit an event names**, and that is what most
//! of these test: `UNIT_PET` names the OWNER (`arg1 == "player"`, wow-re §9), every other `UNIT_*`
@@ -25,8 +25,18 @@ fn load_xml(s: &UiScript, file: &str) {
);
}
/// The pet frame's production load prefix — it is parented to `BenillaPlayerFrame`, so the whole
/// The pet frame's production load prefix — it is parented to `PlayerFrame`, so the whole
/// unit-frame file comes with it, and the tooltip/dropdown kit its hover and its neighbours need.
///
/// **A player is seated, and that is load-bearing rather than tidy.** `PetFrame` really is a CHILD
/// of `PlayerFrame` — our `UnitFrames.xml:1792` says `parent="PlayerFrame"` and the reference's
/// `PetFrame.xml:4` says the same — and `UnitFrame_Update` hides a frame whose unit does not exist.
/// So a fixture with a pet and no player gives `PlayerFrame:Hide()`, and a hidden parent means an
/// invisible pet frame. That is the reference's own structure working correctly; a pet without a
/// player is a state the game cannot be in.
///
/// (It did not matter until the loader learned the `parent=` attribute, which it had been ignoring:
/// before that `PetFrame` was a top-level frame and nothing could hide it from above.)
fn load_pet_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -36,6 +46,21 @@ fn load_pet_frame() -> UiScript {
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "UnitFrames.xml");
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Tri".into()),
health: 100,
max_health: 100,
level: 60,
power_type: 0,
power: 100,
max_power: 100,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s
}
@@ -93,6 +118,8 @@ fn debuff(spell_id: u32, name: &str, count: u8) -> AuraState {
expiration_time: 0.0,
helpful: false,
cancelable: false,
until_cancelled: false,
channeled: false,
}
}
@@ -103,8 +130,7 @@ fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
let mut s = load_pet_frame();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return PetFrame:IsVisible()").unwrap(),
"no pet, no frame"
);
@@ -114,11 +140,11 @@ fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
let ok: bool = s
.eval(
r#"
local hb, mb = BenillaPetFrameHealthBar, BenillaPetFrameManaBar
local hb, mb = PetFrameHealthBar, PetFrameManaBar
local _, hmax = hb:GetMinMaxValues()
local _, mmax = mb:GetMinMaxValues()
return BenillaPetFrame:IsVisible()
and BenillaPetFrameTextureFrameName:GetText() == "Grimjaw"
return PetFrame:IsVisible()
and PetFrameTextureFrameName:GetText() == "Grimjaw"
and hb:GetValue() == 72 and hmax == 100
and mb:GetValue() == 45 and mmax == 80 and mb:IsVisible()
"#,
@@ -129,9 +155,7 @@ fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
// The dismiss: the token clears and UNIT_PET fires, exactly as `feed_pet_unit` does it.
s.set_unit("pet", None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return PetFrame:IsVisible()").unwrap());
}
/// **The two argument gates.** `UNIT_PET` names the OWNER, so `arg1 == "pet"` on it is not our
@@ -143,15 +167,13 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(s
.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return PetFrame:IsVisible()").unwrap());
// A health change on the PLAYER must not touch the pet's bar.
s.set_unit("pet", Some(pet("Grimjaw", 5, 45, 80, 0)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<f64>("return BenillaPetFrameHealthBar:GetValue()")
s.eval::<f64>("return PetFrameHealthBar:GetValue()")
.unwrap(),
72.0,
"a UNIT_HEALTH for \"player\" is not the pet's event"
@@ -159,7 +181,7 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
// …and the same event naming the pet does.
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("pet".into())]);
assert_eq!(
s.eval::<f64>("return BenillaPetFrameHealthBar:GetValue()")
s.eval::<f64>("return PetFrameHealthBar:GetValue()")
.unwrap(),
5.0
);
@@ -168,14 +190,11 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
s.set_unit("pet", None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("pet".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return PetFrame:IsVisible()").unwrap(),
"UNIT_PET names the OWNER — a \"pet\" arg1 is somebody else's event"
);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return PetFrame:IsVisible()").unwrap());
}
/// The reference's art swap (`PetFrame.lua`'s Update): a pet with no power wears the `-NoMana`
@@ -194,8 +213,8 @@ fn a_powerless_pet_wears_the_no_mana_plate() {
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
let (visible, r, g, b) = s
.eval::<(bool, f64, f64, f64)>(
"local r, g, b = BenillaPetFrameManaBar:GetStatusBarColor() \
return BenillaPetFrameManaBar:IsVisible(), r, g, b",
"local r, g, b = PetFrameManaBar:GetStatusBarColor() \
return PetFrameManaBar:IsVisible(), r, g, b",
)
.unwrap();
assert!(visible);
@@ -207,7 +226,7 @@ fn a_powerless_pet_wears_the_no_mana_plate() {
s.set_unit("pet", Some(pet("Skeleton", 100, 0, 0, 0)));
s.fire_event("UNIT_MAXPOWER", vec![ScriptValue::Str("pet".into())]);
assert!(
!s.eval::<bool>("return BenillaPetFrameManaBar:IsVisible()")
!s.eval::<bool>("return PetFrameManaBar:IsVisible()")
.unwrap(),
"no power bar on the plate that has no rail for it"
);
@@ -223,12 +242,12 @@ fn the_attack_overlay_follows_its_own_two_events() {
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrameAttackMode:IsVisible()")
.eval::<bool>("return PetFrameAttackMode:IsVisible()")
.unwrap());
s.fire_event("PET_ATTACK_START", vec![]);
assert!(s
.eval::<bool>("return BenillaPetFrameAttackMode:IsVisible()")
.eval::<bool>("return PetFrameAttackMode:IsVisible()")
.unwrap());
// The ref's OnUpdate ramps the overlay's tint alpha down from 1 on the opening (sign −1) leg.
@@ -250,7 +269,7 @@ fn the_attack_overlay_follows_its_own_two_events() {
s.fire_event("PET_ATTACK_STOP", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameAttackMode:IsVisible()")
.eval::<bool>("return PetFrameAttackMode:IsVisible()")
.unwrap());
}
@@ -271,13 +290,13 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
let ok: bool = s
.eval(
r#"
return BenillaPetFrameDebuff1:IsVisible()
and BenillaPetFrameDebuff2:IsVisible()
and not BenillaPetFrameDebuff3:IsVisible()
and not BenillaPetFrameDebuff4:IsVisible()
and not BenillaPetFrameDebuff1Count:IsVisible()
and BenillaPetFrameDebuff2Count:IsVisible()
and BenillaPetFrameDebuff2Count:GetText() == "3"
return PetFrameDebuff1:IsVisible()
and PetFrameDebuff2:IsVisible()
and not PetFrameDebuff3:IsVisible()
and not PetFrameDebuff4:IsVisible()
and not PetFrameDebuff1Count:IsVisible()
and PetFrameDebuff2Count:IsVisible()
and PetFrameDebuff2Count:GetText() == "3"
"#,
)
.unwrap();
@@ -290,7 +309,7 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
s.set_auras("pet", Some(vec![]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrameDebuff1:IsVisible()")
.eval::<bool>("return PetFrameDebuff1:IsVisible()")
.unwrap());
}
@@ -302,11 +321,11 @@ fn left_clicking_the_pet_frame_targets_it() {
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
s.run("BenillaPetFrame_OnClick(\"LeftButton\")").unwrap();
s.run("PetFrame_OnClick(\"LeftButton\")").unwrap();
assert_eq!(s.take_target_requests(), vec!["pet".to_string()]);
// The right button is the deferred PET menu — it must do nothing at all, not target.
s.run("BenillaPetFrame_OnClick(\"RightButton\")").unwrap();
s.run("PetFrame_OnClick(\"RightButton\")").unwrap();
assert!(s.take_target_requests().is_empty());
}
@@ -361,13 +380,13 @@ fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
}),
);
s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]);
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(
s.eval::<bool>("return CursorHasItem()").unwrap(),
"the food is on the cursor"
);
s.run("BenillaPetFrame_OnClick(\"LeftButton\")")
s.run("PetFrame_OnClick(\"LeftButton\")")
.expect("the cursor-holds-an-item leg must not error");
assert_eq!(
s.take_drop_item_on_unit(),
@@ -430,19 +449,19 @@ fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
s.set_pet_stats(true, stats(true, Some(bucket), 125.0, rate));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
s.eval::<bool>("return PetFrameHappiness:IsVisible()")
.unwrap(),
"bucket {bucket} must show the icon"
);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
s.eval::<String>("return PetFrameHappiness.tooltip")
.unwrap(),
tip,
"bucket {bucket} took the wrong texcoord branch"
);
// The loyalty line is chosen by the SIGN of the rate — and is absent at exactly zero.
let line = s
.eval::<Option<String>>("return BenillaPetFrameHappiness.tooltipLoyalty")
.eval::<Option<String>>("return PetFrameHappiness.tooltipLoyalty")
.unwrap();
assert_eq!(
line.as_deref(),
@@ -456,7 +475,7 @@ fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
s.set_pet_stats(true, stats(false, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.eval::<bool>("return PetFrameHappiness:IsVisible()")
.unwrap());
}
@@ -472,7 +491,7 @@ fn happiness_bucket_zero_keeps_the_icon_showing() {
s.set_pet_stats(true, stats(true, Some(0), 100.0, 0.0));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
s.eval::<bool>("return PetFrameHappiness:IsVisible()")
.unwrap(),
"bucket 0 is a number, not the hide case"
);
@@ -481,7 +500,7 @@ fn happiness_bucket_zero_keeps_the_icon_showing() {
s.set_pet_stats(true, stats(true, None, 100.0, 0.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.eval::<bool>("return PetFrameHappiness:IsVisible()")
.unwrap());
}
@@ -495,7 +514,7 @@ fn unit_happiness_repaints_only_the_icon() {
s.set_pet_stats(true, stats(true, Some(1), 75.0, -10.0));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
s.eval::<String>("return PetFrameHappiness.tooltip")
.unwrap(),
"Unhappy"
);
@@ -504,7 +523,7 @@ fn unit_happiness_repaints_only_the_icon() {
s.set_pet_stats(true, stats(true, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
s.eval::<String>("return PetFrameHappiness.tooltip")
.unwrap(),
"Happy",
"UNIT_HAPPINESS must re-cut the icon on its own"
@@ -520,9 +539,9 @@ fn the_pet_art_paints_over_the_bars() {
let s = load_pet_frame();
let level: (i64, i64, i64) = s
.eval(
"return BenillaPetFrameTextureFrame:GetFrameLevel(), \
BenillaPetFrameHealthBar:GetFrameLevel(), \
BenillaPetFrameManaBar:GetFrameLevel()",
"return PetFrameTextureFrame:GetFrameLevel(), \
PetFrameHealthBar:GetFrameLevel(), \
PetFrameManaBar:GetFrameLevel()",
)
.unwrap();
let (art, health, mana) = level;
@@ -123,7 +123,7 @@ fn texts(s: &mut UiScript) -> Vec<String> {
/// has no rect at all, and `GetLeft()` answers nil rather than a stale number.
fn tab_left(s: &mut UiScript, tab: u32) -> f64 {
s.resolve();
s.eval::<f64>(&format!("return BenillaCharacterFrameTab{tab}:GetLeft()"))
s.eval::<f64>(&format!("return CharacterFrameTab{tab}:GetLeft()"))
.unwrap_or_else(|e| panic!("tab {tab} has no resolved rect: {e}"))
}
@@ -149,7 +149,7 @@ fn shipped_pet_page_loads_clean() {
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
// The page itself exists and starts down, like every other subframe.
assert!(!s
.eval::<bool>("return BenillaPetPaperDollFrame:IsVisible()")
.eval::<bool>("return PetPaperDollFrame:IsVisible()")
.unwrap());
}
@@ -162,11 +162,10 @@ fn shipped_pet_page_loads_clean() {
fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
let mut s = load_pet_page();
// The rects only exist while something lays the window out.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(
!s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
!s.eval::<bool>("return CharacterFrameTab2:IsVisible()")
.unwrap(),
"no pet: the Pet tab is down"
);
@@ -174,7 +173,7 @@ fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
give_pet(&mut s);
assert!(
s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
s.eval::<bool>("return CharacterFrameTab2:IsVisible()")
.unwrap(),
"a pet raises the Pet tab"
);
@@ -191,7 +190,7 @@ fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
take_pet(&mut s);
assert!(
!s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
!s.eval::<bool>("return CharacterFrameTab2:IsVisible()")
.unwrap(),
"the pet leaving lowers the tab again"
);
@@ -205,22 +204,17 @@ fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
#[test]
fn asking_for_the_pet_page_without_a_pet_does_nothing() {
let s = load_pet_page();
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(
!s.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap(),
"no pet: the window stays shut"
);
// …and with the window already open on the character page, the refusal leaves it open.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaPaperDollFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return PaperDollFrame:IsVisible()").unwrap(),
"the refusal must not switch pages or close the window"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
@@ -233,10 +227,9 @@ fn asking_for_the_pet_page_without_a_pet_does_nothing() {
fn the_page_reads_the_pets_own_snapshot() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(s
.eval::<bool>("return BenillaPetPaperDollFrame:IsVisible()")
.eval::<bool>("return PetPaperDollFrame:IsVisible()")
.unwrap());
let drawn = texts(&mut s);
@@ -262,25 +255,19 @@ fn the_page_borrows_the_windows_name_line_and_gives_it_back() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(!s
.eval::<bool>("return BenillaCharacterNameText:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaPetNameText:IsVisible()")
.eval::<bool>("return CharacterNameText:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return PetNameText:IsVisible()").unwrap());
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaCharacterNameText:IsVisible()")
s.eval::<bool>("return CharacterNameText:IsVisible()")
.unwrap(),
"the player's name line comes back"
);
assert!(!s
.eval::<bool>("return BenillaPetNameText:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return PetNameText:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
@@ -291,16 +278,12 @@ fn the_page_borrows_the_windows_name_line_and_gives_it_back() {
fn the_pet_leaving_closes_the_page_under_it() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(s
.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap());
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
take_pet(&mut s);
assert!(
!s.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap(),
"the pet's page cannot outlive the pet"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
@@ -316,21 +299,20 @@ fn a_minion_gets_the_page_without_the_hunter_furniture() {
s.set_pet_combat_stats(Some(pet_combat_stats()));
s.fire_event("PET_BAR_UPDATE", vec![]);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
s.eval::<bool>("return CharacterFrameTab2:IsVisible()")
.unwrap(),
"a minion still gets the tab"
);
assert!(!s
.eval::<bool>("return BenillaPetPaperDollPetInfo:IsVisible()")
.eval::<bool>("return PetPaperDollPetInfo:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaPetTrainingPointText:IsVisible()")
.eval::<bool>("return PetTrainingPointText:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaPetTrainingPointLabel:IsVisible()")
.eval::<bool>("return PetTrainingPointLabel:IsVisible()")
.unwrap());
// …and the stat rows still read the minion's own numbers.
assert!(
@@ -371,8 +353,7 @@ fn the_level_line_names_the_family_and_is_blank_without_one() {
},
);
s.fire_event("PET_BAR_UPDATE", vec![]);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
let drawn = texts(&mut s);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
drawn
@@ -405,10 +386,9 @@ fn the_level_line_names_the_family_and_is_blank_without_one() {
fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaPetPaperDollPetInfo:IsVisible()")
s.eval::<bool>("return PetPaperDollPetInfo:IsVisible()")
.unwrap(),
"the diet icon is shown for a hunter pet"
);
@@ -459,7 +439,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
);
s.run(
"local f = BenillaPetPaperDollPetInfo \
"local f = PetPaperDollPetInfo \
f:GetScript(\"OnEnter\")(f)",
)
.unwrap();
+52 -62
View File
@@ -59,8 +59,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return QuestFrame:IsVisible()").unwrap(),
"the questgiver window opened onto the left slot"
);
assert_eq!(
@@ -73,8 +72,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
s.fire_event("QUEST_FINISHED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return QuestFrame:IsVisible()").unwrap(),
"QUEST_FINISHED hid the questgiver window"
);
assert_eq!(
@@ -100,10 +98,10 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
load_xml(&s, "QuestFrame.xml");
let panels = [
"BenillaQuestGreetingPanel",
"BenillaQuestDetailPanel",
"BenillaQuestProgressPanel",
"BenillaQuestRewardPanel",
"QuestGreetingPanel",
"QuestDetailPanel",
"QuestProgressPanel",
"QuestRewardPanel",
];
let is_shown =
|s: &UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
@@ -122,12 +120,12 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "BenillaQuestGreetingPanel",
name == "QuestGreetingPanel",
"only the greeting panel is shown after QUEST_GREETING"
);
}
assert_eq!(
s.eval::<String>("return BenillaQuestNpcNameText:GetText()")
s.eval::<String>("return QuestNpcNameText:GetText()")
.unwrap(),
"Deputy Willem"
);
@@ -148,7 +146,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "BenillaQuestDetailPanel",
name == "QuestDetailPanel",
"only the detail panel is shown after QUEST_DETAIL"
);
}
@@ -160,7 +158,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
/// The reward/choice grid, ported from the ref's shared `QuestFrameItems_Update` (the SAME idiom the
/// quest log's own reward rows already prove out) — now on REAL per-panel widgets
/// (`BenillaQuestDetailChoice*`/`BenillaQuestDetailReward*`), exercised on the Detail panel with the
/// (`QuestDetailChoice*`/`QuestDetailReward*`), exercised on the Detail panel with the
/// fixture shape the task's capture drives: 2 choices + 1 fixed reward + money.
#[test]
fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
@@ -198,56 +196,55 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestDetailChoice1:IsShown()")
.eval::<bool>("return QuestDetailChoice1:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestDetailChoice2:IsShown()")
.eval::<bool>("return QuestDetailChoice2:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestDetailChoice3:IsShown()")
.eval::<bool>("return QuestDetailChoice3:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestDetailChoice1Name:GetText()")
s.eval::<String>("return QuestDetailChoice1Name:GetText()")
.unwrap(),
"Worn Sword"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailItemChooseText:GetText()")
s.eval::<String>("return QuestDetailItemChooseText:GetText()")
.unwrap(),
"You may choose one of these rewards:"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailItemReceiveText:GetText()")
s.eval::<String>("return QuestDetailItemReceiveText:GetText()")
.unwrap(),
"You will also receive:",
"choices present -> the ref's \"also\" wording (QuestFrame.lua:461-462)"
);
assert!(s
.eval::<bool>("return BenillaQuestDetailReward1:IsShown()")
.eval::<bool>("return QuestDetailReward1:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestDetailReward2:IsShown()")
.eval::<bool>("return QuestDetailReward2:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestDetailReward1Name:GetText()")
s.eval::<String>("return QuestDetailReward1Name:GetText()")
.unwrap(),
"Militia Hammer"
);
assert_eq!(
s.eval::<String>("return BenillaQuestDetailRewardTitleText:GetText()")
s.eval::<String>("return QuestDetailRewardTitleText:GetText()")
.unwrap(),
"Rewards"
);
// The detail panel's choice rows are informational only (ref: only the REWARD panel's rows
// select). They DO carry an OnClick now (`BenillaQuestItem_OnClick`, the ref's own
// select). They DO carry an OnClick now (`QuestItem_OnClick`, the ref's own
// QuestItemTemplate script — the ctrl/shift fork, decisions 1059/1060), but it has no select
// arm at all: an unmodified click must not raise and must not set an itemChoice.
s.run("BenillaQuestDetailChoice1:Click()").ok();
s.run("QuestDetailChoice1:Click()").ok();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
0,
"a detail-panel row never selects a reward"
);
@@ -288,26 +285,26 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
!s.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
.unwrap(),
"no row picked yet"
);
// Completing without a choice picked is a no-op (ref: QuestChooseRewardError guard).
s.run("BenillaQuestRewardCompleteButton:Click()").unwrap();
s.run("QuestRewardCompleteButton:Click()").unwrap();
assert!(
s.take_quest_actions().is_empty(),
"must pick a choice first"
);
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
s.run("QuestRewardChoice2:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
s.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
.unwrap(),
"picking a choice row shows the highlight"
);
s.run("BenillaQuestRewardCompleteButton:Click()").unwrap();
s.run("QuestRewardCompleteButton:Click()").unwrap();
assert_eq!(
s.take_quest_actions(),
vec![benilla_ui::script::QuestAction::Reward(1)],
@@ -337,15 +334,12 @@ fn greeting_goodbye_button_closes_the_window() {
vec![ScriptValue::Str("Deputy Willem".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return QuestFrame:IsVisible()").unwrap());
s.run("BenillaQuestGreetingGoodbyeButton:Click()").unwrap();
s.run("QuestGreetingGoodbyeButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaQuestFrame:IsVisible()")
.unwrap(),
!s.eval::<bool>("return QuestFrame:IsVisible()").unwrap(),
"Goodbye closes the questgiver window"
);
}
@@ -375,10 +369,10 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsShown()")
.eval::<bool>("return QuestAcceptButton:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestDeclineButton:IsShown()")
.eval::<bool>("return QuestDeclineButton:IsShown()")
.unwrap());
s.resolve();
@@ -434,10 +428,10 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
"instant text keeps the writing sound: one scratch on the wake tick"
);
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.unwrap());
assert_eq!(
s.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
.unwrap(),
1.0,
"the block snaps straight to opaque — no QUESTINFO_FADE_IN ramp in instant mode"
@@ -493,7 +487,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
"the write-on scratches the WriteQuest quill each tick"
);
assert!(!s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.unwrap());
// Half a second more runs the 13-char gradient off (40 chars/s): Accept wakes and the block
@@ -501,10 +495,10 @@ fn write_on_still_fades_when_instant_text_is_off() {
s.tick(0.5);
assert!(s.errors().is_empty(), "tick errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestAcceptButton:IsEnabled()")
.eval::<bool>("return QuestAcceptButton:IsEnabled()")
.unwrap());
let alpha = s
.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
.unwrap();
assert!(
alpha < 1.0,
@@ -512,7 +506,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
);
s.tick(1.1);
assert_eq!(
s.eval::<f32>("return BenillaQuestDetailTextAlphaFrame:GetAlpha()")
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
.unwrap(),
1.0,
"the fade completes at opaque"
@@ -544,7 +538,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return getglobal('BenillaQuestNpcNameText'):GetText() or ''")
s.eval::<String>("return getglobal('QuestNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the panel-open event's arg1 lands in the title bar"
@@ -566,14 +560,13 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
);
// The late-name path: open with an empty name, the refresh brings it.
s.run("getglobal('BenillaQuestNpcNameText'):SetText('')")
.unwrap();
s.run("getglobal('QuestNpcNameText'):SetText('')").unwrap();
s.fire_event(
"QUEST_ITEM_UPDATE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert_eq!(
s.eval::<String>("return getglobal('BenillaQuestNpcNameText'):GetText() or ''")
s.eval::<String>("return getglobal('QuestNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the QUEST_ITEM_UPDATE refresh also carries the name"
@@ -633,7 +626,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"local b = getglobal('BenillaQuestTitleButton{i}')\n\
"local b = getglobal('QuestTitleButton{i}')\n\
return b:GetTop(), b:GetBottom(), b:GetFontString():GetStringHeight()"
))
.unwrap()
@@ -715,15 +708,14 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
// arm). Row 2 is picked here so the modified clicks below — aimed at row 1 — would visibly
// move it if they leaked into the select arm.
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
s.run("QuestRewardChoice2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
2,
"a plain click still selects the reward choice"
);
assert!(s
.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
.unwrap());
assert!(
s.take_dressup_intents().is_empty(),
@@ -733,7 +725,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// SHIFT + chat open → the row's full escaped link, and the selection is untouched.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.run("QuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -742,16 +734,15 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"shift-click posted choice 1's link"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
2,
"the shift arm returns — it must NOT also select the clicked choice"
);
// A FIXED reward row (plain BenillaQuestItemTemplate → BenillaQuestItem_OnClick) posts too.
// A FIXED reward row (plain QuestItemTemplate → QuestItem_OnClick) posts too.
s.run("ChatFrameEditBox:SetText(\"\")").unwrap();
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardReward1:Click()").unwrap();
s.run("QuestRewardReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -762,7 +753,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// CTRL → the dressing room wearing the clicked reward, and STILL no reselect. The intent pair
// is ordered: Dress (the room was closed, so it re-dresses in the player's own gear) then TryOn.
s.set_modifiers(false, true, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.run("QuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
@@ -773,8 +764,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"ctrl-click opened the room wearing choice 1"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
2,
"the ctrl arm returns — it must NOT also select the clicked choice"
);
@@ -124,16 +124,12 @@ fn shipped_questlog_frame_drives_end_to_end() {
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
);
assert!(!s
.eval::<bool>("return BenillaQuestLogFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return QuestLogFrame:IsVisible()").unwrap());
// ToggleQuestLog() (the 'L' binding's entry point) opens it through ShowUIPanel.
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaQuestLogFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return QuestLogFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igQuestLogOpen".into())],
@@ -143,35 +139,34 @@ fn shipped_questlog_frame_drives_end_to_end() {
// Row 1 carries entry 1's title (indented), the count line reads "Quests: |cffffffff8/20|r", and nothing having been
// selected, the first non-header entry auto-selects (pin §2's SetFirstValidSelection).
assert!(s
.eval::<String>("return BenillaQuestLogTitle1Text:GetText()")
.eval::<String>("return QuestLogTitle1Text:GetText()")
.unwrap()
.contains("Quest 1"));
assert_eq!(
s.eval::<String>("return BenillaQuestLogCount:GetText()")
.unwrap(),
s.eval::<String>("return QuestLogCount:GetText()").unwrap(),
"Quests: |cffffffff8/20|r"
);
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
// The detail pane carries the pushed title + objective text.
assert_eq!(
s.eval::<String>("return BenillaQuestLogQuestTitle:GetText()")
s.eval::<String>("return QuestLogQuestTitle:GetText()")
.unwrap(),
"Quest 1"
);
assert_eq!(
s.eval::<String>("return BenillaQuestLogObjective1:GetText()")
s.eval::<String>("return QuestLogObjective1:GetText()")
.unwrap(),
"Kobold Vermin slain: 3/10"
);
// The reward row (QuestFrame.xml's item-row pattern, reused) + its money.
assert_eq!(
s.eval::<String>("return BenillaQuestLogReward1Name:GetText()")
s.eval::<String>("return QuestLogReward1Name:GetText()")
.unwrap(),
"Militia Hammer"
);
assert_ne!(
s.eval::<String>("return BenillaQuestLogRewardTitleText:GetText()")
s.eval::<String>("return QuestLogRewardTitleText:GetText()")
.unwrap(),
"",
"the Rewards header shows once there's a reward to show"
@@ -179,25 +174,23 @@ fn shipped_questlog_frame_drives_end_to_end() {
// Lua-managed Description header (no longer a static `text=` — QuestLogFrame.xml's header
// comment on why it used to float over the empty-log parchment).
assert_eq!(
s.eval::<String>("return BenillaQuestLogDescriptionTitle:GetText()")
s.eval::<String>("return QuestLogDescriptionTitle:GetText()")
.unwrap(),
"Description"
);
// No choices in this fixture: "You will receive:" (REWARD_ITEMS_ONLY), not "...also...", and
// the choose-text/choice rows stay blank/hidden (QuestFrame.lua:454-473).
assert_eq!(
s.eval::<String>("return BenillaQuestLogItemReceiveText:GetText()")
s.eval::<String>("return QuestLogItemReceiveText:GetText()")
.unwrap(),
"You will receive:"
);
assert_eq!(
s.eval::<String>("return BenillaQuestLogItemChooseText:GetText()")
s.eval::<String>("return QuestLogItemChooseText:GetText()")
.unwrap(),
""
);
assert!(!s
.eval::<bool>("return BenillaQuestLogChoice1:IsShown()")
.unwrap());
assert!(!s.eval::<bool>("return QuestLogChoice1:IsShown()").unwrap());
// Wheel DOWN over an actual list ROW (delta -1) advances the faux-scroll offset by one: row 1 now
// shows entry 2's title. Aiming over a row (not the empty margin) is the case that matters — a spin
@@ -218,7 +211,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
s.mouse_wheel(wx, wy, -1.0);
assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors());
assert!(
s.eval::<String>("return BenillaQuestLogTitle1Text:GetText()")
s.eval::<String>("return QuestLogTitle1Text:GetText()")
.unwrap()
.contains("Quest 2"),
"wheel-down scrolled the list by one row"
@@ -275,9 +268,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
// ToggleQuestLog() again closes it through HideUIPanel, playing the close kit.
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!s
.eval::<bool>("return BenillaQuestLogFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return QuestLogFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igQuestLogClose".into())],
@@ -289,7 +280,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
/// QuestLogFrame.lua:469-505 — driven through the `set_modifiers` mirror the cursor arc landed),
/// toggling both the row's watch checkbox (a `SetTexture` toggle — bare Texture regions have no
/// Show/Hide, same limit as the FontString regions elsewhere in this window) and the
/// always-on-screen tracker HUD (`BenillaQuestWatchFrame`), which the log window itself doesn't
/// always-on-screen tracker HUD (`QuestWatchFrame`), which the log window itself doesn't
/// own or gate on visibility.
#[test]
fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
@@ -306,7 +297,7 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
// Nothing watched yet — the tracker HUD is hidden and row 1 shows no checkbox texture.
assert!(!s
.eval::<bool>("return BenillaQuestWatchFrame:IsVisible()")
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
let checkbox_shown = |s: &mut UiScript| {
s.resolve();
@@ -369,15 +360,15 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
// watched quest's title on line 1 and its objective on line 2, ref-colored (title dark-gold —
// objectives incomplete; objective 0.8-gray — unfinished).
assert!(s
.eval::<bool>("return BenillaQuestWatchFrame:IsVisible()")
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestWatchLine1:GetText()")
s.eval::<String>("return QuestWatchLine1:GetText()")
.unwrap(),
"Quest 1"
);
assert_eq!(
s.eval::<String>("return BenillaQuestWatchLine2:GetText()")
s.eval::<String>("return QuestWatchLine2:GetText()")
.unwrap(),
" - Kobold Vermin slain: 3/10"
);
@@ -396,7 +387,7 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert!(!checkbox_shown(&mut s), "unwatching clears the checkbox");
assert!(!s
.eval::<bool>("return BenillaQuestWatchFrame:IsVisible()")
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
}
@@ -438,7 +429,7 @@ fn watch_guards_no_op_without_erroring() {
);
assert!(!s.eval::<bool>("return IsQuestWatched(2)").unwrap());
assert_eq!(
s.eval::<String>("return BenillaErrorsFrameLine1:GetText()")
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"This quest has no objectives to track",
"the ref's QUEST_WATCH_NO_OBJECTIVES red line surfaces"
@@ -473,7 +464,7 @@ fn watch_guards_no_op_without_erroring() {
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert_eq!(s.eval::<i64>("return GetNumQuestWatches()").unwrap(), 5);
assert_eq!(
s.eval::<String>("return BenillaErrorsFrameLine1:GetText()")
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"You may only watch 5 quests at a time",
"the ref's QUEST_WATCH_TOO_MANY red line surfaces"
@@ -504,7 +495,7 @@ fn progress_auto_watches_for_five_minutes() {
assert!(s.errors().is_empty(), "auto-watch errors: {:?}", s.errors());
assert!(s.eval::<bool>("return IsQuestWatched(1)").unwrap());
assert!(s
.eval::<bool>("return BenillaQuestWatchFrame:IsVisible()")
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BENILLA_QUEST_WATCH_TIMERS[\"Quest 1\"] ~= nil")
@@ -528,7 +519,7 @@ fn progress_auto_watches_for_five_minutes() {
"expired after 300 s without progress"
);
assert!(!s
.eval::<bool>("return BenillaQuestWatchFrame:IsVisible()")
.eval::<bool>("return QuestWatchFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "expiry errors: {:?}", s.errors());
}
@@ -589,27 +580,24 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
// A bare FontString region has no Show/Hide/IsVisible in this engine (only Frames/Buttons do —
// `region.rs`'s method table) — the empty-state message is a toggled SetText, read back here.
assert_eq!(
s.eval::<String>("return BenillaQuestLogEmptyText:GetText()")
s.eval::<String>("return QuestLogEmptyText:GetText()")
.unwrap(),
"Your quest log is empty."
);
assert!(!s.eval::<bool>("return QuestLogTitle1:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestLogTitle1:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestLogAbandonButton:IsEnabled()")
.eval::<bool>("return QuestLogAbandonButton:IsEnabled()")
.unwrap());
// The Description header is now Lua-managed (BenillaQuestLogDetail_Clear blanks it) rather than
// a static `text=` — it used to float "Description" over the empty-log parchment with no
// selection (this task's fix; QuestLogFrame.xml's header comment on the FontString).
assert_eq!(
s.eval::<String>("return BenillaQuestLogDescriptionTitle:GetText()")
s.eval::<String>("return QuestLogDescriptionTitle:GetText()")
.unwrap(),
""
);
assert_eq!(
s.eval::<String>("return BenillaQuestLogCount:GetText()")
.unwrap(),
s.eval::<String>("return QuestLogCount:GetText()").unwrap(),
"Quests: |cffffffff0/20|r"
);
}
@@ -668,27 +656,21 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Both choice rows show, the rest of the 6-row cap stays hidden.
assert!(s
.eval::<bool>("return BenillaQuestLogChoice1:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaQuestLogChoice2:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestLogChoice3:IsShown()")
.unwrap());
assert!(s.eval::<bool>("return QuestLogChoice1:IsShown()").unwrap());
assert!(s.eval::<bool>("return QuestLogChoice2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogChoice3:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestLogChoice1Name:GetText()")
s.eval::<String>("return QuestLogChoice1Name:GetText()")
.unwrap(),
"Worn Sword"
);
assert_eq!(
s.eval::<String>("return BenillaQuestLogChoice2Name:GetText()")
s.eval::<String>("return QuestLogChoice2Name:GetText()")
.unwrap(),
"Worn Mace"
);
assert_eq!(
s.eval::<String>("return BenillaQuestLogItemChooseText:GetText()")
s.eval::<String>("return QuestLogItemChooseText:GetText()")
.unwrap(),
"You may choose one of these rewards:"
);
@@ -699,33 +681,29 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
// Frame/Button — `region.rs` has no GetPoint), so the anchor CHAIN itself is verified via the
// mandatory capture-loop screenshot (this task's report), not introspected here.
assert_eq!(
s.eval::<String>("return BenillaQuestLogItemReceiveText:GetText()")
s.eval::<String>("return QuestLogItemReceiveText:GetText()")
.unwrap(),
"You will also receive:"
);
// One fixed reward shows, chained under the receive text (its own SetPoint target).
assert!(s
.eval::<bool>("return BenillaQuestLogReward1:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaQuestLogReward2:IsShown()")
.unwrap());
assert!(s.eval::<bool>("return QuestLogReward1:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogReward2:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return BenillaQuestLogReward1Name:GetText()")
s.eval::<String>("return QuestLogReward1Name:GetText()")
.unwrap(),
"Militia Hammer"
);
assert_eq!(
s.eval::<String>("return BenillaQuestLogRewardTitleText:GetText()")
s.eval::<String>("return QuestLogRewardTitleText:GetText()")
.unwrap(),
"Rewards"
);
}
/// One entry whose selected detail overflows the 261px-tall `BenillaQuestLogDetailScroll`: 10
/// objective lines (each a FIXED 12px slot, `BenillaQuestLogObjective1`'s own XML comment) plus a
/// One entry whose selected detail overflows the 261px-tall `QuestLogDetailScroll`: 10
/// objective lines (each a FIXED 12px slot, `QuestLogObjective1`'s own XML comment) plus a
/// long description and a reward row push the child's summed height (`BenillaQuestLogDetail_
/// ResizeChild`) well past the pane — load-bearing for the scroll/clip tests below even
/// engine-only (no font atlas, so every auto-height FontString measures 0 here — this fixture
@@ -770,8 +748,8 @@ fn overflowing_entry() -> QuestLogState {
}
/// The ScrollFrame clip (decision 0112 §4): every quad in the moved detail chain's subtree —
/// `BenillaQuestLogDetailChild` and its descendants (here, a reward row's icon-slot texture) —
/// carries `BenillaQuestLogDetailScroll`'s own resolved rect as its clip, so content that overflows
/// `QuestLogDetailChild` and its descendants (here, a reward row's icon-slot texture) —
/// carries `QuestLogDetailScroll`'s own resolved rect as its clip, so content that overflows
/// the pane never draws past its bottom edge; a sibling entirely outside the scroll child (the
/// window's own book-icon chrome) stays unclipped, mirroring `scrollframe.rs`'s own unit test for
/// the mechanism, just through the shipped window instead of a synthetic fixture.
@@ -828,7 +806,7 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
/// Wheeling over the detail pane changes `GetVerticalScroll()` — the ScrollFrame is
/// mouse-wheel-interactive by construction (decision 0112, no wheel-catcher needed), and is
/// declared after `BenillaQuestLogWheelCatcher` in this window's `<Frames>` so it out-ranks the
/// declared after `QuestLogWheelCatcher` in this window's `<Frames>` so it out-ranks the
/// catcher within its own rect (this window's own XML comment on hit-test z-order).
#[test]
fn wheel_over_the_detail_pane_changes_vertical_scroll() {
@@ -850,14 +828,14 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
let (x, y) = (scroll_rect.left + 10.0, scroll_rect.top - 10.0);
assert_eq!(
s.eval::<f32>("return BenillaQuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
.unwrap(),
0.0
);
s.mouse_wheel(x, y, -1.0); // WoW convention: -1 = down
assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors());
let after = s
.eval::<f32>("return BenillaQuestLogDetailScroll:GetVerticalScroll()")
.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
.unwrap();
assert!(
after > 0.0,
@@ -883,10 +861,9 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve(); // GetVerticalScrollRange (SetVerticalScroll's clamp) reads resolved rects.
s.run("BenillaQuestLogDetailScroll:SetVerticalScroll(10)")
.unwrap();
s.run("QuestLogDetailScroll:SetVerticalScroll(10)").unwrap();
assert_eq!(
s.eval::<f32>("return BenillaQuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
.unwrap(),
10.0
);
@@ -895,18 +872,18 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(s.errors().is_empty(), "refresh errors: {:?}", s.errors());
assert_eq!(
s.eval::<f32>("return BenillaQuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
.unwrap(),
10.0,
"QUEST_LOG_UPDATE's data-refresh path must not yank the scroll position (pin §5)"
);
// A manual reselect (the row-click path — no doNotScroll) DOES reset it.
s.run(r#"BenillaQuestLogTitle_OnClick(BenillaQuestLogTitle1, "LeftButton")"#)
s.run(r#"BenillaQuestLogTitle_OnClick(QuestLogTitle1, "LeftButton")"#)
.unwrap();
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(
s.eval::<f32>("return BenillaQuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
.unwrap(),
0.0,
"a manual reselect snaps the detail pane's scroll back to the top"
@@ -1063,14 +1040,13 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestLogReward1:IsShown()")
.unwrap(),
s.eval::<bool>("return QuestLogReward1:IsShown()").unwrap(),
"the fixture's one fixed reward row is laid out"
);
let _ = s.take_dressup_intents();
// A PLAIN click is inert: no room, no chat text, no error (the row has no select arm).
s.run("BenillaQuestLogReward1:Click()").unwrap();
s.run("QuestLogReward1:Click()").unwrap();
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
@@ -1086,7 +1062,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
// SHIFT + chat open → the reward's full escaped link (ref l.547-550).
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestLogReward1:Click()").unwrap();
s.run("QuestLogReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -1101,7 +1077,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
// CTRL → the room wearing the reward (ref l.543-546): re-dress first, then try it on.
s.set_modifiers(false, true, false);
s.run("BenillaQuestLogReward1:Click()").unwrap();
s.run("QuestLogReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
@@ -341,7 +341,7 @@ fn the_boot_phase_materializes_no_frames() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/Fonts.xml");
let text = std::fs::read_to_string(&dir).expect("Fonts.xml");
let doc = benilla_ui::framexml::parse(&text).expect("parses");
let provider = |_: &str| -> Option<String> { None };
let provider = |_: &str| -> Option<Vec<u8>> { None };
let report = benilla_ui::loader::load(&s, &doc, &provider);
assert_eq!(
report.frames, 0,
@@ -422,3 +422,76 @@ fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
s.errors()
);
}
/// **Every `<Font name=…>` the shipped manifest declares is a real Lua global** — a `Font` object
/// answering the FontInstance getters, not a bare style record with no name.
///
/// The per-fragment test in `benilla-ui` proves the mechanism on a two-font document; this proves
/// it over the 54 fonts our real `Fonts.xml` (and the windows after it) actually declare, in
/// manifest order, which is the only place a name collision with a *frame* of the same name — the
/// one way publication can silently not happen, since `publish_global` never overwrites — could
/// show up.
///
/// The named spot-checks are the corpus's four most-wanted font objects: `GameFontNormal` (98
/// addons), `GameTooltipText` (89), `GameFontHighlightSmall` (69), `GameTooltipHeaderText` (the
/// `Tablet-2.0.lua:289` header-size probe, 268 read sites).
#[test]
fn every_shipped_font_object_is_published_as_a_lua_global() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "loader errors: {failures:?}");
// Collect the declared names straight out of the shipped XML, so the sweep cannot go stale.
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut names: Vec<String> = Vec::new();
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "xml") {
let text = std::fs::read_to_string(&path).expect("read");
for chunk in text.split("<Font ").skip(1) {
if let Some(rest) = chunk.split_once("name=\"") {
if let Some((name, _)) = rest.1.split_once('"') {
names.push(name.to_string());
}
}
}
}
}
names.sort();
names.dedup();
assert!(
names.len() >= 50,
"only {} <Font name=> declarations found — the sweep broke",
names.len()
);
let mut unpublished: Vec<&str> = Vec::new();
for name in &names {
match s.eval::<String>(&format!("return {name}:GetObjectType()")) {
Ok(t) if t == "Font" => {}
_ => unpublished.push(name),
}
}
assert!(
unpublished.is_empty(),
"declared <Font name=> that is not a Font global: {unpublished:?}"
);
// The four the corpus wants most actually carry a face and a size, not just a name.
for name in [
"GameFontNormal",
"GameTooltipText",
"GameFontHighlightSmall",
"GameTooltipHeaderText",
] {
let (face, height) = s
.eval::<(String, f32)>(&format!("return {name}:GetFont()"))
.unwrap_or_else(|e| panic!("{name}:GetFont() — {e}"));
assert!(
face.to_ascii_uppercase().ends_with(".TTF"),
"{name}: {face}"
);
assert!(height > 0.0, "{name}: height {height}");
}
}
@@ -81,7 +81,7 @@ fn shown_skills_page() -> UiScript {
skill(415, "Cloth", 1, 1, false, (8, "Armor Proficiencies", 6)),
],
});
s.run(r#"ToggleCharacter("BenillaSkillFrame")"#).unwrap();
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
s.resolve();
s
}
@@ -90,13 +90,11 @@ fn shown_skills_page() -> UiScript {
fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
let text = s
.eval::<String>(&format!(
"return BenillaSkillRankFrame{i}SkillRank:GetText() or \"\""
"return SkillRankFrame{i}SkillRank:GetText() or \"\""
))
.unwrap();
let c = s
.eval::<(f32, f32, f32, f32)>(&format!(
"return BenillaSkillRankFrame{i}:GetStatusBarColor()"
))
.eval::<(f32, f32, f32, f32)>(&format!("return SkillRankFrame{i}:GetStatusBarColor()"))
.unwrap();
(text, [c.0, c.1, c.2, c.3])
}
@@ -106,7 +104,7 @@ fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
let c = s
.eval::<(f32, f32, f32, f32)>(&format!(
"return BenillaSkillRankFrame{i}Background:GetVertexColor()"
"return SkillRankFrame{i}Background:GetVertexColor()"
))
.unwrap();
[c.0, c.1, c.2, c.3]
@@ -116,8 +114,7 @@ fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
fn a_single_rank_line_paints_gray_with_no_rank_text() {
let mut s = shown_skills_page();
assert!(
s.eval::<bool>("return BenillaSkillFrame:IsVisible()")
.unwrap(),
s.eval::<bool>("return SkillFrame:IsVisible()").unwrap(),
"the Skills page is up"
);
@@ -184,13 +181,13 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
s.run("SetSelectedSkill(2) BenillaSkillFrame_Update()")
.unwrap();
assert_eq!(
s.eval::<String>("return BenillaSkillDetailBarSkillRank:GetText() or \"\"")
s.eval::<String>("return SkillDetailBarSkillRank:GetText() or \"\"")
.unwrap(),
"",
"the detail pane's own bar is a proficiency too"
);
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return BenillaSkillDetailBarBackground:GetVertexColor()")
s.eval::<(f32, f32, f32, f32)>("return SkillDetailBarBackground:GetVertexColor()")
.unwrap(),
(1.0, 1.0, 1.0, 0.5),
"including its trough (the shared PaintBar; ref SkillFrame.lua:376)"
@@ -142,44 +142,39 @@ fn shipped_spellbook_drives_end_to_end() {
s.set_spellbook(book());
assert!(!s
.eval::<bool>("return BenillaSpellBookFrame:IsVisible()")
.unwrap());
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 BenillaSpellBookFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return SpellBookFrame:IsVisible()").unwrap());
// Tab 1 ("Fire", selected by default — SpellBookFrame_OnLoad's own SkillLineTab_OnClick(1)):
// book id 1 (BenillaSpellButton1, id="1") shows Fireball; book id 2 (BenillaSpellButton3,
// 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. BenillaSpellButton2 (id="7", book id 7) is
// 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 BenillaSpellButton1SpellName:GetText()")
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Fireball"
);
assert_eq!(
s.eval::<String>("return BenillaSpellButton1SubSpellName:GetText()")
s.eval::<String>("return SpellButton1SubSpellName:GetText()")
.unwrap(),
"Rank 1"
);
assert_eq!(
s.eval::<String>("return BenillaSpellButton3SpellName:GetText()")
s.eval::<String>("return SpellButton3SpellName:GetText()")
.unwrap(),
"Fire Blast"
);
assert!(
!s.eval::<bool>("return BenillaSpellButton2:IsEnabled()")
.unwrap(),
!s.eval::<bool>("return SpellButton2:IsEnabled()").unwrap(),
"book id 7 is past the 2-spell Fire tab — disabled"
);
s.resolve();
let (x1, y1) = center(&s, "BenillaSpellButton1");
let (x1, y1) = center(&s, "SpellButton1");
// A plain click CASTS (drains the intent) — never picks up.
s.mouse_button(x1, y1, "LeftButton", true);
@@ -208,7 +203,7 @@ fn shipped_spellbook_drives_end_to_end() {
// (`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.
let (ax, ay) = center(&s, "BenillaActionButton1");
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());
@@ -248,7 +243,7 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
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("BenillaSpellButton1Cooldown") {
if s.quad_owner_name(q.target).as_deref() != Some("SpellButton1Cooldown") {
return None;
}
match q.content {
@@ -256,7 +251,7 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
_ => None,
}
});
let (fraction, flash) = sweep.expect("BenillaSpellButton1's Cooldown widget is showing");
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}"
@@ -271,13 +266,13 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
s.resolve();
let quads = s.extract();
assert!(
!quads.iter().any(|q| {
s.quad_owner_name(q.target).as_deref() == Some("BenillaSpellButton1Cooldown")
}),
!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("BenillaSpellButton1") {
if s.quad_owner_name(q.target).as_deref() != Some("SpellButton1") {
return None;
}
match &q.content {
@@ -316,7 +311,7 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
let mut slot5_paths: Vec<String> = Vec::new();
for eq in s.extract() {
if s.quad_owner_name(eq.target).as_deref() == Some("BenillaSpellButton5") {
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());
}
@@ -329,7 +324,7 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
);
// The reference passes SetChecked(0) — numeric coercion, not Lua truthiness.
assert!(!s
.eval::<bool>("return BenillaSpellButton5:GetChecked() and true or false")
.eval::<bool>("return SpellButton5:GetChecked() and true or false")
.unwrap());
}
@@ -354,8 +349,8 @@ fn set_checked_uses_blizzard_bool_coercion() {
] {
let got = s
.eval::<bool>(&format!(
"BenillaSpellButton1:SetChecked({arg}) \
return BenillaSpellButton1:GetChecked() and true or false"
"SpellButton1:SetChecked({arg}) \
return SpellButton1:GetChecked() and true or false"
))
.unwrap();
assert_eq!(got, want, "SetChecked({arg})");
@@ -420,48 +415,46 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaSpellBookFrameTabButton1:IsVisible()")
!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 BenillaSpellBookFrame.bookType == BOOKTYPE_SPELL")
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 BenillaSpellBookFrame:IsVisible()")
.unwrap());
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 BenillaSpellBookFrameTabButton1:IsVisible()")
.eval::<bool>("return SpellBookFrameTabButton1:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaSpellBookFrameTabButton2:GetText()")
s.eval::<String>("return SpellBookFrameTabButton2:GetText()")
.unwrap(),
"Pet",
"the label is PET_TYPE_<token>, not the token"
);
// ── Click the pet tab ─────────────────────────────────────────────────────────────────────
click(&mut s, "BenillaSpellBookFrameTabButton2", "LeftButton");
click(&mut s, "SpellBookFrameTabButton2", "LeftButton");
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaSpellBookFrame.bookType == BOOKTYPE_PET")
.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_PET")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaSpellBookTitleText:GetText()")
s.eval::<String>("return SpellBookTitleText:GetText()")
.unwrap(),
"Pet",
"the window title becomes the pet's, not SPELLBOOK"
);
assert!(
!s.eval::<bool>("return BenillaSpellBookSkillLineTab1:IsVisible()")
!s.eval::<bool>("return SpellBookSkillLineTab1:IsVisible()")
.unwrap(),
"the pet book has no skill lines — the whole strip hides (ref l.124)"
);
@@ -469,42 +462,42 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
// 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 BenillaSpellButton1SpellName:GetText()")
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Growl"
);
assert_eq!(
s.eval::<String>("return BenillaSpellButton3SpellName:GetText()")
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 BenillaSpellButton1AutoCastable:IsVisible()")
.eval::<bool>("return SpellButton1AutoCastable:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return BenillaSpellButton5AutoCastable:IsVisible()")
!s.eval::<bool>("return SpellButton5AutoCastable:IsVisible()")
.unwrap(),
"a passive is not autocastable"
);
// …and the sparkle follows the SECOND (is it on).
assert!(s
.eval::<bool>("return BenillaSpellButton1.autocasting == 1")
.eval::<bool>("return SpellButton1.autocasting == 1")
.unwrap());
assert!(s
.eval::<bool>("return BenillaSpellButton3.autocasting == nil")
.eval::<bool>("return SpellButton3.autocasting == nil")
.unwrap());
// ── The clicks ────────────────────────────────────────────────────────────────────────────
// Left: a pet cast, on the pet queue and NOT the player's.
click(&mut s, "BenillaSpellButton1", "LeftButton");
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, "BenillaSpellButton3", "RightButton");
click(&mut s, "SpellButton3", "RightButton");
assert!(
s.errors().is_empty(),
"right-click errors: {:?}",
@@ -518,21 +511,16 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
// ── 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 BenillaSpellBook_GetSpellID(1)")
.unwrap(),
1
);
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 BenillaSpellBook_GetSpellID(1)")
.unwrap(),
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, "BenillaSpellBookNextPageButton", "LeftButton");
click(&mut s, "SpellBookNextPageButton", "LeftButton");
assert!(s.errors().is_empty(), "page errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>(r#"return SPELLBOOK_PAGENUMBERS["pet"]"#)
@@ -541,7 +529,7 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
"DO NOT FIX: the ref writes SPELLBOOK_PAGENUMBERS[selectedSkillLine] on both books"
);
assert_eq!(
s.eval::<String>("return BenillaSpellButton1SpellName:GetText()")
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
"Growl",
"so the page does not turn"
@@ -552,11 +540,10 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
s.fire_event("SPELLS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "teardown errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaSpellBookFrame:IsVisible()")
.unwrap(),
!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 BenillaSpellBookFrame.bookType == BOOKTYPE_SPELL")
.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_SPELL")
.unwrap());
}
@@ -71,6 +71,10 @@ fn debuff(spell_id: u32, name: &str, count: u8, debuff_type: Option<&str>) -> Au
expiration_time: 0.0,
helpful: false,
cancelable: false,
// Only the PLAYER cache carries `untilCancelled`, and only `GetPlayerBuff` reads it
// (decision 0257 / `benilla::ui_aura`); a target's rows have no such record.
until_cancelled: false,
channeled: false,
}
}
@@ -85,6 +89,8 @@ fn buff(spell_id: u32, name: &str) -> AuraState {
expiration_time: 0.0,
helpful: true,
cancelable: false,
until_cancelled: false,
channeled: false,
}
}
@@ -120,45 +126,39 @@ fn a_hostile_target_draws_debuffs_first_with_tint_and_count() {
],
);
assert!(shown(&s, "BenillaTargetFrameDebuff1"), "first debuff shows");
assert!(shown(&s, "TargetFrameDebuff1"), "first debuff shows");
assert!(shown(&s, "TargetFrameDebuff2"), "second debuff shows");
assert!(
shown(&s, "BenillaTargetFrameDebuff2"),
"second debuff shows"
);
assert!(
!shown(&s, "BenillaTargetFrameDebuff3"),
!shown(&s, "TargetFrameDebuff3"),
"no third debuff — button hides"
);
assert!(shown(&s, "BenillaTargetFrameBuff1"), "the buff shows");
assert!(!shown(&s, "BenillaTargetFrameBuff2"), "no second buff");
assert!(shown(&s, "TargetFrameBuff1"), "the buff shows");
assert!(!shown(&s, "TargetFrameBuff2"), "no second buff");
// Hostile: the debuff row opens at the frame's BOTTOMLEFT (5,32); buffs seat under Debuff7
// (the not-shown target-of-target leg, ref l.329).
let (p, rel, rp, x, y) = anchor(&s, "BenillaTargetFrameDebuff1");
let (p, rel, rp, x, y) = anchor(&s, "TargetFrameDebuff1");
assert_eq!(
(p.as_str(), rel.as_str(), rp.as_str(), x, y),
("TOPLEFT", "BenillaTargetFrame", "BOTTOMLEFT", 5.0, 32.0),
("TOPLEFT", "TargetFrame", "BOTTOMLEFT", 5.0, 32.0),
"hostile: debuffs first"
);
let (_, rel, _, _, _) = anchor(&s, "BenillaTargetFrameBuff1");
assert_eq!(
rel, "BenillaTargetFrameDebuff7",
"hostile: buffs below row 2"
);
let (_, rel, _, _, _) = anchor(&s, "TargetFrameBuff1");
assert_eq!(rel, "TargetFrameDebuff7", "hostile: buffs below row 2");
// Under the wrap (2 < 6): full 21px icons, 23px borders.
assert_eq!(size(&s, "BenillaTargetFrameDebuff1"), (21.0, 21.0));
assert_eq!(size(&s, "BenillaTargetFrameDebuff1Border"), (23.0, 23.0));
assert_eq!(size(&s, "BenillaTargetFrameBuff1"), (21.0, 21.0));
assert_eq!(size(&s, "TargetFrameDebuff1"), (21.0, 21.0));
assert_eq!(size(&s, "TargetFrameDebuff1Border"), (23.0, 23.0));
assert_eq!(size(&s, "TargetFrameBuff1"), (21.0, 21.0));
// Stack count shows only above 1.
assert_eq!(
s.eval::<String>(r#"return tostring((BenillaTargetFrameDebuff2Count:GetText()) or "")"#)
s.eval::<String>(r#"return tostring((TargetFrameDebuff2Count:GetText()) or "")"#)
.unwrap(),
"3"
);
assert_eq!(
s.eval::<String>(r#"return tostring((BenillaTargetFrameDebuff1Count:GetText()) or "")"#)
s.eval::<String>(r#"return tostring((TargetFrameDebuff1Count:GetText()) or "")"#)
.unwrap(),
""
);
@@ -205,22 +205,16 @@ fn a_friendly_target_puts_the_buff_row_first() {
],
);
let (p, rel, rp, x, y) = anchor(&s, "BenillaTargetFrameBuff1");
let (p, rel, rp, x, y) = anchor(&s, "TargetFrameBuff1");
assert_eq!(
(p.as_str(), rel.as_str(), rp.as_str(), x, y),
("TOPLEFT", "BenillaTargetFrame", "BOTTOMLEFT", 5.0, 32.0),
("TOPLEFT", "TargetFrame", "BOTTOMLEFT", 5.0, 32.0),
"friendly: buffs first"
);
let (p, rel, rp, x, y) = anchor(&s, "BenillaTargetFrameDebuff1");
let (p, rel, rp, x, y) = anchor(&s, "TargetFrameDebuff1");
assert_eq!(
(p.as_str(), rel.as_str(), rp.as_str(), x, y),
(
"TOPLEFT",
"BenillaTargetFrameBuff1",
"BOTTOMLEFT",
0.0,
-2.0
),
("TOPLEFT", "TargetFrameBuff1", "BOTTOMLEFT", 0.0, -2.0),
"friendly: debuffs under the buff row"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -235,15 +229,15 @@ fn reaching_the_wrap_shrinks_the_first_row_to_17px() {
target(&mut s, 2, debuffs);
// 6 debuffs ≥ wrap (6): 17px icons, 19px borders — the reference resizes only the FIRST row.
assert_eq!(size(&s, "BenillaTargetFrameDebuff1"), (17.0, 17.0));
assert_eq!(size(&s, "BenillaTargetFrameDebuff1Border"), (19.0, 19.0));
assert_eq!(size(&s, "BenillaTargetFrameBuff1"), (17.0, 17.0));
assert_eq!(size(&s, "TargetFrameDebuff1"), (17.0, 17.0));
assert_eq!(size(&s, "TargetFrameDebuff1Border"), (19.0, 19.0));
assert_eq!(size(&s, "TargetFrameBuff1"), (17.0, 17.0));
// Dropping below the wrap grows them back — the feed re-fires UNIT_AURA on the change.
s.set_auras("target", Some(vec![debuff(1000, "D0", 1, None)]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
assert_eq!(size(&s, "BenillaTargetFrameDebuff1"), (21.0, 21.0));
assert_eq!(size(&s, "BenillaTargetFrameDebuff1Border"), (23.0, 23.0));
assert_eq!(size(&s, "TargetFrameDebuff1"), (21.0, 21.0));
assert_eq!(size(&s, "TargetFrameDebuff1Border"), (23.0, 23.0));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -251,25 +245,22 @@ fn reaching_the_wrap_shrinks_the_first_row_to_17px() {
fn clearing_the_list_or_the_target_hides_the_buttons() {
let mut s = harness();
target(&mut s, 2, vec![debuff(589, "Pain", 1, Some("Magic"))]);
assert!(shown(&s, "BenillaTargetFrameDebuff1"));
assert!(shown(&s, "TargetFrameDebuff1"));
// The last debuff expires: the feed pushes the emptied list and re-fires.
s.set_auras("target", Some(vec![]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
assert!(
!shown(&s, "BenillaTargetFrameDebuff1"),
!shown(&s, "TargetFrameDebuff1"),
"an emptied list hides the button"
);
// Deselect: the frame (and every child button) hides; the token clears without a UNIT_AURA.
target(&mut s, 2, vec![debuff(589, "Pain", 1, Some("Magic"))]);
assert!(shown(&s, "BenillaTargetFrameDebuff1"));
assert!(shown(&s, "TargetFrameDebuff1"));
s.set_unit("target", None);
s.set_auras("target", None);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(
!shown(&s, "BenillaTargetFrameDebuff1"),
"no target, no buttons"
);
assert!(!shown(&s, "TargetFrameDebuff1"), "no target, no buttons");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+13 -34
View File
@@ -71,9 +71,7 @@ fn menu() -> TaxiUiState {
fn shipped_taxi_frame_drives_end_to_end() {
let mut s = taxi_script();
assert!(!s
.eval::<bool>("return BenillaTaxiFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
s.set_taxi(Some(menu()));
s.fire_event(
@@ -82,33 +80,22 @@ fn shipped_taxi_frame_drives_end_to_end() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaTaxiFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return BenillaTaxiNameText:GetText()")
.unwrap(),
s.eval::<String>("return TaxiNameText:GetText()").unwrap(),
"Dungar Longdrink"
);
// The two pushed nodes show; the pool's remainder (slot 3 onward) stays hidden.
assert!(s
.eval::<bool>("return BenillaTaxiButton1:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaTaxiButton2:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaTaxiButton3:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaTaxiButton50:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return TaxiButton1:IsVisible()").unwrap());
assert!(s.eval::<bool>("return TaxiButton2:IsVisible()").unwrap());
assert!(!s.eval::<bool>("return TaxiButton3:IsVisible()").unwrap());
assert!(!s.eval::<bool>("return TaxiButton50:IsVisible()").unwrap());
// A click on node 2 (Sentinel Hill) drains through TakeTaxiNode.
s.resolve();
let (cx, cy) = s
.eval::<(f32, f32)>("return BenillaTaxiButton2:GetCenter()")
.eval::<(f32, f32)>("return TaxiButton2:GetCenter()")
.unwrap();
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
@@ -118,9 +105,7 @@ fn shipped_taxi_frame_drives_end_to_end() {
// TAXIMAP_CLOSED hides the window.
s.set_taxi(None);
s.fire_event("TAXIMAP_CLOSED", vec![]);
assert!(!s
.eval::<bool>("return BenillaTaxiFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -151,9 +136,7 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
// OnShow ran DrawOneHopLines, which found zero single-hop nodes: the red error line posted
// and the window hid itself again — the reference's own reaction to a dead-end flight point.
assert!(!s
.eval::<bool>("return BenillaTaxiFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
s.resolve();
let quads = s.extract();
let has_refusal = quads.iter().any(|q| match &q.content {
@@ -164,7 +147,7 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
});
assert!(
has_refusal,
"ERR_TAXINOPATHS posted to BenillaErrorsFrame: {quads:?}"
"ERR_TAXINOPATHS posted to UIErrorsFrame: {quads:?}"
);
}
@@ -178,14 +161,10 @@ fn close_button_queues_the_intent_and_hides() {
"TAXIMAP_OPENED",
vec![ScriptValue::Str("Dungar Longdrink".into())],
);
assert!(s
.eval::<bool>("return BenillaTaxiFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
s.run("BenillaTaxiCloseButton_OnClick()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.take_taxi_close());
assert!(!s
.eval::<bool>("return BenillaTaxiFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
}
@@ -9,7 +9,7 @@
//! — engine tests stubbed the handler, so nothing asserted the real files' geometry. Everything
//! here loads the shipped XMLs and asserts resolved rects / anchors, never a stub.
use benilla_ui::script::{AuraState, ScriptValue, UiScript, UnitState};
use benilla_ui::script::{AuraState, UiScript, UnitState};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the bag/panel
/// tests' loader, duplicated so this file is self-contained).
@@ -107,15 +107,14 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
// The kit + popups precede the unit frames (their DropDown children's OnLoad), app order.
let mut s = harness(&["UIDropDownMenu.xml", "UnitPopup.xml", "UnitFrames.xml"]);
s.set_unit("target", Some(wolf()));
s.run("BenillaUnitFrame_OnEnter(BenillaTargetFrame)")
.unwrap();
s.run("BenillaUnitFrame_OnEnter(TargetFrame)").unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
s.resolve();
let ok: bool = s
.eval(
"return GameTooltip:IsVisible() \
and GameTooltip:GetRight() == 1011 and GameTooltip:GetBottom() == 70 \
and GameTooltip:IsOwned(BenillaTargetFrame)",
and GameTooltip:IsOwned(TargetFrame)",
)
.unwrap();
assert!(
@@ -130,8 +129,7 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
"a non-player target still gets the unit lines"
);
// Leave: gone on the spot, no ramp to wait out.
s.run("BenillaUnitFrame_OnLeave(BenillaTargetFrame)")
.unwrap();
s.run("BenillaUnitFrame_OnLeave(TargetFrame)").unwrap();
let hidden: bool = s.eval("return not GameTooltip:IsShown()").unwrap();
assert!(
hidden,
@@ -153,8 +151,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
let mut s = harness(&["UIDropDownMenu.xml", "UnitPopup.xml", "UnitFrames.xml"]);
s.set_unit("player", Some(wolf()));
s.run("BenillaUnitFrame_OnEnter(BenillaPlayerFrame)")
.unwrap();
s.run("BenillaUnitFrame_OnEnter(PlayerFrame)").unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -183,8 +180,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
..wolf()
}),
);
s.run("BenillaUnitFrame_OnEnter(BenillaTargetFrame)")
.unwrap();
s.run("BenillaUnitFrame_OnEnter(TargetFrame)").unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -201,8 +197,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
#[test]
fn action_button_hover_takes_the_default_corner() {
let s = harness(&["Cooldown.xml", "ActionBar.xml"]);
s.run("BenillaActionButton_OnEnter(BenillaActionButton3)")
.unwrap();
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let ok: bool = s
.eval(
@@ -235,11 +230,20 @@ fn buff_hover_hangs_below_left_of_the_button() {
expiration_time: 1800.0,
helpful: true,
cancelable: true,
until_cancelled: false,
channeled: false,
}]),
);
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("player".into())]);
// The reference's own event, which the buff buttons register for (`ui_aura` fires it beside
// the Era-shaped UNIT_AURA on the same rebuild).
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
s.resolve();
s.run("BenillaBuffButton_OnEnter(BuffButton0)").unwrap();
// Through the template's real `<OnEnter>` — the reference keeps the SetOwner/SetPlayerBuff pair
// inline there rather than in a named function, so the handler body itself is what this drives.
// `this` is set by hand because the engine sets it only when it *fires* a handler (RF-0025);
// calling the compiled function directly does not, and the body reads `this`, not its argument.
s.run("this = BuffButton0; BuffButton0:GetScript(\"OnEnter\")(BuffButton0)")
.unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let ok: bool = s
.eval(
@@ -156,8 +156,7 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
s.set_modifiers(false, false, false);
// Open the window; the shift edge now seats the compare on the head slot.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_BTN)").unwrap();
s.set_modifiers(true, false, false);
@@ -168,7 +167,7 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
return ShoppingTooltip1:IsShown() \
and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \
and ShoppingTooltip1TextLeft2:GetText() == \"Test Helm\" \
and rel:GetName() == \"BenillaCharacterHeadSlot\" and p == \"BOTTOMLEFT\"",
and rel:GetName() == \"CharacterHeadSlot\" and p == \"BOTTOMLEFT\"",
)
.unwrap();
assert!(ok, "the compare plate seats on the head doll slot");
@@ -294,12 +293,11 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
BenillaBagSlot_OnEnter(BENILLA_TEST_BTN)",
)
.unwrap();
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
// The doll hover: the live pair, not the template's 40/40.
s.run("BenillaPaperDollSlot_OnEnter(BenillaCharacterHeadSlot)")
s.run("BenillaPaperDollSlot_OnEnter(CharacterHeadSlot)")
.unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let found: String = s
@@ -0,0 +1,448 @@
//! `GameTooltipTemplate` as an ADDON sees it — the shipped `assets/ui/GameTooltip.xml` driven the
//! way the corpus drives it.
//!
//! **This is not a test of our tooltip window** (that is `tooltip_anchor_tests` /
//! `tooltip_compare_tests`, and they are the regression suite this refactor had to leave
//! untouched). It is a test of the *template*, which is a different surface with a different
//! consumer: `GameTooltipTemplate` is the single most-wanted missing template in the addon corpus
//! — 27 addons name it in an XML `inherits=` and two more reach it through `CreateFrame`
//! (`addon_harness::inherits_demand` / `template_demand` over the 218-addon corpus).
//!
//! Every shape below is a real addon's, quoted, not one invented to be convenient:
//!
//! - **The declaration** is `<GameTooltip name="…" inherits="GameTooltipTemplate">` — all 27 use
//! the `<GameTooltip>` element (not `<Frame>`), none use a comma-separated `inherits` list, and
//! the two `CreateFrame` sites both pass `kind = "GameTooltip"` with `parent = nil`
//! (BetterCharacterStats/helper.lua l.3, BigWigs/Raids/Naxxramas/Loatheb.lua l.75).
//! - **The `$parent` names are the point.** 120 corpus lines read
//! `getglobal(tip:GetName().."TextLeft"..i)` or the hardcoded equivalent; a template whose
//! children came out named after the *template* would be useless, and a tooltip with no plate is
//! invisible. Those are the two failures these tests exist to catch.
//! - **The bare `GameTooltip_OnLoad()`** an addon writes in its own `<OnLoad>` (TipBuddy.xml
//! l.1402, atsw.xml l.3417) is the reference's zero-argument spelling; our corpus convention
//! takes the frame as a leading parameter, and nothing lets us edit an addon.
//! - **`$parentStatusBar` is load-bearing for exactly one addon and completely for it**: TipBuddy
//! both `parent=`s and `relativeTo=`s a `TipBuddyTooltipStatusBar` it never declares
//! (TipBuddy.xml l.2320, l.2591) — a name only the template can publish.
use benilla_ui::script::UiScript;
fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// One shipped `assets/ui/<file>` into `s`, asserting it loaded clean.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(ui_dir().join(file)).unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// An **addon's** document text, loaded after the shipped files exactly as `LoadAddOn` would —
/// the template registry is persistent, so `inherits=` reaches what `GameTooltip.xml` registered.
/// Returns the loader's own report so a test can assert on warnings as well as errors.
fn load_addon_xml(s: &UiScript, text: &str) -> benilla_ui::loader::LoadReport {
let doc = benilla_ui::framexml::parse(text).unwrap();
benilla_ui::loader::load(s, &doc, &|_| None)
}
/// Fonts + UIParent + the tooltip file, which is where both templates live.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in ["Fonts.xml", "UIParent.xml", "GameTooltip.xml"] {
load_xml(&s, f);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// Answer every pending FontString measure the way the app's font atlas does — 6 px/char, 14 px a
/// line. A tooltip that declares no `<Size>` (which is 43 of the corpus's 44 `<GameTooltip>`
/// elements, and the reference template too) has NO rect at all until its text is measured, so
/// without this the auto-size never runs and nothing resolves, let alone draws.
fn answer_measures(s: &mut UiScript) {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.into_iter()
.map(|r| (r.id, r.text.chars().count() as f32 * 6.0, 14.0, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
}
/// Every texture path the resolved frame tree actually draws — `<Texture>` regions AND the
/// `<Backdrop>` plate pieces, which are a quad kind of their own (`QuadContent::Backdrop`, emitted
/// from the frame's own draw slot rather than from a region). The plate is the whole point here,
/// so a filter that saw only `Texture` would report an empty list for a perfectly drawn tooltip.
fn drawn_textures(s: &mut UiScript) -> Vec<String> {
s.resolve();
answer_measures(s);
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
benilla_ui::script::QuadContent::Texture { path: Some(p), .. } => Some(p),
benilla_ui::script::QuadContent::Backdrop { path, .. } => Some(path),
_ => None,
})
.collect()
}
/// **The test this file exists for.** `MyTipTextLeft1` must exist; `GameTooltipTemplateTextLeft1`
/// must not.
///
/// The failure it pins is not "no tooltip": it is a tooltip whose lines are called
/// `GameTooltipTemplateTextLeft1`, so `getglobal(tip:GetName().."TextLeft1")` is nil, so the
/// addon's very next line dies — with the declaration itself having succeeded. 120 corpus lines
/// write that idiom.
///
/// Our line pairs are engine-created (`script/tooltip.rs` `ensure_lines`) rather than declared in
/// the template the way the reference's are, so they are named from the frame's own name by
/// construction — this asserts that the template route does not disturb it, and that the template
/// itself never publishes a line region.
#[test]
fn an_addon_tooltip_from_the_template_names_its_lines_after_the_caller() {
let s = harness();
// AtlasLoot/Core/AtlasLoot.xml l.576's declaration, renamed.
let report = load_addon_xml(
&s,
r#"<Ui><GameTooltip name="MyTip" inherits="GameTooltipTemplate" parent="UIParent" hidden="true"/></Ui>"#,
);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
assert!(
!report
.warnings
.iter()
.any(|w| w.contains("unknown template")),
"the template resolves: {:?}",
report.warnings
);
// AtlasLoot.lua l.3220-3236's own sequence: own the frame, clear, add lines, show.
s.run(
r#"MyTip:SetOwner(UIParent, "ANCHOR_RIGHT")
MyTip:ClearLines()
MyTip:SetText("Tough Jerky")
MyTip:AddLine("Drop Rate: 12%", 1, 1, 0)
MyTip:Show()"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return MyTip:NumLines()").unwrap(),
2,
"SetText is line 1, AddLine is line 2"
);
// The idiom, verbatim — MikScrollingBattleText.lua l.1824's spelling.
assert_eq!(
s.eval::<String>(r#"return getglobal(MyTip:GetName().."TextLeft1"):GetText()"#)
.unwrap(),
"Tough Jerky"
);
assert_eq!(
s.eval::<String>(r#"return getglobal("MyTipTextLeft2"):GetText()"#)
.unwrap(),
"Drop Rate: 12%"
);
// And never under the template's name.
assert!(
s.eval::<bool>(r#"return getglobal("GameTooltipTemplateTextLeft1") == nil"#)
.unwrap(),
"a line region named after the TEMPLATE is the failure this test exists for"
);
// A `virtual="true"` element is registered, never instantiated: no frame by that name either.
assert!(
s.eval::<bool>(r#"return getglobal("GameTooltipTemplate") == nil"#)
.unwrap(),
"the template is not a frame"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **plate** is what an addon actually gets from the template, and losing it is silent: the
/// declaration succeeds, the lines populate, and the tooltip renders as floating text over the
/// world with no backdrop behind it.
///
/// Strip `<Backdrop>` out of `GameTooltipTemplate` and this is the assertion that goes red.
#[test]
fn an_addon_tooltip_from_the_template_gets_the_plate() {
let mut s = harness();
load_addon_xml(
&s,
r#"<Ui><GameTooltip name="MyTip" inherits="GameTooltipTemplate" parent="UIParent" hidden="true"/></Ui>"#,
);
s.run(
r#"MyTip:SetOwner(UIParent, "ANCHOR_RIGHT")
MyTip:SetText("Tough Jerky")
MyTip:Show()"#,
)
.unwrap();
let drawn = drawn_textures(&mut s);
assert!(
drawn
.iter()
.any(|p| p.eq_ignore_ascii_case(r"Interface\Tooltips\UI-Tooltip-Background")),
"the addon tooltip draws the template's plate: {drawn:?}"
);
assert!(
drawn
.iter()
.any(|p| p.eq_ignore_ascii_case(r"Interface\Tooltips\UI-Tooltip-Border")),
"…and its border: {drawn:?}"
);
// The Thicken deviation rides the template too — it is the shared plate, not one window's.
assert!(
s.eval::<bool>(r#"return getglobal("MyTipThicken") ~= nil"#)
.unwrap(),
"$parentThicken resolves against the CALLER's name"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// TipBuddy's shape: an addon re-declares `<OnLoad>` to add its own line and opens it with the
/// **reference's** zero-argument `GameTooltip_OnLoad();` (TipBuddy.xml l.1402, atsw.xml l.3417).
///
/// Our helpers take the frame as a leading parameter (the corpus-wide convention shift noted at
/// the head of `GameTooltip.xml`), so a bare call arrives with `self = nil` — a load-time error,
/// and the plate it was about to tint never gets its colours. Nothing lets us edit an addon, so
/// the helper reads `this` when the argument is absent, which is what the reference reads anyway.
///
/// Asserted from both sides: no error, and the tint actually landed.
#[test]
fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
let s = harness();
let report = load_addon_xml(
&s,
r#"<Ui>
<GameTooltip name="MyTip" inherits="GameTooltipTemplate" frameStrata="TOOLTIP" hidden="true" parent="UIParent">
<Scripts>
<OnLoad>
GameTooltip_OnLoad()
this:SetOwner(UIParent, "ANCHOR_NONE")
</OnLoad>
<OnHide>
GameTooltip_OnHide()
</OnHide>
</Scripts>
</GameTooltip>
</Ui>"#,
);
assert!(
report.errors.is_empty(),
"a bare GameTooltip_OnLoad() must not error: {:?}",
report.errors
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The tint the bare call was there to apply (ref-GameTooltip.lua l.79-82's two colours).
// Compared as f32: the colours make a round trip through the arena's f32 storage.
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return MyTip:GetBackdropColor()")
.unwrap();
assert_eq!(
(r, g, b),
(0.09, 0.09, 0.19),
"GameTooltip_OnLoad tinted the plate — TOOLTIP_DEFAULT_BACKGROUND_COLOR"
);
// TipBuddy also routes its OnHide through the same helper, bare (TipBuddy.xml l.1412).
s.run("MyTip:Show() MyTip:Hide()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Lua route, which two addons take: `CreateFrame("GameTooltip", name, nil,
/// "GameTooltipTemplate")` — BetterCharacterStats/helper.lua l.3 and
/// BigWigs/Raids/Naxxramas/Loatheb.lua l.75, both with `kind = "GameTooltip"` and `parent = nil`.
///
/// The same template through the other door, so the same two things must hold: the caller's name
/// owns the children, and the plate came along.
#[test]
fn createframe_with_the_template_is_the_same_tooltip() {
let s = harness();
// BetterCharacterStats/helper.lua l.3, verbatim in shape (its `getglobal(...) or` guard and
// the WorldFrame owner both collapse to this in a fresh VM).
s.run(
r#"BCS_Tooltip = getglobal("BetterCharacterStatsTooltip")
or CreateFrame("GameTooltip", "BetterCharacterStatsTooltip", nil, "GameTooltipTemplate")
BCS_Tooltip:SetOwner(UIParent, "ANCHOR_NONE")"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Its next lines: fill, then read the lines back off a stored name prefix (`BCS_Prefix`).
s.run(
r#"BCS_Prefix = "BetterCharacterStatsTooltip"
BCS_Tooltip:SetText("Equip: Improves your chance to hit by 1%.")"#,
)
.unwrap();
assert_eq!(
s.eval::<String>(r#"return getglobal(BCS_Prefix .. "TextLeft" .. 1):GetText()"#)
.unwrap(),
"Equip: Improves your chance to hit by 1%."
);
assert!(
s.eval::<bool>(r#"return getglobal("BetterCharacterStatsTooltipThicken") ~= nil"#)
.unwrap(),
"the plate came through CreateFrame's fourth argument too"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **scanner** shape — the majority use: an off-screen tooltip nobody ever looks at, filled
/// only so its FontStrings can be read back and parsed.
///
/// CT_BarMod.lua l.1659-1669 blanks `TextLeft1`, fills the tooltip, and reads the FontString back
/// as its "did it populate?" sentinel; l.1747-1770 then walks `1..NumLines()` over both columns.
/// MikScrollingBattleText.lua l.1821-1858 is the same with `SetOwner(UIParent, "ANCHOR_NONE")` and
/// an explicit `Hide()` between passes (l.1832). Both read `TextRight` as well as `TextLeft`.
#[test]
fn the_scanner_shape_reads_both_columns_and_hides_again() {
let s = harness();
load_addon_xml(
&s,
r#"<Ui><GameTooltip name="CTTooltip" inherits="GameTooltipTemplate"/></Ui>"#,
);
s.run(r#"CTTooltip:SetOwner(UIParent, "ANCHOR_NONE")"#)
.unwrap();
// The sentinel clear, then the fill (a double line is the `%d yd range` row CT_BarMod hunts).
s.run(
r#"CTTooltip:ClearLines()
CTTooltip:SetText("Shadow Bolt")
CTTooltip:AddDoubleLine("Rank 4", "30 yd range")"#,
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// CT_BarMod.lua l.1663-1665's read, verbatim in shape.
assert_eq!(
s.eval::<String>("return CTTooltipTextLeft1:GetText()")
.unwrap(),
"Shadow Bolt",
"the bare-global spelling resolves too (CT_BarMod, TipBuddy and Outfitter all use it)"
);
// CT_BarMod.lua l.1752-1754's loop body, over the right column.
let range: String = s
.eval(
r#"for y = 1, CTTooltip:NumLines() do
local t = getglobal("CTTooltipTextRight" .. y):GetText()
if t and t ~= "" then return t end
end
return "<none>""#,
)
.unwrap();
assert_eq!(
range, "30 yd range",
"the right column is reachable by name"
);
// MikScrollingBattleText.lua l.1832's `tooltip:Hide()` — the scanner's way out, and what keeps
// it off screen. (`SetText` SHOWS the plate by design in our engine — the byte-pinned
// `0x531b90` note in tooltip/verbs.rs, and PaperDoll's empty-slot text depends on it — so a
// scanner is off screen because it hides again and because its owner anchor is ANCHOR_NONE,
// not because filling it is invisible.)
s.run("CTTooltip:Hide()").unwrap();
assert!(
!s.eval::<bool>("return CTTooltip:IsShown()").unwrap(),
"the scanner puts the plate away between passes"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `$parentStatusBar` belongs to the TEMPLATE, and TipBuddy is why.
///
/// TipBuddy replaces the whole unit tooltip and never declares a status bar of its own; it
/// `parent=`s a frame to `TipBuddyTooltipStatusBar` (TipBuddy.xml l.2320) and `relativeTo=`s an
/// anchor at it (l.2591), then drives it from Lua (`:SetValue`, `:Show`, `:Hide`, `:IsVisible` —
/// TipBuddy.lua l.177, l.1462-1464). Every one of those is a name only the template can publish.
///
/// This is the assertion that goes red if the status bar is moved back onto the `GameTooltip`
/// instance, which is where it lived while it was inlined.
#[test]
fn the_status_bar_is_the_templates_because_tipbuddy_anchors_to_it() {
let s = harness();
let report = load_addon_xml(
&s,
r#"<Ui>
<GameTooltip name="TipBuddyTooltip" frameStrata="TOOLTIP" hidden="true" parent="UIParent" inherits="GameTooltipTemplate"/>
<Frame name="TipBuddy_HealthTextGTT" frameStrata="TOOLTIP" parent="TipBuddyTooltipStatusBar">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="TipBuddyTooltipStatusBar" relativePoint="BOTTOMLEFT"/>
</Anchors>
</Frame>
</Ui>"#,
);
assert!(
report.errors.is_empty(),
"TipBuddy's anchor at the template's status bar must resolve: {:?}",
report.errors
);
assert!(
s.eval::<bool>(r#"return getglobal("TipBuddyTooltipStatusBar") ~= nil"#)
.unwrap(),
"$parentStatusBar publishes under the CALLER's name"
);
// TipBuddy.lua l.1462-1464 / l.177 — it shows, hides and drives the bar directly.
s.run(
r#"TipBuddyTooltipStatusBar:SetMinMaxValues(0, 100)
TipBuddyTooltipStatusBar:SetValue(42)
TipBuddyTooltipStatusBar:Show()"#,
)
.unwrap();
assert!(s
.eval::<bool>("return TipBuddyTooltipStatusBar:IsShown()")
.unwrap());
assert_eq!(
s.eval::<f64>("return TipBuddyTooltipStatusBar:GetValue()")
.unwrap(),
42.0
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The instance attributes the corpus actually overrides on top of the template: `frameStrata`
/// (13 sites — `TOOLTIP`, and `LOW` at Necrosis.xml l.1332) and `hidden="false"` (PowerAuras.xml
/// l.20, SimpleActionSets.xml l.349, against the template's `hidden="true"`).
///
/// The splice puts the template's attributes first and the instance's on top, so both of these are
/// the instance's to win — and `hidden="false"` in particular must not be read as "hidden", since
/// PowerAuras never calls `Show()`.
///
/// **It is also the falsifier for `GameTooltip_OnLoad`'s body.** Our copy used to end with a
/// `self:Hide()` the reference's does not have (ref-GameTooltip.lua l.79-82 is the whole function);
/// it was invisible on our four tooltips, every one of which carries `hidden="true"` anyway, and it
/// hid this one. Put the line back and this assertion goes red — which is the only place anything
/// would notice.
#[test]
fn an_instance_attribute_beats_the_templates() {
let s = harness();
load_addon_xml(
&s,
r#"<Ui>
<GameTooltip name="NecrosisTooltip" frameStrata="LOW" parent="UIParent" inherits="GameTooltipTemplate"/>
<GameTooltip name="Powa_Tooltip" frameStrata="TOOLTIP" hidden="false" parent="UIParent" inherits="GameTooltipTemplate"/>
</Ui>"#,
);
assert_eq!(
s.eval::<String>("return NecrosisTooltip:GetFrameStrata()")
.unwrap(),
"LOW",
"the instance's strata overrides the template's TOOLTIP"
);
assert!(
s.eval::<bool>("return Powa_Tooltip:IsShown()").unwrap(),
"hidden=\"false\" beats the template's hidden=\"true\" — PowerAuras never calls Show()"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -0,0 +1,253 @@
//! `assets/ui/UIParent.xml`'s addon-facing helpers, driven from Lua the way an addon drives them.
//!
//! The panel/ESC machinery in that file is covered by `panel_tests` and `escape_tests`; this is for
//! the loose functions the reference's `UIParent.lua` also defines, which benilla itself may never
//! call and an addon calls constantly.
use benilla_ui::script::UiScript;
/// Fonts (for any `inherits=`), then UIParent — the manifest's order.
fn ui_parent() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in ["Fonts.xml", "UIParent.xml"] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
s.set_screen_size(1024.0, 768.0);
s.run(
r#"Box = CreateFrame("Frame", "Box", UIParent)
Box:SetWidth(100) Box:SetHeight(50)
Box:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 200, 300)"#,
)
.unwrap();
s.resolve();
s
}
/// **`MouseIsOver` is the hover idiom, and its edge cases are the reference's, not the obvious
/// ones** — 9 corpus addons call it.
///
/// Three behaviours that a re-implementation would get wrong and a transcription gets right: the
/// offsets are *added* to every edge (so a positive `bottomOffset` raises the bottom rather than
/// lowering it), a nil `topOffset` zeroes **all four** even if the others were passed, and the
/// bounds are strict, so a cursor exactly on an edge is outside.
#[test]
fn mouse_is_over_is_the_references_own_box_test() {
let mut s = ui_parent();
// The box is x 200..300, y 300..350.
s.mouse_move(250.0, 325.0);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Box)").unwrap(),
Some(1),
"dead centre"
);
s.mouse_move(150.0, 325.0);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Box)").unwrap(),
None,
"100px to the left is out, and the miss is nil rather than false"
);
// ...but a leftOffset of -60 pushes the left edge out to 140 and takes it in.
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Box, 0, 0, -60, 0)")
.unwrap(),
Some(1),
"offsets are ADDED to each edge: leftOffset -60 grows the box leftward"
);
// A positive bottomOffset SHRINKS from below — the same addition, read the other way.
s.mouse_move(250.0, 305.0);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Box)").unwrap(),
Some(1)
);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Box, 0, 10, 0, 0)")
.unwrap(),
None,
"bottom + 10 = 310, and the cursor is at 305"
);
// The reference's `if ( not topOffset )` zeroes ALL FOUR — so this 10 is ignored entirely.
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Box, nil, 10, 0, 0)")
.unwrap(),
Some(1),
"a nil topOffset discards the other three, exactly as 1.12 does"
);
// Strict bounds: exactly on an edge is outside.
s.mouse_move(200.0, 325.0);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Box)").unwrap(),
None,
"`x > left`, not `>=`"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **`RaiseFrameLevel` / `LowerFrameLevel` are FrameXML, not engine** — an addon calling them is
/// calling a function the client's own UI defines, and `BetterCharacterStats` dies at load without
/// them (`attempt to call global 'RaiseFrameLevel'`).
///
/// Two lines each in the reference and worth a test only because the direction is easy to swap:
/// Raise is `+1`, Lower is `-1`, and both read the frame's CURRENT level rather than a stored one,
/// so repeated calls accumulate.
#[test]
fn raise_and_lower_frame_level_step_the_frames_own_level() {
let s = ui_parent();
let base: i64 = s.eval("return Box:GetFrameLevel()").unwrap();
s.run("RaiseFrameLevel(Box) RaiseFrameLevel(Box)").unwrap();
assert_eq!(
s.eval::<i64>("return Box:GetFrameLevel()").unwrap(),
base + 2,
"each call reads the level back, so they accumulate"
);
s.run("LowerFrameLevel(Box)").unwrap();
assert_eq!(
s.eval::<i64>("return Box:GetFrameLevel()").unwrap(),
base + 1
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `randomseed` is an engine global in 1.12 beside `random`, and it was the missing half —
/// `IgniteStatus` calls it at file scope and dies on `attempt to call global`.
#[test]
fn randomseed_is_a_bare_global_like_random() {
let s = ui_parent();
// Seeding twice with the same value must produce the same first draw; that is the whole
// contract an addon wants from it.
let a: i64 = s
.eval("randomseed(12345) return random(1, 1000000)")
.unwrap();
let b: i64 = s
.eval("randomseed(12345) return random(1, 1000000)")
.unwrap();
assert_eq!(a, b, "the same seed gives the same sequence");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The reference's own "hack to fix a symptom not the real issue": a frame that has never been
/// laid out answers nil from `GetLeft()`, and `MouseIsOver` must return nil rather than raise.
#[test]
fn mouse_is_over_survives_a_frame_with_no_resolved_rect() {
let mut s = ui_parent();
s.mouse_move(250.0, 325.0);
s.run(r#"Floating = CreateFrame("Frame", "Floating", UIParent)"#)
.unwrap();
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Floating)")
.unwrap(),
None,
"an unanchored frame is a nil answer, not an error"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── Fonts.xml's shared colour globals, as an addon reaches them ───────────────────────────────
/// **`RAID_CLASS_COLORS` is the table every raid addon paints names with** — 22 corpus addons, and
/// its absence was the whole of the harness's remaining `bad argument #1 to 'pairs' (table
/// expected, got nil)` row.
///
/// The shape matters as much as the values: addons walk it with `pairs` and index it by the
/// UPPERCASE class file token that `UnitClass`'s second return gives them, so the test does exactly
/// that rather than reading one key.
#[test]
fn raid_class_colors_is_the_references_own_nine() {
let mut s = UiScript::new().unwrap();
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/Fonts.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.set_screen_size(1024.0, 768.0);
// PaintChips-2.0's own line, verbatim in shape — the one that was raising.
let n: i64 = s
.eval("local n = 0 for i, v in pairs(RAID_CLASS_COLORS) do n = n + 1 end return n")
.unwrap();
assert_eq!(n, 9, "1.12 has nine playable classes and one row each");
let keys: String = s
.eval(
"local t = {} for k in pairs(RAID_CLASS_COLORS) do table.insert(t, k) end \
table.sort(t) return table.concat(t, \" \")",
)
.unwrap();
assert_eq!(
keys, "DRUID HUNTER MAGE PALADIN PRIEST ROGUE SHAMAN WARLOCK WARRIOR",
"keyed by UnitClass's uppercase second return, which is how addons index it"
);
// Spot the two the reference makes identical — a "fix" here would be a divergence.
assert_eq!(
s.eval::<(f64, f64, f64)>(
"local p, h = RAID_CLASS_COLORS.PALADIN, RAID_CLASS_COLORS.SHAMAN \
return p.r - h.r, p.g - h.g, p.b - h.b"
)
.unwrap(),
(0.0, 0.0, 0.0),
"SHAMAN shares PALADIN's pink in 1.12 — transcribed, not corrected"
);
assert_eq!(
s.eval::<(f64, f64, f64)>("local c = RAID_CLASS_COLORS.HUNTER return c.r, c.g, c.b")
.unwrap(),
(0.67, 0.83, 0.45)
);
}
/// **The four font-PATH globals** (ref `Fonts.xml` l.4-7), which this file quoted around for a long
/// time — it took the colour block at l.8-19 and skipped the four lines above it.
///
/// `STANDARD_TEXT_FONT` is why they matter: `Dewdrop-2.0` calls
/// `button.text:SetFont(STANDARD_TEXT_FONT, height)` **unguarded** at two of its three sites, so a
/// nil global is a failed `SetFont` on every menu button the Ace2 ecosystem draws.
#[test]
fn the_font_path_globals_are_the_references_own_four() {
let mut s = UiScript::new().unwrap();
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/Fonts.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.set_screen_size(1024.0, 768.0);
for name in [
"STANDARD_TEXT_FONT",
"UNIT_NAME_FONT",
"DAMAGE_TEXT_FONT",
"NAMEPLATE_FONT",
] {
assert_eq!(
s.eval::<String>(&format!("return {name}")).unwrap(),
"Fonts\\FRIZQT__.TTF",
"{name} — 1.12 assigns the same face to all four, which is the reference's own \
identity and not a simplification"
);
}
// Dewdrop's own unguarded line, in shape: a nil global here is a failed SetFont on every
// Ace2 menu button.
s.run(
r#"F = CreateFrame("Frame", "F", UIParent)
T = F:CreateFontString("T", "ARTWORK")
T:SetFont(STANDARD_TEXT_FONT, 10)"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -45,7 +45,7 @@ fn shipped_unit_frames_drive_end_to_end() {
// No units yet: both frames hid themselves on their OnLoad's first update.
let hidden: bool = s
.eval("return not BenillaPlayerFrame:IsVisible() and not BenillaTargetFrame:IsVisible()")
.eval("return not PlayerFrame:IsVisible() and not TargetFrame:IsVisible()")
.unwrap();
assert!(hidden, "frames hide while their units don't exist");
@@ -67,20 +67,18 @@ fn shipped_unit_frames_drive_end_to_end() {
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert!(s
.eval::<bool>("return BenillaPlayerFrame:IsVisible()")
.unwrap());
assert!(s.eval::<bool>("return PlayerFrame:IsVisible()").unwrap());
let ok: bool = s
.eval(
r#"
local hb, pb = BenillaPlayerFrameHealthBar, BenillaPlayerFramePowerBar
local hb, pb = PlayerFrameHealthBar, PlayerFramePowerBar
local _, hmax = hb:GetMinMaxValues()
local _, pmax = pb:GetMinMaxValues()
local r, g, b = pb:GetStatusBarColor()
return hb:GetValue() == 72 and hmax == 100
and pb:GetValue() == 45 and pmax == 80 and pb:IsVisible()
and b == 1 and r == 0 -- mana blue
and BenillaPlayerFrameTextureFrameNameText:GetText() == "Unknown"
and PlayerFrameTextureFrameNameText:GetText() == "Unknown"
"#,
)
.unwrap();
@@ -105,7 +103,7 @@ fn shipped_unit_frames_drive_end_to_end() {
);
s.fire_event("UNIT_NAME_UPDATE", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<String>("return BenillaPlayerFrameTextureFrameNameText:GetText()")
s.eval::<String>("return PlayerFrameTextureFrameNameText:GetText()")
.unwrap(),
"Benilla"
);
@@ -132,11 +130,11 @@ fn shipped_unit_frames_drive_end_to_end() {
let ok: bool = s
.eval(
r#"
return BenillaTargetFrame:IsVisible()
and not BenillaTargetFramePowerBar:IsVisible()
and BenillaTargetFrameTextureFrameNameText:GetText() == "Young Wolf"
and BenillaTargetFrameTextureFrameLevelText:GetText() == "3"
and BenillaTargetFrameTextureFrameDeadText:GetText() == "" -- living target: no DEAD text
return TargetFrame:IsVisible()
and not TargetFramePowerBar:IsVisible()
and TargetFrameTextureFrameNameText:GetText() == "Young Wolf"
and TargetFrameTextureFrameLevelText:GetText() == "3"
and TargetFrameTextureFrameDeadText:GetText() == "" -- living target: no DEAD text
"#,
)
.unwrap();
@@ -191,9 +189,7 @@ fn shipped_unit_frames_drive_end_to_end() {
// Deselect: the target frame hides again, playing the lost-target kit (ref TargetFrame_OnHide).
s.set_unit("target", None);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!s
.eval::<bool>("return BenillaTargetFrame:IsVisible()")
.unwrap());
assert!(!s.eval::<bool>("return TargetFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName(
@@ -278,7 +274,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
let ok: bool = s
.eval(
r#"
local ind = BenillaPlayerFrameTextureFrameHitIndicator
local ind = PlayerFrameTextureFrameHitIndicator
return ind:IsShown() ~= nil and tostring(ind:GetText()) == "17"
"#,
)
@@ -293,7 +289,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
// A spell crit: ×1.5 height (the CRITICAL arm), and the type>0 yellow.
s.fire_event("UNIT_COMBAT", ev("player", "WOUND", "CRITICAL", 64, 4));
let ok: bool = s
.eval("return tostring(BenillaPlayerFrameTextureFrameHitIndicator:GetText()) == \"64\"")
.eval("return tostring(PlayerFrameTextureFrameHitIndicator:GetText()) == \"64\"")
.unwrap();
assert!(ok, "spell crit paints ({:?})", s.errors());
assert_eq!(
@@ -305,7 +301,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
// A full absorb: the word at ×0.75.
s.fire_event("UNIT_COMBAT", ev("player", "WOUND", "ABSORB", 0, 0));
let ok: bool = s
.eval("return BenillaPlayerFrameTextureFrameHitIndicator:GetText() == \"Absorb\"")
.eval("return PlayerFrameTextureFrameHitIndicator:GetText() == \"Absorb\"")
.unwrap();
assert!(ok, "full absorb paints the word ({:?})", s.errors());
assert_eq!(
@@ -319,7 +315,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
let ok: bool = s
.eval(
r#"
local ind = BenillaPlayerFrameTextureFrameHitIndicator
local ind = PlayerFrameTextureFrameHitIndicator
return ind:IsShown() ~= nil and ind:GetAlpha() == 1.0
"#,
)
@@ -327,7 +323,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
assert!(ok, "mid-hold: opaque ({:?})", s.errors());
s.tick(0.8); // past fade-in + hold + fade-out (1.2 s total)
assert!(
s.eval::<bool>("return BenillaPlayerFrameTextureFrameHitIndicator:IsShown() == nil")
s.eval::<bool>("return PlayerFrameTextureFrameHitIndicator:IsShown() == nil")
.unwrap(),
"the envelope ends in a Hide ({:?})",
s.errors()
@@ -336,7 +332,7 @@ fn unit_combat_drives_the_player_hit_indicator() {
// A target-token event leaves the (hidden) indicator alone.
s.fire_event("UNIT_COMBAT", ev("target", "WOUND", "", 99, 0));
assert!(
s.eval::<bool>("return BenillaPlayerFrameTextureFrameHitIndicator:IsShown() == nil")
s.eval::<bool>("return PlayerFrameTextureFrameHitIndicator:IsShown() == nil")
.unwrap(),
"a target event never touches the player indicator"
);
@@ -388,7 +384,7 @@ fn left_clicking_the_player_frame_targets_self() {
// Click the frame's centre through the real hit-test path (press + release on the same frame).
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaPlayerFrame:GetCenter()")
.eval::<(f64, f64)>("return PlayerFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
@@ -420,7 +416,7 @@ fn left_clicking_the_player_frame_targets_self() {
guid: 0xA11CE,
}],
leader_index: 0, // we lead
raid_members: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
@@ -509,7 +505,7 @@ fn raid_mark_clicks_through_the_nested_level() {
guid: 0xA11CE,
}],
leader_index: 0, // we lead — the mark rows are leader-gated
raid_members: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
@@ -517,7 +513,7 @@ fn raid_mark_clicks_through_the_nested_level() {
s.resolve();
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaPlayerFrame:GetCenter()")
.eval::<(f64, f64)>("return PlayerFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
@@ -640,8 +636,8 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local lvl = getglobal("BenillaTargetFrameTextureFrameLevelText")
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
local lvl = getglobal("TargetFrameTextureFrameLevelText")
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
local c = GetDifficultyColor(8)
return lvl:IsShown() ~= nil and skull:IsShown() == nil
and tostring(lvl:GetText()) == "8"
@@ -669,8 +665,8 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local lvl = getglobal("BenillaTargetFrameTextureFrameLevelText")
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
local lvl = getglobal("TargetFrameTextureFrameLevelText")
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
return UnitLevel("target") == -1
and skull:IsShown() ~= nil and lvl:IsShown() == nil
"#,
@@ -697,8 +693,8 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local lvl = getglobal("BenillaTargetFrameTextureFrameLevelText")
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
local lvl = getglobal("TargetFrameTextureFrameLevelText")
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
return UnitIsCorpse("target") == nil
and lvl:IsShown() ~= nil and skull:IsShown() == nil
and tostring(lvl:GetText()) == "8"
@@ -726,7 +722,7 @@ fn shipped_target_frame_runs_the_level_law() {
let ok: bool = s
.eval(
r#"
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
return UnitIsCorpse("target") == 1 and skull:IsShown() ~= nil
"#,
)
@@ -805,16 +801,13 @@ fn pvp_icon_follows_the_three_branch_law() {
// Unflagged: no icon, no sound.
s.set_unit("player", Some(alliance_player(false, false)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("player".into())]);
assert!(
!icon_shown(&s, "BenillaPlayerFrame"),
"unflagged shows none"
);
assert!(!icon_shown(&s, "PlayerFrame"), "unflagged shows none");
assert!(s.take_sounds().is_empty(), "no sound while unflagged");
// Flagged: the Alliance icon, and one igPVPUpdate for the flag change.
s.set_unit("player", Some(alliance_player(true, false)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("player".into())]);
assert!(icon_shown(&s, "BenillaPlayerFrame"));
assert!(icon_shown(&s, "PlayerFrame"));
assert!(icon_path(&mut s, "UI-PVP-Alliance"), "faction leg art");
assert_eq!(
s.take_sounds(),
@@ -851,7 +844,7 @@ fn pvp_icon_follows_the_three_branch_law() {
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(
!icon_shown(&s, "BenillaTargetFrame"),
!icon_shown(&s, "TargetFrame"),
"flagged but sideless draws no icon"
);
@@ -871,7 +864,7 @@ fn pvp_icon_follows_the_three_branch_law() {
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(icon_shown(&s, "BenillaTargetFrame"));
assert!(icon_shown(&s, "TargetFrame"));
assert!(icon_path(&mut s, "UI-PVP-Horde"), "the target's own side");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1046,8 +1039,8 @@ fn target_frame_border_follows_the_classification_law() {
let plain_on_player: bool = s
.eval(
r#"
local p = getglobal("BenillaPlayerFrameTextureFrameTexture")
return p ~= nil and BenillaPlayerFrame.frameTexture == nil
local p = getglobal("PlayerFrameTextureFrameTexture")
return p ~= nil and PlayerFrame.frameTexture == nil
"#,
)
.unwrap();
@@ -1233,9 +1226,9 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
let alive: bool = s
.eval(
r#"
local hb, pb = BenillaTargetFrameHealthBar, BenillaTargetFramePowerBar
local hb, pb = TargetFrameHealthBar, TargetFramePowerBar
return hb:GetValue() == 1200 and pb:GetValue() == 300
and BenillaTargetFrameTextureFrameDeadText:GetText() == ""
and TargetFrameTextureFrameDeadText:GetText() == ""
"#,
)
.unwrap();
@@ -1245,24 +1238,22 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
s.set_unit("target", hunter(0, 0, true));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("target".into())]);
let (hp, hmax, mana, mmax): (f64, f64, f64, f64) = (
s.eval("return BenillaTargetFrameHealthBar:GetValue()")
s.eval("return TargetFrameHealthBar:GetValue()").unwrap(),
s.eval("local _, m = TargetFrameHealthBar:GetMinMaxValues() return m")
.unwrap(),
s.eval("local _, m = BenillaTargetFrameHealthBar:GetMinMaxValues() return m")
.unwrap(),
s.eval("return BenillaTargetFramePowerBar:GetValue()")
.unwrap(),
s.eval("local _, m = BenillaTargetFramePowerBar:GetMinMaxValues() return m")
s.eval("return TargetFramePowerBar:GetValue()").unwrap(),
s.eval("local _, m = TargetFramePowerBar:GetMinMaxValues() return m")
.unwrap(),
);
assert_eq!((hp, hmax), (0.0, 1500.0), "empty health bar, real track");
assert_eq!((mana, mmax), (0.0, 900.0), "empty mana bar, real track");
assert!(
s.eval::<bool>("return BenillaTargetFramePowerBar:IsVisible()")
s.eval::<bool>("return TargetFramePowerBar:IsVisible()")
.unwrap(),
"the mana bar empties, it does not disappear — UnitManaMax 0x5177e0 is ungated"
);
assert_eq!(
s.eval::<String>("return BenillaTargetFrameTextureFrameDeadText:GetText()")
s.eval::<String>("return TargetFrameTextureFrameDeadText:GetText()")
.unwrap(),
"DEAD",
"TargetFrame_CheckDead's UnitHealth(unit) <= 0 test, tripped by the flag"
@@ -1278,9 +1269,9 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
let up: bool = s
.eval(
r#"
return BenillaTargetFrameHealthBar:GetValue() == 1200
and BenillaTargetFramePowerBar:GetValue() == 300
and BenillaTargetFrameTextureFrameDeadText:GetText() == ""
return TargetFrameHealthBar:GetValue() == 1200
and TargetFramePowerBar:GetValue() == 300
and TargetFrameTextureFrameDeadText:GetText() == ""
and not UnitIsDead("target")
"#,
)
@@ -1320,13 +1311,13 @@ fn the_player_frame_flashes_zzz_while_resting() {
let resting: bool = s
.eval(
r#"
local tf = "BenillaPlayerFrameTextureFrame"
local tf = "PlayerFrameTextureFrame"
return getglobal(tf .. "StatusTexture"):IsShown()
and getglobal(tf .. "RestIcon"):IsShown()
and not getglobal(tf .. "AttackIcon"):IsShown()
and BenillaPlayerFrameStatusGlow:IsShown()
and BenillaPlayerFrameStatusGlowRestGlow:IsShown()
and not BenillaPlayerFrameStatusGlowAttackGlow:IsShown()
and PlayerFrameStatusGlow:IsShown()
and PlayerFrameStatusGlowRestGlow:IsShown()
and not PlayerFrameStatusGlowAttackGlow:IsShown()
and not getglobal(tf .. "AttackBackground"):IsShown()
"#,
)
@@ -1338,12 +1329,12 @@ fn the_player_frame_flashes_zzz_while_resting() {
// The pulse: two OnUpdate ticks move the ring's alpha (the 0.5 s triangle wave).
let a0: f64 = s
.eval("return BenillaPlayerFrameTextureFrameStatusTexture:GetAlpha()")
.eval("return PlayerFrameTextureFrameStatusTexture:GetAlpha()")
.unwrap();
s.run("BenillaPlayerFrame_OnUpdate(BenillaPlayerFrame, 0.25)")
s.run("this = PlayerFrame; PlayerFrame_OnUpdate(0.25)")
.unwrap();
let a1: f64 = s
.eval("return BenillaPlayerFrameTextureFrameStatusTexture:GetAlpha()")
.eval("return PlayerFrameTextureFrameStatusTexture:GetAlpha()")
.unwrap();
assert!(
(a0 - a1).abs() > 0.1,
@@ -1353,7 +1344,7 @@ fn the_player_frame_flashes_zzz_while_resting() {
// Swinging while resting: resting still wins (the ref's branch order).
s.fire_event("PLAYER_ENTER_COMBAT", vec![]);
assert!(
s.eval::<bool>("return BenillaPlayerFrameTextureFrameRestIcon:IsShown()")
s.eval::<bool>("return PlayerFrameTextureFrameRestIcon:IsShown()")
.unwrap(),
"resting outranks auto-attack"
);
@@ -1364,11 +1355,11 @@ fn the_player_frame_flashes_zzz_while_resting() {
let attacking: bool = s
.eval(
r#"
local tf = "BenillaPlayerFrameTextureFrame"
local tf = "PlayerFrameTextureFrame"
return getglobal(tf .. "StatusTexture"):IsShown()
and getglobal(tf .. "AttackIcon"):IsShown()
and not getglobal(tf .. "RestIcon"):IsShown()
and BenillaPlayerFrameStatusGlowAttackGlow:IsShown()
and PlayerFrameStatusGlowAttackGlow:IsShown()
and getglobal(tf .. "AttackBackground"):IsShown()
"#,
)
@@ -1380,11 +1371,11 @@ fn the_player_frame_flashes_zzz_while_resting() {
let clear: bool = s
.eval(
r#"
local tf = "BenillaPlayerFrameTextureFrame"
local tf = "PlayerFrameTextureFrame"
return not getglobal(tf .. "StatusTexture"):IsShown()
and not getglobal(tf .. "RestIcon"):IsShown()
and not getglobal(tf .. "AttackIcon"):IsShown()
and not BenillaPlayerFrameStatusGlow:IsShown()
and not PlayerFrameStatusGlow:IsShown()
and not getglobal(tf .. "AttackBackground"):IsShown()
"#,
)
@@ -1491,8 +1482,8 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
};
// Off (the shipped default): the strings carry the numbers, and nothing paints.
assert!(!shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"));
assert!(!shown(&s, "BenillaPlayerFrameTextureFramePowerBarText"));
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
assert!(!shown(&s, "PlayerFrameTextureFramePowerBarText"));
// On, through the switch's own event — no repaint, no damage taken.
s.register_cvars([("statusBarText", "0")]);
@@ -1500,17 +1491,17 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
.unwrap();
s.tick(0.0);
assert!(
shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
shown(&s, "PlayerFrameTextureFrameHealthBarText"),
"your own health numerals pin on"
);
assert_eq!(
text(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
text(&s, "PlayerFrameTextureFrameHealthBarText"),
"72 / 100",
"bare numbers: the reference's \"Health\"/\"Rage\" labels were cut on the \
director's call (1147)"
);
assert_eq!(
text(&s, "BenillaPlayerFrameTextureFramePowerBarText"),
text(&s, "PlayerFrameTextureFramePowerBarText"),
"45 / 80",
"the power bar shows the number alone, whatever resource it is"
);
@@ -1518,22 +1509,19 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
// The target's stay dark with the switch ON: 1.12 never makes its bars textLockable, so the
// option does not reach them.
assert!(
!shown(&s, "BenillaTargetFrameTextureFrameHealthBarText"),
!shown(&s, "TargetFrameTextureFrameHealthBarText"),
"the switch pins your own numbers, never the target's"
);
// ...but the HOVER reveals them, which is how you read a target's health in 1.12 (1146).
s.run("BenillaUnitFrameBar_OnEnter(BenillaTargetFrameHealthBar)")
s.run("BenillaUnitFrameBar_OnEnter(TargetFrameHealthBar)")
.unwrap();
assert!(shown(&s, "BenillaTargetFrameTextureFrameHealthBarText"));
assert_eq!(
text(&s, "BenillaTargetFrameTextureFrameHealthBarText"),
"50 / 100"
);
s.run("BenillaUnitFrameBar_OnLeave(BenillaTargetFrameHealthBar)")
assert!(shown(&s, "TargetFrameTextureFrameHealthBarText"));
assert_eq!(text(&s, "TargetFrameTextureFrameHealthBarText"), "50 / 100");
s.run("BenillaUnitFrameBar_OnLeave(TargetFrameHealthBar)")
.unwrap();
assert!(
!shown(&s, "BenillaTargetFrameTextureFrameHealthBarText"),
!shown(&s, "TargetFrameTextureFrameHealthBarText"),
"and go away again — the lockShow refcount balances"
);
@@ -1542,16 +1530,16 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
s.run("SetCVar(\"statusBarText\", \"0\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
assert!(!shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"));
s.run("BenillaUnitFrameBar_OnEnter(BenillaPlayerFrameHealthBar)")
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
s.run("BenillaUnitFrameBar_OnEnter(PlayerFrameHealthBar)")
.unwrap();
assert!(
shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
shown(&s, "PlayerFrameTextureFrameHealthBarText"),
"hover shows them even with the option off"
);
s.run("BenillaUnitFrameBar_OnLeave(BenillaPlayerFrameHealthBar)")
s.run("BenillaUnitFrameBar_OnLeave(PlayerFrameHealthBar)")
.unwrap();
assert!(!shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"));
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
@@ -1559,18 +1547,12 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
// A health change repaints through the bar's own OnValueChanged, like the reference's.
s.set_unit("player", Some(alive(31, 45, 1)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert_eq!(
text(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
"31 / 100"
);
assert_eq!(text(&s, "PlayerFrameTextureFrameHealthBarText"), "31 / 100");
// A power-type change repaints the same way (it used to re-LABEL the bar too — 1147 cut that).
s.set_unit("player", Some(alive(31, 60, 3)));
s.fire_event("UNIT_DISPLAYPOWER", vec![ScriptValue::Str("player".into())]);
assert_eq!(
text(&s, "BenillaPlayerFrameTextureFramePowerBarText"),
"60 / 80"
);
assert_eq!(text(&s, "PlayerFrameTextureFramePowerBarText"), "60 / 80");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1639,14 +1621,14 @@ fn no_two_numeral_strings_overlap_on_any_frame() {
for (frame, upper, lower, want) in [
(
"player",
"BenillaPlayerFrameTextureFrameHealthBarText",
"BenillaPlayerFrameTextureFramePowerBarText",
"PlayerFrameTextureFrameHealthBarText",
"PlayerFrameTextureFramePowerBarText",
12.0,
),
(
"pet",
"BenillaPetFrameTextureFrameHealthBarText",
"BenillaPetFrameTextureFrameManaBarText",
"PetFrameTextureFrameHealthBarText",
"PetFrameTextureFrameManaBarText",
11.0,
),
] {
@@ -170,7 +170,7 @@ fn solo_target_right_click_invites_a_player() {
// Right-click the target frame through the real hit path.
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaTargetFrame:GetCenter()")
.eval::<(f64, f64)>("return TargetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
@@ -268,7 +268,7 @@ fn solo_target_trade_click_queues_an_initiate() {
// Right-click the target frame → PLAYER menu.
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaTargetFrame:GetCenter()")
.eval::<(f64, f64)>("return TargetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
@@ -334,7 +334,7 @@ fn solo_target_follow_click_queues_an_exact_by_name_follow() {
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaTargetFrame:GetCenter()")
.eval::<(f64, f64)>("return TargetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
@@ -401,7 +401,7 @@ fn solo_target_inspect_click_reaches_inspect_unit() {
s.resolve();
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaTargetFrame:GetCenter()")
.eval::<(f64, f64)>("return TargetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
@@ -478,6 +478,28 @@ fn bake_pet_strings(s: &UiScript) {
/// A pet that exists, so `UnitExists("pet")` passes the dropdown's own gate.
fn a_pet(s: &mut UiScript, name: &str) {
// A player first: `PetFrame` is a CHILD of `PlayerFrame` (our UnitFrames.xml:1792 and the
// reference's PetFrame.xml:4 both say `parent="PlayerFrame"`), and `UnitFrame_Update` hides a
// frame whose unit does not exist — so a pet with no player leaves the pet frame inside a
// hidden parent and the right-click never lands. A pet without a player is not a state the game
// can be in; the fixture was only ever getting away with it because the loader used to ignore
// the `parent=` attribute.
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Me".into()),
health: 100,
max_health: 100,
level: 60,
is_player: true,
..UnitState::default()
}),
);
s.fire_event(
"UNIT_HEALTH",
vec![benilla_ui::script::ScriptValue::Str("player".into())],
);
s.set_unit(
"pet",
Some(UnitState {
@@ -499,9 +521,7 @@ fn a_pet(s: &mut UiScript, name: &str) {
/// `DropDownList1` afterwards.
fn right_click_the_pet_frame(s: &mut UiScript) {
s.resolve();
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaPetFrame:GetCenter()")
.unwrap();
let (cx, cy) = s.eval::<(f64, f64)>("return PetFrame:GetCenter()").unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
s.resolve();
@@ -702,7 +722,7 @@ fn the_pet_details_row_opens_the_pet_paper_doll() {
click_row(&mut s, 2);
assert_eq!(
s.eval::<String>("return BENILLA_TEST_TOGGLED").unwrap(),
"BenillaPetPaperDollFrame"
"PetPaperDollFrame"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}