benilla's layer speaks the 1.12 API alone: handlers take this and no arguments, GetBinding answers the 1.12 list, the Key Bindings page captures through its window, /errors chains the error handler, and the host hooks leave the VM (#569)

benilla's layer now uses only the 1.12 API and the 1.12 Lua forms, and the host hooks it used are gone from the VM.

- **The VM calls handlers as 1.12.1 does.** A handler body is compiled as the chunk itself, so `self` in it is an ordinary global (`0x704c70`). A handler is called with no arguments (`0x705199`). The loader reads no `function=` attribute.
- **`GetBinding`/`GetNumBindings` answer the 1.12 list** (`0x4b7f60`): `HEADER_*` section rows, plus every key bound to a command, with no category column. The stock `KeyBindingFrame` now shows its section headers and both key columns.
- **The Key Bindings page captures keys through its own window**, as `Blizzard_BindingUI` does: the frame's `OnKeyDown` for keys, clicks for mouse buttons, and the wheel. It takes the `Benilla` prefix, so loading the stock binding addon no longer breaks it. The UI pointer feed now hands frames mouse buttons 4 and 5.
- **`/errors` keeps its log by chaining the Lua error handler**, and the stock ScriptErrors dialog is unchanged. Load failures that never raise now print one chat line each at world entry.
- **Slash commands:** `/reload` and `/errors` are `SlashCmdList` rows in the layer. `/convertraid` is gone; 1.12.1 has no such command. The dev commands are host rows in dev builds only, so a player build answers them with the unknown-command line once.
- **Naming:** every global the layer defines is `Benilla`-prefixed. `BenillaBindCapture`, `KeyBindings_OnHostKey`, the three script-error globals and `SubmitChatInput` are removed.

Verified:
- New tests for each rule and each removed surface; 16 mutations each fail a test.
- Region dumps of the Options window (general page, Key Bindings, armed) are identical before and after.
- `scripts/gates.sh` and `scripts/smoke.sh` are green.
- A live run bound a key, a Shift chord and the middle button through the page, and opened `/errors`.

Built on macOS; no platform seam touched.

