Files
benilla-octo/crates/benilla-ui/src/script/tests/mod.rs
T
Sam SungandClaude Opus 5.5 985dabff44 Layout-mode drag, title press takes the slot, links are their own button (#961)
A title-region press refused while another drag held the slot, `SetLayoutMode` stored nothing, and a press on a chat link made the chat frame itself the target, so every link click started a title move of the window.

In 1.12.1 the drag entry takes the slot unread (`0x7652b0`); a mid-drag press goes to the frozen hover (`0x76637f`). With `SetLayoutMode` (`0x488540`) on, a Ctrl or Alt press drags the nearest movable toplevel (`0x765490`) from a 3×3 grip grid (`0x765320`) and is consumed, world included; its release only ends the drag (`0x7664e0`). Each link span is its own Button (`0x7a3240`) routing `OnHyperlinkEnter`/`Leave`/`Click` to the parent (`0x7a3780`–`0x7a37e0`).

These now hold; a release goes to the capture whatever its button (`0x76642b`), and number arguments keep `_ftol`'s low dword.

Tests bite on the parent for each.

Fixes #951
Fixes #952
Fixes #957

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:22:54 +07:00

335 lines
10 KiB
Rust

//! Rust-driven tests of the Lua host, one module per subject; the shared `script()` fixture lives
//! in [`common`].
mod addon_argument_abi;
mod addon_index_space;
mod adopt;
mod anchor_slots;
mod anchors;
mod backdrop;
mod button;
mod capture;
mod channel;
mod chat_bubble;
mod clear_points;
mod common;
mod cooldown;
mod create_frame_template;
mod cvar_bindings;
mod dispatch_bench;
mod drag_pin;
mod drag_threshold;
mod end_to_end;
mod engine_updates;
mod events;
mod font_object;
mod frame_api;
mod generic_for;
mod gm_ticket;
mod handler_prof;
mod host_state;
mod hover_publish;
mod hover_walk;
mod hyperlink;
mod input;
mod keep_rect;
mod keyboard;
mod layout_gate;
mod layout_mode;
mod layout_structure;
mod line_spacing;
mod measure;
mod minimap;
mod model_clock;
mod modelframe;
mod movable;
mod name_targets;
mod nameplate;
mod numeric_shape_gate;
mod object_model;
mod reference_surface;
mod region_map;
mod regions;
mod resize_bounds;
mod scrollframe;
mod set_font_args;
mod setparent;
mod shown_bit;
mod simplehtml;
mod size_changed;
mod slider;
mod statusbar;
mod stdlib;
mod structure_queries;
mod talent;
mod taxi;
mod texcoord_font;
mod tooltip;
mod tooltip_item;
mod tooltip_spell;
mod tooltip_unit;
mod toplevel;
mod update_walk;
mod visibility;
mod widget_args;
mod widget_surface;
mod worldframe;
mod worldmap;
#[test]
fn an_instruction_budget_turns_a_runaway_loop_into_an_error() {
let s = crate::script::UiScript::new().unwrap();
s.set_instruction_budget(5_000_000);
let err = s
.run("local i = 0 while true do i = i + 1 end")
.expect_err("an unbounded loop must raise rather than run forever")
.to_string();
assert!(
err.contains("instruction budget exhausted"),
"the raise names itself so a report can tell it from an addon's own error: {err}"
);
assert!(
s.instructions_used() >= 5_000_000,
"the counter reports what was spent: {}",
s.instructions_used()
);
assert_eq!(
s.eval::<i64>("return 2 + 3").unwrap(),
5,
"the VM still evaluates after a budget raise"
);
// An ordinary chunk is untouched by a budget it never approaches.
let t = crate::script::UiScript::new().unwrap();
t.set_instruction_budget(200_000_000);
t.run("BudgetOk = 0 for i = 1, 1000 do BudgetOk = BudgetOk + i end")
.unwrap();
assert_eq!(t.eval::<i64>("return BudgetOk").unwrap(), 500_500);
}
/// The hearth location's name, `""` before the packet; `StaticPopup.lua:1742` formats it.
#[test]
fn get_bind_location_answers_the_pushed_name_and_never_nil() {
let mut s = crate::script::UiScript::new().unwrap();
assert_eq!(s.eval::<String>("return GetBindLocation()").unwrap(), "");
assert_eq!(
s.eval::<String>("return type(GetBindLocation())").unwrap(),
"string",
"never nil — a consumer concatenates it"
);
s.set_bind_location("Stormwind City");
assert_eq!(
s.eval::<String>("return GetBindLocation()").unwrap(),
"Stormwind City"
);
assert_eq!(
s.eval::<String>(r#"return "Bound: " .. GetBindLocation()"#)
.unwrap(),
"Bound: Stormwind City"
);
s.set_bind_location("Ironforge");
assert_eq!(
s.eval::<String>("return GetBindLocation()").unwrap(),
"Ironforge"
);
}
/// `RequestTimePlayed()` queues the ask; `TIME_PLAYED_MSG` carries the answer.
#[test]
fn request_time_played_queues_an_ask_and_the_answer_arrives_as_an_event() {
let mut s = crate::script::UiScript::new().unwrap();
assert_eq!(s.arity("RequestTimePlayed()").unwrap(), 0);
// A count, not a latch: the packet is empty, so two asks in a frame are two sends.
s.run("RequestTimePlayed() RequestTimePlayed()").unwrap();
assert_eq!(
s.take_played_time_asks(),
3,
"the eval above plus two more — each ask is its own CMSG_PLAYED_TIME"
);
assert_eq!(s.take_played_time_asks(), 0, "the drain empties the queue");
s.run(
r#"
TPSeen = nil
local f = CreateFrame("Frame", "TPWatcher")
f:RegisterEvent("TIME_PLAYED_MSG")
f:SetScript("OnEvent", function() TPSeen = { arg1, arg2 } end)
"#,
)
.unwrap();
s.fire_event(
"TIME_PLAYED_MSG",
vec![
crate::script::ScriptValue::Int(360_000),
crate::script::ScriptValue::Int(7_200),
],
);
assert_eq!(
s.eval::<(i64, i64)>("return TPSeen[1], TPSeen[2]").unwrap(),
(360_000, 7_200),
"total seconds played and seconds since the last level-up, in that order"
);
}
/// `seterrorhandler`'s contract: an engine-caught script error goes to the chosen handler.
#[test]
fn a_chosen_error_handler_hears_engine_caught_errors_and_none_leaves_the_host_channel_alone() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
"local f = CreateFrame('Frame') \
f:RegisterEvent('ERROR_PROBE') \
f:SetScript('OnEvent', function() error('boom from a handler') end)",
)
.unwrap();
// With no handler set, as before FrameXML sets one, the dispatch adds nothing.
s.fire_event("ERROR_PROBE", vec![]);
s.dispatch_script_errors_to_handler();
let errors = s.take_errors();
assert_eq!(
errors.iter().filter(|e| e.contains("boom")).count(),
1,
"one error, once: {errors:?}"
);
// A chosen handler gets the message, and the host channel still records it.
s.run("caught = {} seterrorhandler(function(msg) table.insert(caught, msg) end)")
.unwrap();
s.fire_event("ERROR_PROBE", vec![]);
s.dispatch_script_errors_to_handler();
assert!(
s.eval::<String>("return caught[1]")
.unwrap()
.contains("boom from a handler"),
"the chosen handler received the engine-caught error"
);
assert_eq!(
s.take_errors()
.iter()
.filter(|e| e.contains("boom"))
.count(),
1,
"and the host channel recorded it too"
);
}
#[test]
fn a_widget_handler_error_reaches_the_chosen_error_handler() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
local b = CreateFrame('Button') \
b:SetScript('OnClick', function() error('click boom') end) \
b:Click() \
local sl = CreateFrame('Slider') \
sl:SetMinMaxValues(0, 10) \
sl:SetScript('OnValueChanged', function() error('slide boom') end) \
sl:SetValue(5)",
)
.unwrap();
s.dispatch_script_errors_to_handler();
let caught = s
.eval::<String>("return table.concat(caught, ' | ')")
.unwrap();
assert!(caught.contains("click boom"), "OnClick: {caught}");
assert!(caught.contains("slide boom"), "OnValueChanged: {caught}");
}
/// 1.12 calls the error handler with the one value the failed call raised (`0x703b40`: the
/// handler pushed, inserted below the message, `lua_call` 1 argument): the bare Lua message, no
/// `runtime error: ` word and no traceback, which only benilla's own log carries.
#[test]
fn the_error_handler_receives_the_bare_lua_message() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
"caught = {} seterrorhandler(function(m) table.insert(caught, m) end) \
local f = CreateFrame('Frame') \
f:RegisterEvent('ERROR_PROBE') \
f:SetScript('OnEvent', function() this:NoSuchMethod() end) \
local b = CreateFrame('Button') \
b:SetScript('OnClick', function() local t = nil; t.x = 1 end) \
b:Click() \
local c = CreateFrame('Button') \
c:SetScript('OnClick', function() SetPortraitTexture() end) \
c:Click()",
)
.unwrap();
s.fire_event("ERROR_PROBE", vec![]);
s.dispatch_script_errors_to_handler();
let caught: Vec<String> = s.eval("return caught").unwrap();
assert!(caught.len() >= 3, "{caught:?}");
for m in &caught {
assert!(
!m.contains("runtime error") && !m.contains("stack traceback") && !m.contains('\n'),
"bare message, one line: {m:?}"
);
}
for want in [
"attempt to call method `NoSuchMethod' (a nil value)",
":1: attempt to index local `t' (a nil value)",
] {
assert!(
caught
.iter()
.any(|m| m.starts_with("[string \"") && m.ends_with(want)),
"{want}: {caught:?}"
);
}
// The host log keeps the decorated text.
assert!(s.take_errors().iter().any(|e| e.contains("runtime error")));
}
/// `fire_global` carries `UPDATE_FACTION` from the faction-header verbs.
#[test]
fn a_self_unregistering_listener_does_not_rob_its_successor_on_the_internal_dispatch() {
let s = crate::script::UiScript::new().unwrap();
s.run(
r#"log = ""
local a = CreateFrame("Frame") a:RegisterEvent("UPDATE_FACTION")
a:SetScript("OnEvent", function() log = log .. "a" this:UnregisterEvent("UPDATE_FACTION") end)
local b = CreateFrame("Frame") b:RegisterEvent("UPDATE_FACTION")
b:SetScript("OnEvent", function() log = log .. "b" end)
ExpandFactionHeader(0)"#,
)
.unwrap();
assert_eq!(s.eval::<String>("return log").unwrap(), "ab");
}
/// The handler's own failure goes to the host channel only, never re-queued; the batch stops.
#[test]
fn an_error_handler_that_errors_does_not_recurse() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
"seterrorhandler(function() error('the handler is broken too') end) \
local f = CreateFrame('Frame') \
f:RegisterEvent('ERROR_PROBE') \
f:SetScript('OnEvent', function() error('original fault') end)",
)
.unwrap();
s.fire_event("ERROR_PROBE", vec![]);
s.dispatch_script_errors_to_handler();
let errors = s.take_errors();
assert!(
errors.iter().any(|e| e.contains("original fault")),
"the original fault is on the host channel: {errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.contains("error handler itself failed")),
"and so is the handler's own failure, named as such: {errors:?}"
);
s.dispatch_script_errors_to_handler();
assert!(
s.take_errors().is_empty(),
"nothing recurses: the queue drained and the handler failure did not re-enter it"
);
}