Files
benilla-octo/crates/benilla-ui/src/script/tests/input.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

923 lines
34 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.
//! Mouse input: hit order, hover, clicks, double clicks and the release capture.
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:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(false)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
-- 1/nil, not a boolean: this is the exact comparison shape that inverts, so the
-- test asserts it rather than leaning on truthiness.
assert(a:IsMouseEnabled() == 1 and b:IsMouseEnabled() == nil)
"#,
)
.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());
}
/// At a strata and level tie the earlier-linked frame takes the hit, though the later one draws on
/// top: `0x764aa0` appends equal keys after (strict `ja`) and `0x7660d0` sweeps from index 0.
#[test]
fn hit_order_is_strata_then_level_then_the_earlier_linked_frame() {
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:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
a:SetScript("OnEnter", function(self) who = self:GetName() end)
local b = CreateFrame("Frame", "B")
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetWidth(800); b:SetHeight(600); b:EnableMouse(true)
b:SetScript("OnEnter", function(self) who = self:GetName() end)
"#,
)
.unwrap();
s.resolve();
// Same strata and level: A, linked first, captures though B draws on top.
s.mouse_move(400.0, 300.0);
assert_eq!(s.eval::<String>("return who").unwrap(), "A");
// Move off everything to clear the mouseover (fires OnLeave, resets focus) before re-testing.
s.mouse_move(-10.0, -10.0);
// Strata outranks the tie; no rect changes, so no re-resolve.
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);
// Same strata, higher level: level outranks link order, so B captures.
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:SetWidth(400); a:SetHeight(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:SetWidth(400); a:SetHeight(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:SetWidth(400); b:SetHeight(600); b:EnableMouse(true)
b:SetScript("OnClick", function(self, button, down) clicks_b = clicks_b + 1 end)
"#,
)
.unwrap();
s.resolve();
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");
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:SetWidth(800); own:SetHeight(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:SetWidth(800); parent:SetHeight(600)
local child = CreateFrame("Frame", "Ch", parent)
child:SetPoint("BOTTOMLEFT", 0, 0); child:SetWidth(800); child:SetHeight(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:SetWidth(800); a:SetHeight(600); a:EnableMouse(true)
-- The wheel is its own index and its own flag: SetScript never auto-enables (that law is
-- XML-load-time only), so a runtime-created frame needs this explicitly, exactly as it
-- needs EnableMouse. Mouse-enablement alone puts nothing in the wheel index.
a:EnableMouseWheel(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; the top inset is the micro buttons' 18
// (`MainMenuBarMicroButtons.xml:9`), with 5 off each other side.
s.run(
r#"
entered = false
local a = CreateFrame("Frame", "A")
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetWidth(100); a:SetHeight(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();
assert_eq!(s.eval::<f64>("return A:GetHeight()").unwrap(), 100.0);
assert_eq!(s.eval::<f64>("return A:GetTop()").unwrap(), 100.0);
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());
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, 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());
}
#[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-disabled, as in the reference; the two scrolling kinds' own
// constructors enable it.
assert!(!s
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
.unwrap());
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());
}
/// The reference takes a closed-vocabulary value with stray whitespace: the zBar addon ships
/// `frameStrata="BACKGROUND "` (`zBar.xml:146`). Frame kind, strata and draw layer all trim.
#[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"
);
// The padded kind resolved: an unknown kind would have raised from `CreateFrame`.
assert!(s.eval::<bool>("return PaddedKind ~= nil").unwrap());
assert!(s.eval::<bool>("return PaddedTex ~= nil").unwrap());
}
// ── OnDoubleClick ──
//
// The interval is a fixed 300 ms (`0x77937b`, `cmp ecx, 0x12c`) and the only fire site is the
// mouse-up dispatcher `0x7792d0`. The double replaces the second `OnClick` (`0x77939d` jumps past
// it), a completed double zeroes the stamp so clicks pair up, the detector arms only when the frame
// has an `OnDoubleClick` script (`[+0x4d4] != 0`), and it keys on no button: `RegisterForClicks`
// is what confines it to the left button.
#[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:SetWidth(200); b:SetHeight(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();
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 detector tests `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script always takes
/// the single-click leg.
#[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:SetWidth(200); b:SetHeight(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());
}
#[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:SetWidth(100); a:SetHeight(100); a:EnableMouse(true); watch(a)
local b = CreateFrame("Button", "DblBB")
b:SetPoint("BOTTOMLEFT", 300, 0); b:SetWidth(100); b:SetHeight(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 is past the 300 ms, so the second click starts afresh.
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: the stamp 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());
}
/// `[CButton+0x334]` is a bare timestamp, so a button registered for two `…Up` types completes a
/// double click across them, and `arg1` is the second click's button.
#[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:SetWidth(200); b:SetHeight(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, disable or the cursor leaving the
/// window: none of `[+0x334]`'s three writers is one of those, so
/// [`crate::script::UiScript::pointer_left_window`] must not reset it.
#[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:SetWidth(200); b:SetHeight(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"
);
}
/// A name in `SCRIPT_KINDS` must have something that fires it, so this asserts the fire.
#[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:SetWidth(100); Btn:SetHeight(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");
}
/// Script names nothing fires still raise; a name leaves this list only when 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 [
// A 2.0 secure-frame slot; no 1.12 resolver has it.
"OnAttributeChanged",
// Real 1.12 slots that benilla does not fire. `OnHyperlinkEnter`/`Leave` left this list
// when `pointer::update_hyperlink_hover` started firing them off the rasterized link
// spans — which is the rule this test states, not an exception to it.
"OnMessageScrollChanged",
"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}"
);
}
}
/// Hiding the hovered frame fires its `OnLeave` inside the hide, before its `OnHide` (`0x764ba0`'s
/// removal tail, the leave at `0x764cce`), and an ancestor's `Hide()` reaches it by 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:SetWidth(800); win:SetHeight(600)
local slot = CreateFrame("Button", "Slot", win)
slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetWidth(100); slot:SetHeight(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());
}
/// `0x764cce` dispatches the leave virtually (`[edx+0x50]`), so a disabled Button's own guard
/// (`0x7794e0`) swallows it; `OnHide` is not the hover notify and fires either way.
#[test]
fn hiding_a_hovered_disabled_button_fires_onhide_but_no_onleave() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
log = {}
local slot = CreateFrame("Button", "Slot")
slot:SetPoint("BOTTOMLEFT", 100, 100); slot:SetWidth(100); slot:SetHeight(100); slot:EnableMouse(true)
slot:SetScript("OnLeave", function() table.insert(log, "leave") end)
slot:SetScript("OnHide", function() table.insert(log, "hide") end)
slot:Disable()
"#,
)
.unwrap();
s.resolve();
s.mouse_move(150.0, 150.0);
s.run("Slot:Hide()").unwrap();
assert_eq!(
s.eval::<String>("return table.concat(log, ',')").unwrap(),
"hide",
"the removal tail reaches the button's own leave notify, which is DISABLED-gated"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// 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:SetWidth(50); a:SetHeight(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());
}
/// After the hovered frame hides, the next tick re-hovers what is topmost at the unmoved cursor
/// (`[root+0x1100]`, the pump tail `0x7660d0` on the saved event); a frame shown under the cursor
/// is hovered the same way (`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:SetWidth(100); under:SetHeight(100); under:EnableMouse(true)
under:SetScript("OnEnter", function() table.insert(entered, "under") end)
local over = CreateFrame("Button", "Over")
over:SetPoint("BOTTOMLEFT", 100, 100); over:SetWidth(100); over:SetHeight(100); over:EnableMouse(true)
over:SetScript("OnEnter", function() table.insert(entered, "over") end)
-- Say "on top" with the LEVEL, not with declaration order: at equal levels the sweep takes
-- the earlier-linked frame (`Under`), which is the tie law, not what this test is about.
over:SetFrameLevel(10)
"#,
)
.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; the 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"
);
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());
}
// ── The mouse-up dispatch (`0x766420`) ──
/// Two side-by-side frames, each recording every `OnMouseDown`/`OnMouseUp` it gets.
fn two_frames() -> crate::script::UiScript {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
log = {}
local function mk(name, x)
local f = CreateFrame("Frame", name)
f:SetPoint("BOTTOMLEFT", x, 200); f:SetWidth(200); f:SetHeight(200); f:EnableMouse(true)
f:SetScript("OnMouseDown", function(self) table.insert(log, self:GetName()..":down") end)
f:SetScript("OnMouseUp", function(self) table.insert(log, self:GetName()..":up") end)
return f
end
mk("A", 100)
mk("B", 400)
"#,
)
.unwrap();
s.resolve();
s
}
fn log_of(s: &mut crate::script::UiScript) -> String {
s.eval::<String>("return table.concat(log, ' ')").unwrap()
}
/// `0x766420` reads the capture `[mgr+0x80]` at entry (`0x76642b`) and never the hover
/// `[mgr+0x7c]`, so the release reaches the pressed frame wherever the cursor is; a chat resize
/// grip's `FCF_StopResize` relies on it (`FloatingChatFrame.xml:292`).
#[test]
fn the_release_goes_to_the_frame_that_took_the_press() {
let mut s = two_frames();
s.mouse_button(200.0, 300.0, "LeftButton", true); // press on A
s.mouse_move(500.0, 300.0); // ... cursor wanders onto B
s.mouse_button(500.0, 300.0, "LeftButton", false); // ... and releases there
assert_eq!(
log_of(&mut s),
"A:down A:up",
"the capture takes the release; B gets nothing on that edge"
);
}
/// The down handler falls back to the hover (`root+0x80` else `root+0x7c`), the up handler does
/// not: with no capture, `0x766498` jumps to `0x7664cd` and dispatches nothing.
#[test]
fn a_press_that_captured_nothing_delivers_no_release() {
let mut s = two_frames();
s.mouse_button(50.0, 50.0, "LeftButton", true); // press over open space
s.mouse_move(500.0, 300.0); // ... cursor onto B
s.mouse_button(500.0, 300.0, "LeftButton", false);
assert_eq!(
log_of(&mut s),
"",
"no capture, no dispatch — the UP handler has no hover arm"
);
}
#[test]
fn a_press_and_release_on_one_frame_is_unchanged() {
let mut s = two_frames();
s.mouse_button(200.0, 300.0, "LeftButton", true);
s.mouse_button(200.0, 300.0, "LeftButton", false);
assert_eq!(log_of(&mut s), "A:down A:up");
}
/// `Model::mouse_down_on` is keyed by button name, so the capture is per button.
#[test]
fn the_capture_is_per_button() {
let mut s = two_frames();
s.mouse_button(200.0, 300.0, "LeftButton", true); // A holds the left button
s.mouse_move(500.0, 300.0);
s.mouse_button(500.0, 300.0, "RightButton", false); // a right release, never pressed
assert_eq!(
log_of(&mut s),
"A:down",
"the right button captured nothing, so its release dispatches nothing"
);
}
/// A disabled Button fires neither `OnEnter` nor `OnLeave`: `CSimpleButton::OnEnter` (`0x779490`)
/// and `OnLeave` (`0x7794e0`) test `[esi+0x328]` and skip the base notify (`0x76b6a0`,
/// `0x76b6f0`). It still takes the hover: the walk sets `[root+0x7c]` before the notify.
#[test]
fn a_disabled_button_takes_the_hover_but_fires_no_enter_or_leave() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
log = {}
local function mk(name, x)
local b = CreateFrame("Button", name)
b:SetPoint("BOTTOMLEFT", x, 0); b:SetWidth(100); b:SetHeight(100); b:EnableMouse(true)
b:SetScript("OnEnter", function(self) table.insert(log, self:GetName()..":enter") end)
b:SetScript("OnLeave", function(self) table.insert(log, self:GetName()..":leave") end)
return b
end
Off = mk("Off", 0)
On = mk("On", 200)
Off:Disable()
-- The guard the addons actually wrote, and why it cannot be theirs: a NUMBER, never nil.
assert(Off:IsEnabled() == 0 and On:IsEnabled() == 1)
"#,
)
.unwrap();
s.resolve();
let hit = s.mouse_move(50.0, 50.0);
assert!(hit.is_some(), "a disabled button still captures the point");
assert_eq!(
s.eval::<String>("return GetMouseFocus():GetName()")
.unwrap(),
"Off",
"the hover target moves before the notify — a disabled button IS the mouse focus"
);
assert_eq!(
s.eval::<String>("return table.concat(log, ',')").unwrap(),
"",
"entering a disabled button fires nothing"
);
s.mouse_move(250.0, 50.0);
assert_eq!(
s.eval::<String>("return table.concat(log, ',')").unwrap(),
"On:enter",
"no leave from the disabled button; the enabled one notifies normally"
);
// Disabled while hovered: the leave is gated on the state at leave time.
s.run("On:Disable()").unwrap();
s.mouse_move(400.0, 50.0);
assert_eq!(
s.eval::<String>("return table.concat(log, ',')").unwrap(),
"On:enter",
"a button disabled under the cursor swallows its own leave"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **pfUI's nameplate right-click emulator, transcribed** (`modules/nameplates.lua:1899-1934`),
/// the one corpus caller of the mouse-look trio. The emulator exists because a right-press on a
/// plate frame is ambiguous — it may be the start of a camera drag or a right-click — and it
/// resolves it by starting a look and then asking whether one is running.
///
/// The property under test is the *arming*: a nil `MouselookStart` aborted `OnMouseDown` before it
/// recorded the frame and the timestamp, so the `OnUpdate` body below never ran and right-clicking
/// a pfUI nameplate did nothing at all. This fails against that absence, and against an
/// `IsMouselooking` that answers truthy for a press the world never owned — either way the
/// synthesised click never happens.
#[test]
fn pfuis_nameplate_right_click_emulator_arms_and_then_synthesises_the_click() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
clicked, attacked = 0, 0
AttackTarget = function() attacked = attacked + 1 end
local plate = CreateFrame("Button", "pfPlate")
plate:SetPoint("BOTTOMLEFT", 0, 0); plate:SetWidth(800); plate:SetHeight(600)
plate:EnableMouse(true)
plate:RegisterForClicks("LeftButtonUp", "RightButtonUp")
plate:SetScript("OnClick", function() clicked = clicked + 1 end)
look = CreateFrame("Frame", nil, nil)
look.time, look.frame = nil, nil
plate:SetScript("OnMouseDown", function()
if arg1 and arg1 == "RightButton" then
MouselookStart()
look.time = GetTime()
look.frame = this
end
end)
-- `:1917-1934`, with the 0.5 s `clickthreshold` inlined.
function PollLook(now)
if not look.time or not look.frame then return end
if not IsMouselooking() and look.time + 0.5 < now then look.time = nil return end
if not IsMouselooking() then
look.frame:Click("LeftButton")
if UnitCanAttack("player", "target") then AttackTarget() end
look.time = nil
end
end
"#,
)
.unwrap();
s.resolve();
s.mouse_move(400.0, 300.0);
s.mouse_button(400.0, 300.0, "RightButton", true);
assert!(
s.errors().is_empty(),
"the press must not raise: {:?}",
s.errors()
);
assert!(
s.eval::<bool>("return look.frame ~= nil and look.time ~= nil")
.unwrap(),
"OnMouseDown ran past MouselookStart and armed the poll"
);
assert_eq!(
s.eval::<Option<i64>>("return IsMouselooking()").unwrap(),
None,
"a press the plate captured is a press the world never owned, so no look is running"
);
s.run("PollLook(GetTime())").unwrap();
assert_eq!(
s.eval::<i64>("return clicked").unwrap(),
1,
"the emulated click fired"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}