Files
benilla-octo/crates/benilla-app/src/ui_script/layer_tests.rs
T
Sam SungandClaude Opus 5.5 05e204de10 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>
2026-09-29 00:12:27 +07:00

496 lines
18 KiB
Rust

//! benilla's layer ([`super::manifest::LAYER_MANIFEST`]) loads as more of the stock load: after
//! every core file, before the first third-party addon, with no `ADDON_LOADED` and no AddOn API
//! row, as 1.12.1 loads FrameXML (`UI_Init 0x48fbf0`: `FrameXML.toc` at `0x48ffed`, the addons at
//! `0x4900a3`, only the latter through `AddOn_Load`); and a dev build boots without it.
use benilla_ui::script::UiScript;
use crate::local_state::test_env::{EnvGuard, ENV_LOCK};
/// Records each global's first definition in order: `__newindex` fires only for a new key, so a
/// file that redefines a stock function is not logged, and one that loads stock code is.
const DEFINE_LOG: &str = r#"
DEFINE_LOG = {}
setmetatable(getfenv(0), { __newindex = function(t, k, v)
table.insert(DEFINE_LOG, k)
rawset(t, k, v)
end })
"#;
/// Records every `ADDON_LOADED` from before the load.
const ADDON_LOADED_LOG: &str = r#"
ADDON_LOADED_LOG = {}
local f = CreateFrame("Frame")
f:RegisterEvent("ADDON_LOADED")
f:SetScript("OnEvent", function() table.insert(ADDON_LOADED_LOG, arg1) end)
"#;
/// A VM after the production in-game load, with one third-party addon, `ZZOrder`, in a hermetic
/// AddOns root; the layer on unless `stock_ui`. Returns the VM and the load's failures.
fn production_load(tag: &str, stock_ui: bool) -> (UiScript, Vec<String>) {
let before = format!("{DEFINE_LOG}\n{ADDON_LOADED_LOG}");
production_load_with(tag, stock_ui, &before, |root| {
let dir = root.join("ZZOrder");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ZZOrder.toc"), "## Interface: 11200\nprobe.lua\n").unwrap();
std::fs::write(
dir.join("probe.lua"),
"ZZORDER_SAW_LAYER = BenillaOptionsFrame ~= nil\n",
)
.unwrap();
})
}
/// The production in-game load, the layer on unless `stock_ui`, over a hermetic AddOns root that
/// `addons` fills; `before` runs ahead of the load. The caller holds [`ENV_LOCK`]. Returns the VM
/// and the load's failures.
pub(super) fn production_load_with(
tag: &str,
stock_ui: bool,
before: &str,
addons: impl FnOnce(&std::path::Path),
) -> (UiScript, Vec<String>) {
let tmp = std::env::temp_dir().join(format!("benilla-layer-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
let root = home.join("AddOns");
std::fs::create_dir_all(&root).unwrap();
addons(&root);
let _c = EnvGuard::unset("WOW_CAPTURE");
let _h = EnvGuard::set("BENILLA_HOME", home.to_str().unwrap());
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probesix".into()),
level: 60,
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..Default::default()
}),
);
// A reply that hid nothing, so `GetNumAddOns` counts the registry.
s.note_addon_info_reply(&[]);
s.register_cvars(crate::cvars::registered_pairs());
s.run(before).unwrap();
// The layer passed in, not set through `WOW_STOCK_UI`: every test in this process reads it.
let mut failures = Vec::new();
failures.extend(super::manifest::load_ingame_ui_with(
&mut s,
None,
&[],
true,
!stock_ui,
));
failures.extend(s.errors());
let _ = std::fs::remove_dir_all(&tmp);
(s, failures)
}
/// Where `name` was first defined in [`DEFINE_LOG`].
fn defined_at(log: &[String], name: &str) -> usize {
log.iter()
.position(|n| n == name)
.unwrap_or_else(|| panic!("{name} was never defined in the load"))
}
/// The stock FrameXML's own globals, from the captured 1.12.1 `_G` (`reference/1.12-globals.tsv`):
/// the `framexml` rows a FrameXML file defines at load, LoadOnDemand ones aside.
fn stock_framexml_globals() -> std::collections::HashSet<String> {
let tsv = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-globals.tsv"
));
tsv.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| {
let mut cols = l.split('\t');
let (name, kind, origin) = (cols.next()?, cols.next()?, cols.next()?);
(origin == "framexml" && kind != "lod").then(|| name.to_string())
})
.collect()
}
/// The layer loads after every stock file and before the first third-party addon: the core's last
/// rows define before the layer's first, the layer's last before the addon, and nothing first
/// defined from the layer's start on is a stock FrameXML global.
#[test]
fn the_layer_loads_after_every_stock_file_and_before_the_addons() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("order", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let log: Vec<String> = s.eval("return DEFINE_LOG").unwrap();
// The core's last row that defines a global (RaidWarning.xml; ClassTrainerFrameTemplates.xml
// declares templates alone), the layer's first file (ScrollTemplates.xml) and last
// (FrameXMLFixes.xml is all redefinitions, so ScriptLogFrame.xml), then the addon.
let order = [
"RaidWarningFrame_OnLoad",
"BenillaScrollBar_Step",
"BenillaScriptLog_Toggle",
"ZZORDER_SAW_LAYER",
];
let at: Vec<usize> = order.iter().map(|n| defined_at(&log, n)).collect();
for w in 0..order.len() - 1 {
assert!(
at[w] < at[w + 1],
"{} (#{}) must be defined before {} (#{})",
order[w],
at[w],
order[w + 1],
at[w + 1]
);
}
let stock = stock_framexml_globals();
assert!(stock.len() > 5000, "the reference's _G parse is broken");
// The engine sets `this`, `event` and `arg1`-`arg9` around each handler it runs.
let dispatch = |n: &str| {
n == "this"
|| n == "event"
|| n.strip_prefix("arg")
.is_some_and(|d| d.len() == 1 && d.as_bytes()[0].is_ascii_digit())
};
let late: Vec<&String> = log[at[1]..]
.iter()
.filter(|n| stock.contains(n.as_str()) && !dispatch(n))
.collect();
assert!(
late.is_empty(),
"stock FrameXML globals first defined after the layer began — a stock file loaded inside \
or after the layer: {late:?}"
);
assert!(
s.eval::<bool>("return ZZORDER_SAW_LAYER").unwrap(),
"the addon's file scope sees the layer"
);
}
/// The layer is no addon: no `ADDON_LOADED` of its own, no row in the AddOn API, and no row on the
/// character-select AddOns screen.
#[test]
fn the_layer_is_absent_from_the_addon_api() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("api", false);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let loaded: Vec<String> = s.eval("return ADDON_LOADED_LOG").unwrap();
assert_eq!(
loaded,
["ZZOrder"],
"only the third-party addon announces itself"
);
let names: Vec<String> = s
.eval(
"local out = {} \
for i = 1, GetNumAddOns() do table.insert(out, (GetAddOnInfo(i))) end \
return out",
)
.unwrap();
assert!(
names.iter().any(|n| n == "ZZOrder"),
"the probe sees the registry: {names:?}"
);
for name in ["benilla", "layer"] {
assert!(
!names.iter().any(|n| n.eq_ignore_ascii_case(name)),
"{name} is a row in the AddOn API: {names:?}"
);
assert!(
!s.eval::<bool>(&format!(
"return IsAddOnLoaded(\"{name}\") and true or false"
))
.unwrap(),
"IsAddOnLoaded(\"{name}\") answers"
);
}
assert!(
!super::addons::installed_rows()
.iter()
.any(|a| a.name.eq_ignore_ascii_case("benilla")),
"the AddOns screen lists benilla"
);
}
/// `WOW_STOCK_UI=1` drops the layer in a dev build alone; a player build always loads it.
#[test]
fn the_stock_ui_switch_is_a_dev_builds_alone() {
use super::manifest::layer_enabled_by;
assert!(
!layer_enabled_by(true, Some("1")),
"a dev build boots without it"
);
assert!(
layer_enabled_by(false, Some("1")),
"a player build ignores the switch"
);
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 and the stock files
/// raise nothing.
#[test]
fn the_stock_ui_switch_boots_the_core_without_the_layer() {
benilla_formats::wow_data_or_skip!();
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (s, failures) = production_load("stock", true);
assert!(failures.is_empty(), "load failures: {failures:#?}");
let log: Vec<String> = s.eval("return DEFINE_LOG").unwrap();
// One global from each layer file that defines a new one.
for name in [
"BenillaScrollBar_Step",
"BenillaKeyBindings_OnKeyDown",
"BenillaOptionsFrame_SelectCategory",
"BenillaGameMenuButtonEditMode",
"BENILLA_BAG_WAS_OPEN",
"BenillaScriptLog_Toggle",
] {
assert!(
!log.iter().any(|n| n == name),
"{name} is defined: a layer file loaded under WOW_STOCK_UI=1"
);
}
assert!(!s.eval::<bool>("return BenillaOptionsFrame ~= nil").unwrap());
assert!(!s.eval::<bool>("return ZZORDER_SAW_LAYER").unwrap());
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:?}"
);
}