Files
benilla-octo/crates/benilla-ui/src/script/tests/input.rs
T
SamandClaude Fable 5 5360a44f89 event-driven UI feeds, and the character window's cost lets go
- the eight heaviest UI feeds — spellbook, party, character, bags, talents, units, pet, player — rebuild only when an input moved instead of every frame: 216 → 12 µs a frame parked
- the character window's cost settles once it closes: enchant countdowns feed by the second instead of firing an inventory event into all 20 slots every frame, the paper-doll booth parks with its camera, and a window hidden under the cursor fires OnLeave so its tooltip goes with it — the reference's hover-hide law
- the walk stops paying for finished work: the loading-screen tally's per-placement walk latches off once the world is ready, the settled placement spawner is one integer compare, and billboards stop dirtying every card's transform
- never-rendering hierarchy nodes — rig anchors, joints, doodad hosts — keep the hide chain but leave the per-camera visibility sweep: 25,198 → 18,026 swept entities at Goldshire

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:01:11 +07:00

721 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Input / hit-testing (decision 0068; spec-faithful, not byte-pinned).
use super::common::script;
#[test]
fn enable_mouse_gates_hit_testing() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
who = nil
-- Two full-screen frames. `b` is created later (drawn on top) but mouse-disabled, so it is
-- transparent to hits; the enabled frame behind it (`a`) must capture.
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(false)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
assert(a:IsMouseEnabled() == true and b:IsMouseEnabled() == false)
"#,
)
.unwrap();
s.resolve();
let hit = s.mouse_move(400.0, 300.0);
assert!(
hit.is_some(),
"a mouse-enabled frame under the cursor captures"
);
assert_eq!(
s.eval::<String>("return who").unwrap(),
"A",
"the top frame is mouse-disabled ⇒ the enabled frame behind it captures"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn topmost_by_draw_order_captures_among_overlapping_enabled_frames() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
who = nil
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(800, 600); b:EnableMouse(true)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
"#,
)
.unwrap();
s.resolve();
// Same strata/level: B has the later insertion ⇒ drawn on top ⇒ captures.
s.mouse_move(400.0, 300.0);
assert_eq!(s.eval::<String>("return who").unwrap(), "B");
// Move off everything to clear the mouseover (fires OnLeave, resets focus) before re-testing.
s.mouse_move(-10.0, -10.0);
// Raise A above B by strata (no rect change ⇒ no re-resolve needed): A now captures.
s.run("A:SetFrameStrata('DIALOG')").unwrap();
s.mouse_move(400.0, 300.0);
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
s.mouse_move(-10.0, -10.0);
// Put B in the same (DIALOG) strata but a higher frame level: B captures again.
s.run("B:SetFrameStrata('DIALOG'); B:SetFrameLevel(10)")
.unwrap();
s.mouse_move(400.0, 300.0);
assert_eq!(s.eval::<String>("return who").unwrap(), "B");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn mouse_move_fires_enter_then_leave_across_a_boundary_with_correct_self() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
enters, leaves = 0, 0
enter_self_ok, leave_self_ok = false, false
local a = CreateFrame("Frame", "A") -- left half only
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) enters = enters + 1; enter_self_ok = (self == a) end)
a:SetScript("OnLeave", function(self) leaves = leaves + 1; leave_self_ok = (self == a) end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(200.0, 300.0); // inside A ⇒ OnEnter
s.mouse_move(600.0, 300.0); // outside A ⇒ OnLeave
let (enters, leaves): (i64, i64) = s.eval("return enters, leaves").unwrap();
assert_eq!((enters, leaves), (1, 1));
assert!(
s.eval::<bool>("return enter_self_ok").unwrap(),
"OnEnter self is the frame"
);
assert!(
s.eval::<bool>("return leave_self_ok").unwrap(),
"OnLeave self is the frame"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn onclick_fires_on_press_release_same_frame_not_when_release_lands_elsewhere() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks_a, clicks_b, click_btn = 0, 0, nil
local a = CreateFrame("Frame", "A") -- left half
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(400, 600); a:EnableMouse(true)
a:SetScript("OnClick", function(self, button, down) clicks_a = clicks_a + 1; click_btn = button end)
local b = CreateFrame("Frame", "B") -- right half
b:SetPoint("BOTTOMLEFT", 400, 0); b:SetSize(400, 600); b:EnableMouse(true)
b:SetScript("OnClick", function(self, button, down) clicks_b = clicks_b + 1 end)
"#,
)
.unwrap();
s.resolve();
// Press + release both on A ⇒ OnClick on A (button arg == "LeftButton").
s.mouse_button(200.0, 300.0, "LeftButton", true);
s.mouse_button(200.0, 300.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks_a").unwrap(), 1);
assert_eq!(s.eval::<String>("return click_btn").unwrap(), "LeftButton");
// Press on A, release on B ⇒ no OnClick on either frame.
s.mouse_button(200.0, 300.0, "LeftButton", true);
s.mouse_button(600.0, 300.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return clicks_a").unwrap(),
1,
"release landed off A ⇒ no click on A"
);
assert_eq!(
s.eval::<i64>("return clicks_b").unwrap(),
0,
"release on B was not preceded by a press on B"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn hidden_or_effective_hidden_frame_never_captures() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
entered = false
-- own shown = false
local own = CreateFrame("Frame", "OwnHidden")
own:SetPoint("BOTTOMLEFT", 0, 0); own:SetSize(800, 600); own:EnableMouse(true)
own:SetScript("OnEnter", function() entered = true end)
own:Hide()
-- effective-hidden: child is shown but its parent is hidden
local parent = CreateFrame("Frame", "Par")
parent:SetPoint("BOTTOMLEFT", 0, 0); parent:SetSize(800, 600)
local child = CreateFrame("Frame", "Ch", parent)
child:SetPoint("BOTTOMLEFT", 0, 0); child:SetSize(800, 600); child:EnableMouse(true)
child:SetScript("OnEnter", function() entered = true end)
parent:Hide()
"#,
)
.unwrap();
s.resolve();
assert!(
s.hit_test(400.0, 300.0).is_none(),
"no visible mouse-enabled frame ⇒ no capture"
);
assert!(s.mouse_move(400.0, 300.0).is_none());
assert!(
!s.eval::<bool>("return entered").unwrap(),
"hidden / effective-hidden frames fire no OnEnter"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn mouse_wheel_passes_delta_to_the_captured_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
wheel = nil
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(800, 600); a:EnableMouse(true)
a:SetScript("OnMouseWheel", function(self, delta) wheel = delta end)
"#,
)
.unwrap();
s.resolve();
s.mouse_wheel(400.0, 300.0, 1.0);
assert!((s.eval::<f64>("return wheel").unwrap() - 1.0).abs() < 1e-6);
s.mouse_wheel(400.0, 300.0, -1.0);
assert!((s.eval::<f64>("return wheel").unwrap() + 1.0).abs() < 1e-6);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn hit_rect_insets_shrink_the_mouse_rect_only() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
// A 100×100 frame at the origin with the micro-button shape: an 18-unit dead header at the
// top, and 5 off each other side.
s.run(
r#"
entered = false
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true)
a:SetScript("OnEnter", function() entered = true end)
a:SetHitRectInsets(5, 5, 18, 5)
local l, r, t, b = a:GetHitRectInsets()
assert(l == 5 and r == 5 and t == 18 and b == 5)
"#,
)
.unwrap();
s.resolve();
// Geometry is untouched — insets move the MOUSE rect, never the frame.
assert_eq!(s.eval::<f64>("return A:GetHeight()").unwrap(), 100.0);
assert_eq!(s.eval::<f64>("return A:GetTop()").unwrap(), 100.0);
// Inside the frame but inside the dead header ⇒ no capture (the case the ref's top=18 buys:
// the empty band above a micro button's art must not eat the click).
assert!(
s.hit_test(50.0, 90.0).is_none(),
"a point in the inset header is outside the hit rect"
);
assert!(s.mouse_move(50.0, 90.0).is_none());
assert!(!s.eval::<bool>("return entered").unwrap());
// …and each of the other three insets bites too.
assert!(s.hit_test(2.0, 50.0).is_none(), "left inset");
assert!(s.hit_test(98.0, 50.0).is_none(), "right inset");
assert!(s.hit_test(50.0, 2.0).is_none(), "bottom inset");
// Just below the header, still inside the shrunken rect ⇒ captures.
assert!(s.hit_test(50.0, 80.0).is_some());
assert!(s.mouse_move(50.0, 80.0).is_some());
assert!(s.eval::<bool>("return entered").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn hit_rect_insets_default_to_zero() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"local a = CreateFrame("Frame", "Plain")"#).unwrap();
let (l, r, t, b) = s
.eval::<(f64, f64, f64, f64)>("return Plain:GetHitRectInsets()")
.unwrap();
assert_eq!((l, r, t, b), (0.0, 0.0, 0.0, 0.0), "no inset by default");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `EnableMouseWheel`/`IsMouseWheelEnabled` round-trip, and the two kinds born wheel-enabled
/// (decision 1198).
///
/// The flag is real and settable; **the dispatch is deliberately not gated on it yet** — see
/// `object::frame_state`'s note for the 44 shipped `OnMouseWheel` sites that declare no
/// `enableMouseWheel` and the concrete condition for flipping it. This test pins the flag's own
/// behaviour so that flip is a one-line change with a test already standing behind it.
#[test]
fn the_mouse_wheel_flag_round_trips_and_the_scrolling_kinds_are_born_enabled() {
let s = script();
s.run(
r#"
Plain = CreateFrame("Frame", "WheelPlain")
Scroll = CreateFrame("ScrollFrame", "WheelScroll")
Msg = CreateFrame("ScrollingMessageFrame", "WheelMsg")
"#,
)
.unwrap();
// A plain frame is born wheel-deaf, like WoW's own default.
assert!(!s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
// ...and the two kinds whose ctor takes the wheel are born enabled, the same by-construction
// argument `mouse_enabled` already makes for a Button.
assert!(s
.eval::<bool>("return Scroll:IsMouseWheelEnabled()")
.unwrap());
assert!(s.eval::<bool>("return Msg:IsMouseWheelEnabled()").unwrap());
s.run("Plain:EnableMouseWheel(true)").unwrap();
assert!(s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
s.run("Plain:EnableMouseWheel(false)").unwrap();
assert!(!s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
// Lua truthiness, like every other flag binding: `EnableMouseWheel(1)` is on.
s.run("Plain:EnableMouseWheel(1)").unwrap();
assert!(s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A closed-vocabulary attribute survives stray whitespace (decision 1204).
///
/// The corpus case: `zBar.xml:146` — a shipped, working 1.12 addon — declares
/// `frameStrata="BACKGROUND "` with a trailing space. The real client took it; we refused, and the
/// refusal took the frame's whole `<Frames>` subtree with it, so the addon never loaded at all.
///
/// The three parsers are covered together because the next one to meet a stray space should not
/// need its own bug report.
#[test]
fn an_enum_attribute_tolerates_the_whitespace_a_real_addon_ships() {
let s = script();
s.run(
r#"
Padded = CreateFrame(" Frame ", "PaddedKind")
Padded:SetFrameStrata("BACKGROUND ")
Trailing = Padded:CreateTexture("PaddedTex", " OVERLAY")
"#,
)
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(
s.eval::<String>("return Padded:GetFrameStrata()").unwrap(),
"BACKGROUND",
"the frame took the strata, and reports it in the canonical spelling"
);
// ...and the padded frame KIND resolved too — an unknown kind raises from CreateFrame, so
// reaching this line at all is the assertion.
assert!(s.eval::<bool>("return PaddedKind ~= nil").unwrap());
assert!(s.eval::<bool>("return PaddedTex ~= nil").unwrap());
}
// ── OnDoubleClick — the corpus's biggest script gap (250 sites / 85 addons) ───────────────────
//
// Every rule below is byte-verified, from the §5 cross-check this work dispatched into wow-re
// (`system/ui/scratch/button-doubleclick-law.md`): the interval is a hardcoded **300 ms**
// (`0x77937b cmp ecx, 0x12c`), the fire site is the mouse-**UP** dispatcher `0x7792d0` alone, the
// double leg **replaces** the second `OnClick` (`0x77939d jmp` past `call [edx+0x94]`), a completed
// double **zeroes** the stamp so clicks pair up, the detector is armed only when the frame carries
// an `OnDoubleClick` script (`[+0x4d4] != 0`), and it carries **no button identity** — what
// normally confines it to the left button is `RegisterForClicks`, not the detector.
/// Two fast clicks fire `OnDoubleClick(self, button)` — and the second `OnClick` does **not** fire,
/// because the two legs are exclusive. (This was the interim implementation's mistake: it fired
/// both, on the press edge, at 500 ms. All three were corrected at the bytes.)
#[test]
fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, doubles, dblbtn = 0, 0, nil
local b = CreateFrame("Button", "DblB")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:SetScript("OnClick", function(self, button) clicks = clicks + 1 end)
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
"#,
)
.unwrap();
s.resolve();
// Click one. Note the press alone does nothing: the fire site is the mouse-UP dispatcher.
s.mouse_button(50.0, 50.0, "LeftButton", true);
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
0,
"the default RegisterForClicks is LeftButtonUp — a press fires no click at all"
);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return doubles").unwrap(),
0,
"one click is not a double click"
);
// Click two, inside the 300 ms.
s.tick(0.1);
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return doubles").unwrap(),
1,
"the second click inside the interval is a double click"
);
assert_eq!(
s.eval::<String>("return dblbtn").unwrap(),
"LeftButton",
"OnDoubleClick(self, button) — the same single button-name arg OnClick gets"
);
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
1,
"…and it REPLACED the second OnClick — the two legs are exclusive"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The gate that keeps rapid clicking from breaking every button in the UI.** The binary's chain
/// tests `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script takes the single leg every
/// time. Without that test, the exclusivity above would silently swallow every second `OnClick` on
/// every frame in the game.
#[test]
fn a_frame_with_no_double_click_handler_keeps_every_rapid_on_click() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks = 0
local b = CreateFrame("Button", "NoDbl")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:SetScript("OnClick", function() clicks = clicks + 1 end)
"#,
)
.unwrap();
s.resolve();
for _ in 0..4 {
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
}
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
4,
"no OnDoubleClick script ⇒ four rapid clicks are four clicks"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The boundary: too slow, a different frame, and the PAIRING rule (a completed double zeroes the
/// stamp, so four rapid clicks read Click · Double · Click · Double — there is no triple-click and
/// no run of doubles).
#[test]
fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicks, doubles = 0, 0
local function watch(f)
f:SetScript("OnClick", function() clicks = clicks + 1 end)
f:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
end
local a = CreateFrame("Button", "DblA")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true); watch(a)
local b = CreateFrame("Button", "DblBB")
b:SetPoint("BOTTOMLEFT", 300, 0); b:SetSize(100, 100); b:EnableMouse(true); watch(b)
"#,
)
.unwrap();
s.resolve();
let click = |s: &mut crate::script::UiScript, x: f32| {
s.mouse_button(x, 50.0, "LeftButton", true);
s.mouse_button(x, 50.0, "LeftButton", false);
};
// (1) TOO SLOW — 0.35 s > 300 ms, so the second click is a fresh first half.
click(&mut s, 50.0);
s.tick(0.35);
click(&mut s, 50.0);
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
// (2) A DIFFERENT FRAME — fast, but the timestamp lives on the WIDGET, so B has its own.
s.run("clicks, doubles = 0, 0").unwrap();
s.tick(1.0);
click(&mut s, 50.0);
click(&mut s, 350.0);
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
// (3) PAIRING — four rapid clicks on one frame are Click · Double · Click · Double.
s.run("clicks, doubles = 0, 0").unwrap();
s.tick(1.0);
for _ in 0..4 {
click(&mut s, 50.0);
}
assert_eq!(
(
s.eval::<i64>("return clicks").unwrap(),
s.eval::<i64>("return doubles").unwrap()
),
(2, 2),
"clicks pair up — the completed double zeroes the stamp, so there is no triple-click"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The detector carries no button identity** — the surprising half of the law, and the one an
/// implementation keyed by mouse button would get wrong. `[CButton+0x334]` is a bare timestamp, so
/// a widget registered for two `…Up` types completes a double click across them, with `arg1` = the
/// button of the SECOND click. A stock button never sees this only because `RegisterForClicks`
/// defaults to `{"LeftButtonUp"}`.
#[test]
fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
doubles, dblbtn = 0, nil
local b = CreateFrame("Button", "MixDbl")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
s.tick(0.1);
s.mouse_button(50.0, 50.0, "RightButton", true);
s.mouse_button(50.0, 50.0, "RightButton", false);
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 1);
assert_eq!(
s.eval::<String>("return dblbtn").unwrap(),
"RightButton",
"arg1 is the button of the completing click, not of the one that armed it"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **Nothing clears a half-finished double click** — not hide, not disable, not the cursor leaving
/// the window. `[+0x334]` has three writers image-wide and none of them is any of those, so this
/// pins the *absence* of the tidy-up that looks like it belongs in
/// [`crate::script::UiScript::pointer_left_window`].
#[test]
fn a_half_finished_double_click_survives_the_cursor_leaving_the_window() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
doubles = 0
local b = CreateFrame("Button", "LeaveDbl")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
b:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
"#,
)
.unwrap();
s.resolve();
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
s.pointer_left_window();
s.tick(0.1);
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return doubles").unwrap(),
1,
"the pointer left and came back inside 300 ms — the reference still pairs them"
);
}
/// The load-bearing regression guard for the whole job: accepting `OnDoubleClick` in
/// `SCRIPT_KINDS` without wiring the detector would make this test's `SetScript` succeed and its
/// handler never run — the silent-capability trap. Asserting the *fire* is what makes the row real.
#[test]
fn set_script_on_double_click_is_accepted_because_something_fires_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
ran = false
Btn = CreateFrame("Button", "DblAccepted")
Btn:SetPoint("BOTTOMLEFT", 0, 0); Btn:SetSize(100, 100); Btn:EnableMouse(true)
Btn:SetScript("OnDoubleClick", function() ran = true end)
"#,
)
.unwrap();
assert!(
s.eval::<bool>(r#"return Btn:GetScript("OnDoubleClick") ~= nil"#)
.unwrap(),
"SetScript stored it"
);
s.resolve();
for _ in 0..2 {
s.mouse_button(50.0, 50.0, "LeftButton", true);
s.mouse_button(50.0, 50.0, "LeftButton", false);
}
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
}
/// The script names that deliberately still raise, and why each is out (the reasons live at
/// `object::events_regions::set_script`). This is the other half of the rule "a name is accepted
/// only once something fires it": a future widening has to delete a line here, which is exactly
/// the moment to check something fires it.
#[test]
fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
let s = script();
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
.unwrap();
for name in [
// `OnKeyDown`/`OnKeyUp`/`OnChar` are NOT in this list any more: decision 1319 built the
// delivery walk, so they are fired and therefore accepted — the rule this test enforces,
// applied in the other direction. Their law lives in `script::tests::keyboard`.
//
// Caret geometry is host-side; its four float args would all be zero.
"OnCursorChanged",
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
"OnAttributeChanged",
// Real 1.12 slots we do not fire, and zero corpus call sites.
"OnHorizontalScroll",
"OnHyperlinkEnter",
"OnMessageScrollChanged",
"OnUpdateModel",
"OnAnimFinished",
"OnInputLanguageChanged",
] {
let err = s
.run(&format!(r#"Raiser:SetScript("{name}", function() end)"#))
.unwrap_err();
assert!(
err.to_string().contains(name),
"{name} must raise by name, got {err}"
);
}
}
/// The hover-hide law (wow-re `ui/scratch/hover-hide-and-tooltip-owner-law.md`, §5-arbitrated):
/// hiding the hovered frame fires its `OnLeave` synchronously, BEFORE its own `OnHide`
/// (`0x764ba0`'s kind-2 tail runs mid-hide, the leave at `0x764cce`; OnHide fires at the hide
/// body's tail) — and an ancestor's `Hide()` reaches the hovered descendant through the cascade.
#[test]
fn hiding_the_hovered_frame_fires_its_onleave_before_onhide() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
log = {}
local win = CreateFrame("Frame", "Win")
win:SetPoint("BOTTOMLEFT", 0, 0); win:SetSize(800, 600)
local slot = CreateFrame("Button", "Slot", win)
slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetSize(100, 100); slot:EnableMouse(true)
slot:SetScript("OnLeave", function() table.insert(log, "leave") end)
slot:SetScript("OnHide", function() table.insert(log, "hide") end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(150.0, 150.0); // hover the slot
s.run("Win:Hide()").unwrap(); // the ANCESTOR hides — the cascade reaches the slot
assert_eq!(
s.eval::<String>("return table.concat(log, ',')").unwrap(),
"leave,hide",
"OnLeave fires synchronously inside the hide, before the frame's own OnHide"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The negative: hiding a frame the cursor is NOT over fires no OnLeave (the reference's removal
/// tail acts only when the removed frame IS the cached hover).
#[test]
fn hiding_an_unhovered_frame_fires_no_onleave() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
leaves = 0
local a = CreateFrame("Button", "Away")
a:SetPoint("BOTTOMLEFT", 500, 500); a:SetSize(50, 50); a:EnableMouse(true)
a:SetScript("OnLeave", function() leaves = leaves + 1 end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(10.0, 10.0); // nowhere near it
s.run("Away:Hide()").unwrap();
assert_eq!(s.eval::<f64>("return leaves").unwrap(), 0.0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The re-pick (`[root+0x1100]` + the pump tail `0x7660d0` on the SAVED event): after the hovered
/// frame hides, the next tick re-hovers whatever is now topmost at the unchanged cursor — OnEnter
/// with no physical mouse move. And the symmetric insert law: a frame SHOWN under a stationary
/// cursor gets hovered on the next tick too (`0x764b8d`).
#[test]
fn the_repick_re_hovers_without_a_mouse_move() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
entered = {}
local under = CreateFrame("Button", "Under")
under:SetPoint("BOTTOMLEFT", 100, 100); under:SetSize(100, 100); under:EnableMouse(true)
under:SetScript("OnEnter", function() table.insert(entered, "under") end)
local over = CreateFrame("Button", "Over")
over:SetPoint("BOTTOMLEFT", 100, 100); over:SetSize(100, 100); over:EnableMouse(true)
over:SetScript("OnEnter", function() table.insert(entered, "over") end)
"#,
)
.unwrap();
s.resolve();
s.mouse_move(150.0, 150.0); // hovers Over (topmost)
s.run("Over:Hide()").unwrap();
s.tick(0.016); // the pump's re-pick — cursor has not moved
assert_eq!(
s.eval::<String>("return table.concat(entered, ',')")
.unwrap(),
"over,under",
"the frame beneath is entered on the next tick with no mouse move"
);
// The insert half: re-showing the top frame under the stationary cursor re-hovers it.
s.run("Over:Show()").unwrap();
s.tick(0.016);
assert_eq!(
s.eval::<String>("return table.concat(entered, ',')")
.unwrap(),
"over,under,over",
"a frame shown under the cursor is entered on the next tick"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}