Fixes #539
Fixes #544
Fixes #407
Part of #171

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 00:12:27 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 0f36374445
commit 05e204de10
48 changed files with 1966 additions and 1495 deletions
@@ -719,8 +719,8 @@ fn backpack() -> ContainerState {
}
}
/// The menu wears the options window's `ERA_WINDOW_SCALE` on show, as the era client draws the two
/// at one density; loaded with OptionsFrame.xml, which defines it.
/// The menu wears the options window's `BENILLA_ERA_WINDOW_SCALE` on show, as the era client draws
/// the two at one density; loaded with OptionsFrame.xml, which defines it.
#[test]
fn the_menu_rides_the_shared_era_window_scale() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -737,7 +737,7 @@ fn the_menu_rides_the_shared_era_window_scale() {
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let k = s.eval::<f64>("return GameMenuFrame:GetScale()").unwrap();
let want = s.eval::<f64>("return ERA_WINDOW_SCALE").unwrap();
let want = s.eval::<f64>("return BENILLA_ERA_WINDOW_SCALE").unwrap();
assert!((k - want).abs() < 1e-6, "menu scale {k} != knob {want}");
assert!((want - 0.78).abs() < 1e-6, "the knob itself moved: {want}");
}
+39 -5
View File
@@ -47,6 +47,18 @@ impl PointerFeed<'_> {
}
}
/// The buttons the pointer feed hands the UI, by the names its handlers read (`arg1` of `OnClick`,
/// `OnMouseDown` and `OnMouseUp`, and the ones `RegisterForClicks` takes,
/// `Blizzard_BindingUI.xml:10`); buttons 4 and 5 on the physical buttons the binding chords call
/// BUTTON4 and BUTTON5 (`bindings::chord`).
const UI_MOUSE_BUTTONS: [(MouseButton, &str); 5] = [
(MouseButton::Left, "LeftButton"),
(MouseButton::Right, "RightButton"),
(MouseButton::Middle, "MiddleButton"),
(MouseButton::Forward, "Button4"),
(MouseButton::Back, "Button5"),
];
/// One `OnMouseWheel` call per whole notch, `arg1 = ±1`, the fraction carried in `notches`: a
/// trackpad gesture arrives as a `Pixel` trickle, and the stock handlers act on the sign alone
/// (`ScrollFrameTemplate_OnMouseWheel` scrolls half a pane per call, `UIPanelTemplates.lua:150`).
@@ -162,11 +174,7 @@ pub(super) fn feed_ui_input(
);
}
}
for (btn, name) in [
(MouseButton::Left, "LeftButton"),
(MouseButton::Right, "RightButton"),
(MouseButton::Middle, "MiddleButton"),
] {
for (btn, name) in UI_MOUSE_BUTTONS {
if buttons.just_pressed(btn) {
// A left press that would drop into the world (`world_drop_click`: any payload over
// terrain or nothing) is consumed now: the drop fires on the release, but the world
@@ -390,6 +398,32 @@ mod tests {
assert!(sum <= 1.0, "…and they may move at most one notch: {sum}");
}
/// Every button 1.12's frames take reaches the UI, under the name its binding chord agrees
/// with, so the Key Bindings page binds mouse 4 and 5 from a click.
#[test]
fn the_ui_takes_all_five_buttons_under_the_chords_names() {
let names: Vec<&str> = UI_MOUSE_BUTTONS.iter().map(|(_, n)| *n).collect();
assert_eq!(
names,
[
"LeftButton",
"RightButton",
"MiddleButton",
"Button4",
"Button5"
]
);
for (i, (button, _)) in UI_MOUSE_BUTTONS.iter().enumerate() {
let chord = crate::bindings::chord::Chord::parse(&format!("BUTTON{}", i + 1));
assert_eq!(
chord.map(|c| c.key),
Some(crate::bindings::chord::BindKey::Mouse(*button)),
"BUTTON{} is {button:?}",
i + 1
);
}
}
#[test]
fn a_line_notch_fires_once() {
let mut s = wheel_pane();
@@ -11,17 +11,7 @@ use crate::bindings::commands::SPECS;
/// does.
pub(crate) fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
let cmds: Vec<KeybindCommand> = SPECS
.iter()
.map(|spec| KeybindCommand {
name: spec.name,
category: spec.category,
run_on_up: spec.run_on_up(),
default1: spec.d1,
default2: spec.d2,
})
.collect();
s.register_bindings(&cmds);
register_specs(&mut s);
s.set_screen_size(1024.0, 768.0);
for file in [
"Interface\\FrameXML\\GlobalStrings.lua",
@@ -54,10 +44,27 @@ pub(crate) fn harness() -> UiScript {
s
}
/// The host's command registry, as `seed_bindings_for_vm` registers it.
pub(crate) fn register_specs(s: &mut UiScript) {
let cmds: Vec<KeybindCommand> = SPECS
.iter()
.map(|spec| KeybindCommand {
name: spec.name,
category: spec.category,
run_on_up: spec.run_on_up(),
default1: spec.d1,
default2: spec.d2,
})
.collect();
s.register_bindings(&cmds);
}
/// A label as the page resolves it: the GlobalStrings global named `token`, else `raw`.
pub(crate) fn label(s: &UiScript, token: &str, raw: &str) -> String {
s.eval::<String>(&format!(r#"return KeyBindings_String("{token}", "{raw}")"#))
.unwrap()
s.eval::<String>(&format!(
r#"return BenillaKeyBindings_String("{token}", "{raw}")"#
))
.unwrap()
}
/// Open the options window on the Keybindings page.
@@ -69,6 +76,53 @@ pub(crate) fn on_page(s: &mut UiScript) {
const ROW: &str = "BenillaOptionsFrameContainerBodyKeybindingsRow";
/// Whether the window takes keys and the wheel: a selected capsule arms it, as 1.12's
/// keyboard-enabled `KeyBindingFrame` (`Blizzard_BindingUI.xml:73`).
pub(crate) fn armed(s: &UiScript) -> bool {
s.eval::<bool>(
"return BenillaOptionsFrame:IsKeyboardEnabled() == 1 \
and BenillaOptionsFrame:IsMouseWheelEnabled() == 1 \
and BenillaKeyBindingsPage.selected ~= nil",
)
.unwrap()
}
/// A key press as the host feeds it (`ui_script::input`): ENTER, ESCAPE and TAB through the named
/// walk, every other key through the frame walk by its 1.12 name. Answers whether a frame took it.
pub(crate) fn press(s: &mut UiScript, key: &str) -> bool {
match key {
"ENTER" | "ESCAPE" | "TAB" => s.key_input(key),
_ => s.frame_key_input(key),
}
}
/// A frame's centre in screen pixels, where the pointer feed hits it.
pub(crate) fn centre(s: &mut UiScript, frame: &str) -> (f32, f32) {
s.resolve();
let (l, r, t, b, k): (f64, f64, f64, f64, f64) = s
.eval(&format!(
"local f = {frame} \
local k = (f.GetEffectiveScale and f or f:GetParent()):GetEffectiveScale() \
return f:GetLeft(), f:GetRight(), f:GetTop(), f:GetBottom(), k"
))
.unwrap();
(((l + r) * 0.5 * k) as f32, ((t + b) * 0.5 * k) as f32)
}
/// A click of `button` on `frame` through the pointer feed.
pub(crate) fn click(s: &mut UiScript, frame: &str, button: &str) {
let (x, y) = centre(s, frame);
s.mouse_move(x, y);
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
}
/// One wheel notch over the page, through the pointer feed.
pub(crate) fn wheel(s: &mut UiScript, delta: f32) {
let (x, y) = centre(s, "BenillaOptionsFrameContainerBodyKeybindings");
s.mouse_wheel(x, y, delta);
}
#[test]
fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
benilla_formats::wow_data_or_skip!();
@@ -90,10 +144,12 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The categories in `Bindings.xml` order, each a header collapsed by default, as in the era.
// The categories in `Bindings.xml` order, each a header collapsed by default, as in the era;
// the spacer headers over multibars 2-4 continue the section above (the page's deviation).
let mut expected: Vec<&str> = Vec::new();
for spec in SPECS {
if !expected.contains(&spec.category) {
if !expected.contains(&spec.category) && !spec.category.starts_with("BINDING_HEADER_BLANK")
{
expected.push(spec.category);
}
}
@@ -125,7 +181,7 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
r#"BINDING_HEADER_MOVEMENT = "Movement Keys"
BINDING_NAME_MOVEANDSTEER = "Move and Steer"
KEY_BUTTON3 = "Middle Mouse"
KeyBindingsPage_Update()"#,
BenillaKeyBindingsPage_Update()"#,
)
.unwrap();
assert_eq!(
@@ -168,21 +224,23 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests();
// Row 3 is MOVEFORWARD (W, UP); selecting its Key 1 capsule arms the capture.
assert!(!s.bind_capture_armed());
// Unarmed, the window takes no key: it reaches the game's bindings.
assert!(!armed(&s));
assert!(!press(&mut s, "J"), "an unarmed page lets a key through");
// Row 3 is MOVEFORWARD (W, UP); selecting its Key 1 capsule arms the window.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(
s.bind_capture_armed(),
"a selected capsule arms the capture"
);
assert!(armed(&s), "a selected capsule arms the window");
assert!(
s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"Unbind arms with the selection"
);
// A lone modifier is no key (`Blizzard_BindingUI.lua:172-174`): still armed.
assert!(press(&mut s, "SHIFT"), "the armed window takes it");
assert!(armed(&s), "…and waits for a real key");
// J, a key no command holds, takes slot 1 from W; UP stays in slot 2 and the bind saves.
s.run(r#"KeyBindings_OnHostKey("J")"#).unwrap();
assert!(!s.bind_capture_armed(), "a completed bind disarms");
assert!(press(&mut s, "J"), "the armed window takes the key");
assert!(!armed(&s), "a completed bind disarms");
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "J" and k2 == "UP""#
@@ -202,7 +260,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// T is ATTACKTARGET's only key, so taking it names the victim in red (1.12's
// `KEY_UNBOUND_ERROR`, `Blizzard_BindingUI.lua:185-190`).
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("T")"#).unwrap();
press(&mut s, "T");
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("T")"#).unwrap(),
"MOVEFORWARD"
@@ -214,9 +272,28 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.contains(&victim),
"the newly-bare victim is named"
);
// A chord: the modifiers held at the press prefix the key, ALT-CTRL-SHIFT order
// (`Blizzard_BindingUI.lua:175-183`).
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
s.set_modifiers(true, true, false);
press(&mut s, "K");
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("CTRL-SHIFT-K")"#)
.unwrap(),
"MOVEFORWARD"
);
// ESC while armed binds Escape: 1.12's armed branch has no ESCAPE filter.
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
assert!(press(&mut s, "ESCAPE"));
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("ESCAPE")"#)
.unwrap(),
"MOVEFORWARD"
);
// The wheel binds onto a press+release command too: `0x4b7490` never reads the command.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("MOUSEWHEELUP")"#).unwrap();
wheel(&mut s, 1.0);
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
.unwrap(),
@@ -234,7 +311,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// A key string the engine rejects restores the slot's old key and shows the only refusal text
// 1.12 has, the wheel's (`KeyBindingFrame_SetBinding`, `Blizzard_BindingUI.lua:260-270`).
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("SCROLLLOCK")"#).unwrap();
press(&mut s, "SCROLLLOCK");
assert!(
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
.unwrap(),
@@ -246,15 +323,130 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"Can't bind mousewheel to actions with up and down states"
);
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
assert!(armed(&s));
s.run(&format!(r#"{ROW}3Key1Button:Click("RightButton")"#))
.unwrap();
assert!(!s.bind_capture_armed(), "right-click deselects");
// Hiding the window disarms: an armed capture with no window would swallow all input.
assert!(!armed(&s), "right-click deselects");
// Hiding the window disarms.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
assert!(armed(&s));
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
assert!(!s.bind_capture_armed(), "OnHide disarms");
assert!(!armed(&s), "OnHide disarms");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Mouse buttons 3-5 bind through the clicks 1.12's window takes: a capsule's own `OnClick`
/// (`Blizzard_BindingUI.lua:246`) and the window's (`Blizzard_BindingUI.xml:538-540`), here the
/// page's `OnMouseUp`; left and right clicks only select.
#[test]
fn mouse_buttons_bind_through_the_capsules_and_the_page() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
let capsule = format!("{ROW}3Key1Button");
click(&mut s, &capsule, "LeftButton");
assert!(armed(&s), "a left click selects");
click(&mut s, &capsule, "MiddleButton");
assert!(!armed(&s));
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("BUTTON3")"#)
.unwrap(),
"MOVEFORWARD",
"a middle click on the armed capsule binds BUTTON3"
);
// Over the page, away from any capsule: mouse 4 with SHIFT held.
click(&mut s, &capsule, "LeftButton");
s.set_modifiers(true, false, false);
click(&mut s, &format!("{ROW}3Description"), "Button4");
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("SHIFT-BUTTON4")"#)
.unwrap(),
"MOVEFORWARD"
);
// Unarmed, a middle click on a capsule selects it, as 1.12's does.
click(&mut s, &format!("{ROW}4Key2Button"), "MiddleButton");
assert!(armed(&s));
s.run("BenillaKeyBindings_SetSelected(nil)").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Loading the stock `Blizzard_BindingUI` (an addon manager's Load button does) defines its own
/// `KeyBindingButton_OnClick` and popup (`Blizzard_BindingUI.lua:228`, `:10`) and changes no other
/// window: the page keeps binding and leaving the character set. The stock window reads the 1.12
/// list: a `HEADER_*` row draws a section header, a command's keys fill Key 1 and Key 2.
#[test]
fn the_page_works_after_the_stock_binding_ui_loads_and_the_stock_window_reads_the_list() {
benilla_formats::wow_data_or_skip!();
let _l = crate::local_state::test_env::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (mut s, failures) =
super::layer_tests::production_load_with("bindingui", false, "", |_| {});
assert!(failures.is_empty(), "load failures: {failures:#?}");
register_specs(&mut s);
s.run(r#"assert(LoadAddOn("Blizzard_BindingUI") == 1)"#)
.unwrap();
assert!(s
.eval::<bool>("return KeyBindingFrame ~= nil and KeyBindingButton_OnClick ~= nil")
.unwrap());
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests();
click(&mut s, &format!("{ROW}3Key1Button"), "LeftButton");
assert!(armed(&s), "the page's capsule still arms its own window");
assert!(
s.eval::<bool>("return KeyBindingFrame.selected == nil")
.unwrap(),
"the stock window is untouched"
);
press(&mut s, "J");
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("J")"#).unwrap(),
"MOVEFORWARD"
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
// The character set, both ways, through the page's own popup.
let check = "BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck";
s.run(&format!("{check}:Click()")).unwrap();
assert_eq!(s.current_binding_set(), 2);
s.run(&format!("{check}:Click()")).unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(
s.current_binding_set(),
1,
"leaving the character set works"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
// The stock window over the same list.
s.run("ShowUIPanel(KeyBindingFrame)").unwrap();
assert!(s
.eval::<bool>("return KeyBindingFrame:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>(
"return KeyBindingFrameBinding1Header:IsVisible() \
and KeyBindingFrameBinding1Header:GetText() == BINDING_HEADER_MOVEMENT"
)
.unwrap());
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding2Key1Button:GetText()")
.unwrap(),
s.eval::<String>(r#"return GetBindingText("BUTTON3", "KEY_")"#)
.unwrap(),
"Move and Steer's first key in the Key 1 column"
);
assert_eq!(
s.eval::<String>("return KeyBindingFrameBinding3Key2Button:GetText()")
.unwrap(),
s.eval::<String>(r#"return GetBindingText("UP", "KEY_")"#)
.unwrap(),
"Move Forward's second key in the Key 2 column"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -283,7 +475,7 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
// Deviation: a bind saves at once, as the era panel commits, where 1.12 had Okay and Cancel;
// closing the window keeps it.
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("G")"#).unwrap();
press(&mut s, "G");
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
s.run("BenillaOptionsFrameCloseButton:Click()").unwrap();
assert_eq!(
@@ -384,8 +576,8 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
.unwrap());
s.run("BenillaOptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
.unwrap();
assert!(s.bind_capture_armed());
s.run(r#"KeyBindings_OnHostKey("H")"#).unwrap();
assert!(armed(&s));
press(&mut s, "H");
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("H")"#).unwrap(),
"JUMP"
@@ -474,8 +666,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
on_page(&mut s);
// Every section open: over 100 rows overflow the list's 19 slots.
s.run(
r#"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, true) end
KeyBindingsPage_Update()"#,
r#"for i = 1, table.getn(BenillaKeyBindingsPage.sections) do BenillaKeyBindings_ExpandSection(i, true) end
BenillaKeyBindingsPage_Update()"#,
)
.unwrap();
const SF: &str = "BenillaOptionsFrameContainerBodyKeybindingsScrollFrame";
@@ -536,8 +728,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
);
let set_all = |s: &UiScript, open: bool| {
s.run(&format!(
"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, {}) end
KeyBindingsPage_Update()",
"for i = 1, table.getn(BenillaKeyBindingsPage.sections) do BenillaKeyBindings_ExpandSection(i, {}) end
BenillaKeyBindingsPage_Update()",
if open { "true" } else { "nil" }
))
.unwrap();
@@ -603,9 +795,11 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
after_capsule > after_name,
"a spin over a CAPSULE bubbles to the page too: {after_name} -> {after_capsule}"
);
// While a capsule is armed a spin binds, never scrolls; the page's guard catches a spin the
// host lets through.
// While a capsule is armed a spin binds, never scrolls.
s.run(&format!("{ROW}2Key1Button:Click()")).unwrap();
let armed_command = s
.eval::<String>(&format!("return {ROW}2Key1Button.commandName"))
.unwrap();
s.mouse_wheel(wx, wy, -1.0);
assert_eq!(
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
@@ -613,7 +807,12 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
after_capsule,
"armed: the wheel must not scroll"
);
s.run("KeyBindings_SetSelected(nil)").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("MOUSEWHEELDOWN")"#)
.unwrap(),
armed_command,
"armed: the spin binds"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -626,11 +825,14 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
let mut s = harness();
on_page(&mut s);
// The header row a command sits under: the last `HEADER_*` row above it.
let category = |s: &mut UiScript, name: &str| {
s.eval::<String>(&format!(
r#"for i = 1, GetNumBindings() do
local n, c = GetBinding(i)
if n == "{name}" then return c end
r#"local header
for i = 1, GetNumBindings() do
local n = GetBinding(i)
if strsub(n, 1, 6) == "HEADER" then header = n end
if n == "{name}" then return header end
end"#
))
.unwrap()
@@ -639,7 +841,7 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
let name = format!("BONUSACTIONBUTTON{i}");
assert_eq!(
category(&mut s, &name),
"BINDING_HEADER_ACTIONBAR",
"HEADER_ACTIONBAR",
"{name} files under the action bar, as 1.12 does"
);
let key = s
+230 -10
View File
@@ -236,8 +236,8 @@ fn the_stock_ui_switch_is_a_dev_builds_alone() {
assert!(layer_enabled_by(true, None) && layer_enabled_by(true, Some("0")));
}
/// Booted without the layer, as `WOW_STOCK_UI=1` does: no layer file loads, the stock files raise
/// nothing, and the host's calls into the layer are no-ops.
/// Booted without the layer, as `WOW_STOCK_UI=1` does: no layer file loads and the stock files
/// raise nothing.
#[test]
fn the_stock_ui_switch_boots_the_core_without_the_layer() {
benilla_formats::wow_data_or_skip!();
@@ -250,7 +250,7 @@ fn the_stock_ui_switch_boots_the_core_without_the_layer() {
// One global from each layer file that defines a new one.
for name in [
"BenillaScrollBar_Step",
"KeyBindings_OnHostKey",
"BenillaKeyBindings_OnKeyDown",
"BenillaOptionsFrame_SelectCategory",
"BenillaGameMenuButtonEditMode",
"BENILLA_BAG_WAS_OPEN",
@@ -263,13 +263,233 @@ fn the_stock_ui_switch_boots_the_core_without_the_layer() {
}
assert!(!s.eval::<bool>("return BenillaOptionsFrame ~= nil").unwrap());
assert!(!s.eval::<bool>("return ZZORDER_SAW_LAYER").unwrap());
// The host's calls into the layer.
for body in [
super::ERRORS_TOGGLE.to_string(),
super::ERRORS_CLEAR.to_string(),
super::host_key_capture("CTRL-J"),
] {
s.run(&body).unwrap_or_else(|e| panic!("{body}: {e}"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Types `line` into the chat box and sends it through the stock `ChatEdit_ParseText`
/// (`ChatFrame.lua:2087`), counting the `HELP_TEXT_SIMPLE` lines it prints.
fn type_line(s: &UiScript, line: &str) -> i64 {
s.run(&format!(
r#"HELPS = 0
local box = ChatFrameEditBox
box.chatFrame = box.chatFrame or DEFAULT_CHAT_FRAME
local frame = box.chatFrame
local add = frame.AddMessage
frame.AddMessage = function(f, text, r, g, b, id)
if text == HELP_TEXT_SIMPLE then HELPS = HELPS + 1 end
return add(f, text, r, g, b, id)
end
box:SetText({line:?})
ChatEdit_ParseText(box, 1)
frame.AddMessage = add"#
))
.unwrap_or_else(|e| panic!("{line}: {e}"));
s.eval("return HELPS").unwrap()
}
/// The layer's player commands are plain `SlashCmdList` rows: `/reload` runs `ReloadUI()`,
/// `/errors` (`/err`) toggles the log and `/errors clear` empties it. 1.12 has no `/convertraid`
/// (the Raid tab's button converts, `RaidFrame.lua:63-71`), so it answers `HELP_TEXT_SIMPLE`, and
/// the forwarding hook `SubmitChatInput` is gone.
#[test]
fn the_layers_commands_are_slash_rows_and_convertraid_is_unknown() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (mut s, failures) = production_load("slash", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
assert!(s.eval::<bool>("return SubmitChatInput == nil").unwrap());
s.take_session_requests();
assert_eq!(type_line(&s, "/reload"), 0);
assert_eq!(
s.take_session_requests(),
[benilla_ui::script::SessionRequest::ReloadUi],
"/reload is ReloadUI()"
);
assert!(
s.take_chat_input().is_empty(),
"nothing is handed to the host"
);
assert_eq!(type_line(&s, "/errors"), 0);
assert!(s
.eval::<bool>("return BenillaScriptLogFrame:IsVisible()")
.unwrap());
assert_eq!(type_line(&s, "/err"), 0);
assert!(!s
.eval::<bool>("return BenillaScriptLogFrame:IsVisible()")
.unwrap());
s.run("BenillaScriptLog_Record('kept')").unwrap();
assert_eq!(type_line(&s, "/errors clear "), 0);
assert_eq!(
s.eval::<i64>("return table.getn(BenillaScriptLog.rows)")
.unwrap(),
0,
"/errors clear empties the log"
);
assert_eq!(type_line(&s, "/convertraid"), 1, "the unknown-command line");
assert!(s.take_chat_input().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// In a player build the dev instruments are no `SlashCmdList` row, so a typed one answers
/// `HELP_TEXT_SIMPLE` once and hands the host nothing to re-run; in a dev build each is a host row
/// that hands the host its line.
#[test]
fn the_dev_commands_are_host_rows_in_a_dev_build_alone() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let lines = [
"/chattest",
"/shot",
"/liquid",
"/castvis",
"/partytest ping",
"/reaction",
"/react Probe",
];
let (mut s, failures) = production_load("devcmds-player", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
crate::ui_chat::commands::register_dev_commands(&s, false);
for line in lines {
assert_eq!(
type_line(&s, line),
1,
"{line}: the unknown-command line, once"
);
assert!(
s.take_chat_input().is_empty(),
"{line}: nothing queued to loop"
);
let cmd = line[1..].split(' ').next().unwrap();
assert!(!s.has_slash_command(cmd), "{cmd} is no SlashCmdList row");
}
drop(s);
let (mut s, failures) = production_load("devcmds-dev", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
crate::ui_chat::commands::register_dev_commands(&s, true);
for line in lines {
assert_eq!(type_line(&s, line), 0, "{line}");
let queued = s.take_chat_input();
assert_eq!(queued.len(), 1, "{line}: {queued:?}");
// An alias queues under the command's first one, as the row's handler knows only that.
let want = line.replacen("/react ", "/reaction ", 1);
assert_eq!(queued[0].trim(), want, "{line}");
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The `/errors` log collects off the Lua error handler, ahead of the stock `_ERRORMESSAGE`
/// (`BasicControls.xml:16`), which still shows the `ScriptErrors` dialog: an addon's runtime error,
/// an error in one of the layer's own handlers, and one raised in the stock load before the layer
/// loaded (the engine hands caught errors to the handler after the load).
#[test]
fn the_error_log_collects_off_the_error_handler() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let before = r#"
local pre = CreateFrame("Button", "ZZPreLayer")
pre:SetScript("OnClick", function() error("before the layer") end)
pre:Click()
"#;
let (mut s, failures) = production_load_with("errlog", false, before, |root| {
let dir = root.join("ZZErr");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ZZErr.toc"), "## Interface: 11200\nerr.lua\n").unwrap();
std::fs::write(
dir.join("err.lua"),
"ZZErrButton = CreateFrame('Button', 'ZZErrButton')\n\
ZZErrButton:SetScript('OnClick', function() error('an addon boom') end)\n",
)
.unwrap();
});
assert!(
failures.iter().all(|f| f.contains("before the layer")),
"load failures: {failures:#?}"
);
s.run("ZZErrButton:Click()").unwrap();
// A layer handler raising: the window's OnShow calls this.
s.run("BenillaOptionsFrame_UpdateScale = function() error('a layer boom') end")
.unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.dispatch_script_errors_to_handler();
let rows: Vec<String> = s
.eval(
"local out = {} \
for _, row in ipairs(BenillaScriptLog.rows) do table.insert(out, row.message) end \
return out",
)
.unwrap();
for want in ["before the layer", "an addon boom", "a layer boom"] {
assert!(
rows.iter().any(|r| r.contains(want)),
"{want:?} is in the log: {rows:#?}"
);
}
assert!(
s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap(),
"the stock handler still runs after the log"
);
let shown: String = s.eval("return ScriptErrors_Message:GetText()").unwrap();
assert!(
shown.contains("before the layer"),
"the first error shows: {shown}"
);
// A repeat is one row with a count.
s.run("ZZErrButton:Click() ZZErrButton:Click()").unwrap();
s.dispatch_script_errors_to_handler();
assert_eq!(
s.eval::<i64>(
"for _, row in ipairs(BenillaScriptLog.rows) do \
if strfind(row.message, 'an addon boom', 1, 1) then return row.count end \
end"
)
.unwrap(),
3
);
}
/// Every global the layer defines takes the `Benilla` prefix (a slash alias its `SLASH_BENILLA_`
/// form, as `ChatEdit_ParseText` reads it), so no layer name meets a stock or an addon one; a stock
/// global the layer redefines keeps its name and is not a new definition here.
#[test]
fn every_global_the_layer_defines_takes_the_benilla_prefix() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("prefix", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let log: Vec<String> = s.eval("return DEFINE_LOG").unwrap();
let start = defined_at(&log, "BenillaScrollBar_Step");
let end = defined_at(&log, "ZZORDER_SAW_LAYER");
let dispatch = |n: &str| {
n == "this"
|| n == "event"
|| n.strip_prefix("arg")
.is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
};
let unprefixed: Vec<&String> = log[start..end]
.iter()
.filter(|n| {
!(n.starts_with("Benilla")
|| n.starts_with("BENILLA_")
|| n.starts_with("SLASH_BENILLA_"))
&& !dispatch(n)
})
.collect();
assert!(
unprefixed.is_empty(),
"layer globals without the Benilla prefix: {unprefixed:?}"
);
}
+14 -16
View File
@@ -367,6 +367,7 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
// No sound during the load edge, as in the reference's `UI_Init` (`0x48fbfa` → `0x49016d`).
silenced_ui_load(&mut script, |script| {
let _ = load_ingame_ui(script, identity.as_ref(), &roster, version_check);
crate::ui_chat::commands::register_dev_commands(script, crate::run_mode::dev_affordances());
// The new Minimap's zoom indices from the persisted CVars, as the reference's minimap reset
// copies each CVar into its live index; `Minimap:SetZoom` owns them from here.
script.set_minimap_zoom(zoom.0, zoom.1);
@@ -385,25 +386,22 @@ pub(crate) fn load_ingame_ui_on_world_entry(world: &mut World) {
},
);
});
// Deviation: failed addons are reported in chat, because otherwise an addon that does not
// load says nothing on screen. Counted from the deduplicated diagnostics log as `/errors`
// lists them, after the whole load edge (handlers included); the VM is fresh, so every `Load`
// row is this load's.
let failed = script
.diagnostics()
.iter()
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Load)
.count();
if failed > 0 {
if let Some(mut chat) = world.get_resource_mut::<crate::ui_chat::ChatLog>() {
// Deviation: each failed addon file is reported in chat, one line apiece, because otherwise
// an addon that does not load says nothing on screen: a failure that never raised reaches no
// error handler, so the `/errors` log cannot hold it. Read from the engine's deduplicated
// record after the whole load edge (handlers included); the VM is fresh, so every `Load` row
// is this load's.
if let Some(mut chat) = world.get_resource_mut::<crate::ui_chat::ChatLog>() {
for row in script
.diagnostics()
.iter()
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Load)
{
let head = row.message.lines().next().unwrap_or_default();
// Queued: the chat feed drains `ChatLog` once the UI is up.
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
format!(
"{failed} addon load {} — type /errors to see {}.",
if failed == 1 { "failure" } else { "failures" },
if failed == 1 { "it" } else { "them" }
),
format!("Addon load failure: {head}"),
));
}
}
@@ -26,19 +26,6 @@ pub(super) fn layer_enabled_by(dev_build: bool, stock_ui: Option<&str>) -> bool
!(dev_build && stock_ui == Some("1"))
}
/// `/errors`: toggles the layer's script error log; a no-op when the run boots without the layer.
pub(crate) const ERRORS_TOGGLE: &str =
"if BenillaScriptLog_Toggle then BenillaScriptLog_Toggle() end";
/// `/errors clear`: empties the layer's script error log, or does nothing without the layer.
pub(crate) const ERRORS_CLEAR: &str = "if BenillaScriptLog_Clear then BenillaScriptLog_Clear() end";
/// Hands a chord the layer's Keybindings page is capturing (`BenillaBindCapture`), a bind string
/// such as `CTRL-J`, to its handler; a no-op without the layer.
pub(crate) fn host_key_capture(chord: &str) -> String {
format!("if KeyBindings_OnHostKey then KeyBindings_OnHostKey(\"{chord}\") end")
}
/// Every entry in load order: the core's rows, none without an install, then the layer's.
#[cfg(test)]
pub(super) fn manifest_files() -> Vec<String> {
-1
View File
@@ -34,7 +34,6 @@ pub(crate) use session::VmMemo;
// Not test-only: the addon harness loads the whole shipped interface under each addon.
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::load_ingame_ui;
pub(crate) use manifest::{host_key_capture, ERRORS_CLEAR, ERRORS_TOGGLE};
/// Whether the pointer is over any UI (the egui dev overlay or a player-UI frame), combined by
/// [`arbitrate_pointer_over_ui`]; gameplay reads it, so it is not the dev plugin's.
@@ -173,7 +173,7 @@ fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Scale 1, so rects and the pointer share coordinates; at 1024x768 the fit clamp stays above 1.
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert_eq!(
@@ -275,7 +275,7 @@ fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.resolve();
@@ -514,7 +514,7 @@ fn a_track_press_seats_the_thumb_and_keeps_dragging() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.set_cvar_host("MasterVolume", "0.1");
s.run("ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
@@ -1839,7 +1839,7 @@ fn defaults_resets_the_controls_page_to_registered_defaults() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Run a few frames so the page body's fit converges: `OptionsScroll_Fit` reads the previous
/// Run a few frames so the page body's fit converges: `BenillaOptionsScroll_Fit` reads the previous
/// frame's rects.
fn settle(s: &mut UiScript) {
for _ in 0..4 {
@@ -1942,11 +1942,14 @@ fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
clip.bottom
);
// Widget coordinates here, not the extract's pixels: the window carries `ERA_WINDOW_SCALE`.
// Widget coordinates here, not the extract's pixels: the window carries `BENILLA_ERA_WINDOW_SCALE`.
let sf_bottom: f32 = s
.eval("return BenillaOptionsFrameContainerScroll:GetBottom()")
.unwrap();
let tail = |s: &UiScript| -> f32 { s.eval("return OptionsScroll_ContentBottom()").unwrap() };
let tail = |s: &UiScript| -> f32 {
s.eval("return BenillaOptionsScroll_ContentBottom()")
.unwrap()
};
assert!(
tail(&s) < sf_bottom,
"the tail starts below the fold ({} vs {sf_bottom})",
@@ -2068,7 +2071,7 @@ fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
// ── The row tooltips ─────────────────────────────────────────────────────────────
/// Hover a row's label half, 60 units in at mid-height. Callers pin `ERA_WINDOW_SCALE = 1` first,
/// Hover a row's label half, 60 units in at mid-height. Callers pin `BENILLA_ERA_WINDOW_SCALE = 1` first,
/// so the row's rect and the pointer share coordinates.
fn hover_label(s: &mut UiScript, frame: &str) {
s.resolve();
@@ -2112,7 +2115,7 @@ fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Checking this will prevent the deselection.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let row = "BenillaOptionsFrameContainerBodyControlsRowStickyTarget";
@@ -2181,7 +2184,7 @@ fn a_row_with_no_1_12_string_raises_no_plate() {
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Sticky's own description.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
hover_label(
&mut s,
@@ -2201,10 +2204,10 @@ fn a_row_with_no_1_12_string_raises_no_plate() {
);
// Sticky's plate up, then straight into the mute row with no OnLeave in between.
s.run("OptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowStickyTarget, 1)")
s.run("BenillaOptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowStickyTarget, 1)")
.unwrap();
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
s.run("OptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowAutoLoot, 1)")
s.run("BenillaOptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowAutoLoot, 1)")
.unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
@@ -2232,7 +2235,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
let listing: String = s
.eval(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for page, rows in pairs(BENILLA_OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
table.insert(out, page .. rkey .. \"=\" .. (row.tip or \"\")) \
@@ -2320,7 +2323,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
let mut s = harness_on(audio_harness());
// Stand-in strings for every key the rows name.
s.run(
"for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
"for page, rows in pairs(BENILLA_OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
if row.tip then setglobal(row.tip, \"described: \" .. rkey) end \
@@ -2328,7 +2331,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
end",
)
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("BENILLA_ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let mut raised = 0;
@@ -2346,7 +2349,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
.unwrap();
let rows: String = s
.eval(&format!(
"return table.concat(OPTIONS_PAGE_ROWS.{page}, \",\")"
"return table.concat(BENILLA_OPTIONS_PAGE_ROWS.{page}, \",\")"
))
.unwrap();
for rkey in rows.split(',') {
@@ -2387,7 +2390,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
/// CVar, and runs the family's apply, `CombatText_UpdateOrLoad`.
/// CVar, and runs the family's apply, `BenillaCombatText_UpdateOrLoad`.
#[test]
fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
benilla_formats::wow_data_or_skip!();
@@ -3236,7 +3239,7 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let keys: Vec<String> = s
.eval::<String>("return table.concat(OPTIONS_CATEGORY_KEYS, \",\")")
.eval::<String>("return table.concat(BENILLA_OPTIONS_CATEGORY_KEYS, \",\")")
.unwrap()
.split(',')
.map(str::to_string)
@@ -3245,8 +3248,8 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
for key in &keys {
let has_rows = s
.eval::<bool>(&format!(
"return OPTIONS_PAGE_ROWS[\"{key}\"] ~= nil and \
getn(OPTIONS_PAGE_ROWS[\"{key}\"]) > 0"
"return BENILLA_OPTIONS_PAGE_ROWS[\"{key}\"] ~= nil and \
getn(BENILLA_OPTIONS_PAGE_ROWS[\"{key}\"]) > 0"
))
.unwrap();
assert!(
@@ -3966,7 +3969,7 @@ fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
assert_eq!(
num(&mut s, "BenillaOptionsFrame:GetScale()"),
0.78,
"ERA_WINDOW_SCALE"
"BENILLA_ERA_WINDOW_SCALE"
);
for tab in ["BenillaOptionsFrameGameTab", "BenillaOptionsFrameAddOnsTab"] {
@@ -4156,7 +4159,7 @@ fn every_registered_reference_cvar_has_a_row_on_our_own_window() {
let ours: std::collections::HashSet<String> = s
.eval::<Vec<String>>(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for page, rows in pairs(BENILLA_OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
if row.cvar then table.insert(out, row.cvar) end \
@@ -4396,7 +4399,7 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
.unwrap()
};
assert!(speed_thumb(&mut s));
s.run("SetCVar(\"cameraSmoothStyle\", \"0\") OptionsPage_Refresh(\"Controls\")")
s.run("SetCVar(\"cameraSmoothStyle\", \"0\") BenillaOptionsPage_Refresh(\"Controls\")")
.unwrap();
assert!(
!speed_thumb(&mut s),
@@ -4410,7 +4413,7 @@ fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style()
:IsMouseEnabled()"
)
.unwrap());
s.run("SetCVar(\"cameraSmoothStyle\", \"1\") OptionsPage_Refresh(\"Controls\")")
s.run("SetCVar(\"cameraSmoothStyle\", \"1\") BenillaOptionsPage_Refresh(\"Controls\")")
.unwrap();
assert!(speed_thumb(&mut s));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1964,3 +1964,75 @@ fn the_layer_does_not_grow() {
"assets/ui holds the layer's manifest and its files, nothing else"
);
}
/// The layer speaks the 1.12 dialect: a handler body is the chunk itself (`0x704c70`), so it reads
/// its frame from `this`, never `self`; the loader reads no `function=`; the strata table
/// (`0x8119f8`) has no `BLIZZARD`; `SetDrawLayer` (`0x79a780`) takes a layer name alone; and a
/// button's state textures take a texture, never a colour (`0x781970`).
#[test]
fn the_layer_speaks_the_1_12_dialect() {
fn handlers(el: &benilla_ui::framexml::Element, out: &mut Vec<(String, String)>) {
if el.tag == "Scripts" {
for h in &el.children {
out.push((h.tag.clone(), h.body.clone()));
assert!(
h.attr("function").is_none(),
"<{} function=…> is no 1.12 form",
h.tag
);
}
}
for c in &el.children {
handlers(c, out);
}
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let files = super::addons::Addon::layer().toc.files;
assert!(files.len() >= 8, "the layer's toc lists {files:?}");
let word = |text: &str, w: &str| {
text.match_indices(w).any(|(i, _)| {
let before = text[..i].chars().next_back();
let after = text[i + w.len()..].chars().next();
!before.is_some_and(|c| c.is_alphanumeric() || c == '_')
&& !after.is_some_and(|c| c.is_alphanumeric() || c == '_')
})
};
for file in files {
let text = std::fs::read_to_string(dir.join(&file)).expect("read");
let doc = benilla_ui::framexml::parse(&text).expect("parses");
let mut found = Vec::new();
for item in &doc.items {
use benilla_ui::framexml::TopLevel;
if let TopLevel::Template(el) | TopLevel::Instance(el) = item {
handlers(el, &mut found);
}
}
for (tag, body) in found {
assert!(!word(&body, "self"), "{file}: <{tag}> reads `self`: {body}");
}
assert!(!text.contains("BLIZZARD"), "{file}: BLIZZARD strata");
for line in text.lines().filter(|l| l.contains("SetDrawLayer(")) {
assert_eq!(
line.matches(',').count(),
0,
"{file}: SetDrawLayer takes the layer alone: {line}"
);
}
for verb in [
"SetNormalTexture(",
"SetPushedTexture(",
"SetHighlightTexture(",
"SetDisabledTexture(",
"SetCheckedTexture(",
"SetDisabledCheckedTexture(",
] {
for line in text.lines().filter(|l| l.contains(verb)) {
let args = line.split(verb).nth(1).unwrap_or_default();
assert!(
!args.trim_start().starts_with(|c: char| c.is_ascii_digit()),
"{file}: {verb} takes a texture, never a colour: {line}"
);
}
}
}
}
@@ -865,8 +865,9 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
let _ = std::fs::remove_dir_all(&tmp);
}
/// An addon that fails to load without raising (a `.toc` naming a file the package lacks) is kept
/// in the log, readable from Lua by the error window, and announced in chat.
/// An addon that fails to load without raising (a `.toc` naming a file the package lacks) reaches
/// no Lua error handler, so the `/errors` log (which collects off the handler) does not hold it:
/// the engine's record keeps it, and a chat line names it.
#[test]
fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
benilla_formats::wow_data_or_skip!();
@@ -914,57 +915,20 @@ fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
row.message
);
// 2. Readable from Lua through the window's own reads.
let count: i64 = script
.eval("local shown = BenillaGetNumScriptErrors() return shown")
.expect("BenillaGetNumScriptErrors is installed");
assert!(count >= 1, "the window's own read sees it");
let seen: String = script
.eval(
"local text = '' \
for i = 1, BenillaGetNumScriptErrors() do \
local seq, kind, message = BenillaGetScriptErrorInfo(i) \
if kind == 'load' then text = message end \
end \
return text",
)
.expect("BenillaGetScriptErrorInfo is installed");
assert!(
seen.contains("AaMissing"),
"the window walks the log and finds it: {seen:?}"
);
assert!(
script
.eval::<bool>("return BenillaScriptLogFrame ~= nil")
.expect("eval"),
"ScriptLogFrame.xml loaded and built the window"
);
// The repaint runs over a real row, so a nil global anywhere on its path raises here.
// 2. Not in the `/errors` log: it never reached the error handler.
script
.eval::<()>("BenillaScriptLog_Update() return nil")
.expect("the window repaints over a real log without raising");
// Some row shows it, not necessarily row 1: the log also holds the warnings a world entry
// raises before any addon loads.
let row_labels: Vec<String> = (1..=13)
.filter_map(|i| {
script
.eval::<Option<String>>(&format!("return BenillaScriptLogRow{i}Label:GetText()"))
.expect("eval")
})
.collect();
.expect("the window repaints without raising");
assert!(
row_labels.iter().any(|l| l.contains("AaMissing")),
"a row shows the failure, trimmed to the row's width: {row_labels:?}"
);
let summary: String = script
.eval::<Option<String>>("return BenillaScriptLogSummary:GetText()")
.expect("eval")
.unwrap_or_default();
assert!(
summary.contains("problem"),
"the summary line counted them: {summary:?}"
!script
.eval::<bool>(
"for _, row in ipairs(BenillaScriptLog.rows) do \
if strfind(row.message, 'AaMissing', 1, 1) then return true end \
end \
return false"
)
.expect("eval"),
"a failure no handler saw is not in the handler-fed log"
);
// 3. No dialog, since nothing raised: the reference answers an absent or unparseable file
@@ -977,12 +941,14 @@ fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
through `_ERRORMESSAGE` and through every addon handler that replaces it"
);
// 4. Deviation: a chat line tells the player to look, where the reference stays silent,
// because an unannounced log goes unread.
assert_eq!(
world.resource::<crate::ui_chat::ChatLog>().pending_len(),
1,
"world entry queued the 'N addon load failures — type /errors' line"
// 4. Deviation: a chat line names the failure, where the reference stays silent, because
// otherwise it reaches no screen.
let lines = world.resource::<crate::ui_chat::ChatLog>().pending_lines();
assert!(
lines
.iter()
.any(|l| l.starts_with("Addon load failure: ") && l.contains("AaMissing")),
"world entry queued the failure's line: {lines:?}"
);
drop(world);