Files
benilla-octo/crates/benilla-ui/src/script/editbox/tests.rs
T
SamandClaude Fable 5.1 5432a8bfbd a slider snaps to its step, a pushed button releases, and warnings show
- a slider with a value step snaps to it: a stepped drag fires once per row instead of once per pixel, a scrollbar cannot rest between two rows, and one wheel notch on the trainer list reaches the bottom (2133)
- setting a step on a live slider moves its value onto the new lattice and runs its handler, which an addon on the real client can count on (2143)
- a button pushed from script releases on the next mouse-up, so Tablet-2.0's rows stop staying pressed forever; a release off the button's rect un-presses it, and a locked state holds (2134)
- the mail body and the GM ticket box scroll as you type past their bottom, and the Use UI Scale checkbox reads a registered setting (2141)
- an engine warning, a call that was accepted and did nothing, reaches the /errors window in grey instead of one terminal line; a text row trimmed mid-character no longer kills the window's repaint (2135, 2138)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:52:20 +07:00

1672 lines
61 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.
//! Rust-driven tests of the EditBox runtime (RF-0082): focus acquisition + routing, text/cursor/
//! selection editing, the specialized script fires, caps, numeric/password, and text-region sync.
//! Frames are built programmatically via `CreateFrame("EditBox", …)` and driven through the public
//! keyboard API (`char_input`/`key_input`/`editbox_action`/`has_keyboard_focus`) and the Lua
//! method surface.
use crate::script::{EditAction, EditUnit, QuadContent, UiScript};
fn script() -> UiScript {
UiScript::new().expect("construct UiScript")
}
/// The display text of the (single) text quad an extract produces, if any.
fn text_quad(s: &UiScript) -> Option<String> {
s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Text { text, .. } => text,
_ => None,
})
}
// ── §1/§2 focus acquisition + routing ───────────────────────────────────────────────────────
/// **The self-acquire path**, path 2 of the two: a box that has had no show TRANSITION (created
/// already visible, so `visibility_focus` never runs for it) still takes the keyboard on the first
/// key or char event, and processes that same event. Path 1, focus-on-show, is
/// [`an_autofocus_box_takes_the_keyboard_when_it_is_shown`] below — this test's name used to assert
/// its absence, which stopped being true at decision 1686.
#[test]
fn an_autofocus_box_self_acquires_on_the_first_event() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(true)"#)
.unwrap();
// No show transition has run for this box, so nothing owns the keyboard yet.
assert!(!s.has_keyboard_focus());
assert!(!s.eval::<bool>("return E:HasFocus()").unwrap());
// The first char self-acquires focus AND processes that same event.
assert!(
s.char_input("a"),
"an autoFocus box consumes the acquiring event"
);
assert!(s.has_keyboard_focus());
assert!(s.eval::<bool>("return E:HasFocus()").unwrap());
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "a");
}
/// **An `autoFocus` box takes the keyboard when it is shown** — the OnShow vtable override
/// (`0x81c910` slot +0x30, `0x77a750`), missing here until decision 1686 because wow-re's own
/// "verified by absence" negative came from a `call`-only census that could not see its tail-`jmp`.
/// Gated on nothing else holding focus, and the gate is the whole of it — no topmost/best choice.
#[test]
fn an_autofocus_box_takes_the_keyboard_when_it_is_shown() {
let s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:Hide()
E:ClearFocus()
"#,
)
.unwrap();
assert!(!s.has_keyboard_focus(), "hidden and unfocused to start");
s.run("E:Show()").unwrap();
assert!(
s.eval::<bool>("return E:HasFocus()").unwrap(),
"showing an autoFocus box focuses it",
);
// Hiding the box that holds the keyboard releases it (the mirror override, slot +0x34).
s.run("E:Hide()").unwrap();
assert!(
!s.has_keyboard_focus(),
"hiding the focused box releases the keyboard",
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The two gates, each shown to bite. A box that opted out does not take the keyboard on show; and
/// a box that would have is refused while another still holds it — the reference's
/// `if ([0xcf4dc8] == 0 && (flags & 1))`, both halves.
#[test]
fn the_show_focus_is_refused_without_autofocus_or_with_the_keyboard_taken() {
let s = script();
s.run(
r#"
OPTED_OUT = CreateFrame("EditBox", "OptedOut")
OPTED_OUT:SetAutoFocus(false)
OPTED_OUT:Hide()
OPTED_OUT:ClearFocus()
"#,
)
.unwrap();
s.run("OPTED_OUT:Show()").unwrap();
assert!(
!s.has_keyboard_focus(),
"autoFocus=false is an opt-out that holds on show",
);
// Now give the keyboard away, and show an autoFocus box into an occupied focus.
s.run(
r#"
HOLDER = CreateFrame("EditBox", "Holder")
HOLDER:SetFocus()
LATE = CreateFrame("EditBox", "Late")
LATE:Hide()
"#,
)
.unwrap();
s.run("LATE:Show()").unwrap();
assert!(
s.eval::<bool>("return HOLDER:HasFocus()").unwrap(),
"a shown autoFocus box does not steal a focus that is already held",
);
assert!(!s.eval::<bool>("return LATE:HasFocus()").unwrap());
// And hiding a box that does NOT hold the keyboard leaves it where it is — `0x77e410`'s own
// per-box guard (`cmp ecx,eax; jne ret`), which is what makes the override's unconditional
// tail-jmp harmless.
s.run("LATE:Hide()").unwrap();
assert!(
s.eval::<bool>("return HOLDER:HasFocus()").unwrap(),
"hiding an unfocused box does not clear somebody else's focus",
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn non_autofocus_unfocused_box_ignores_input() {
let mut s = script();
// `SetAutoFocus(false)` explicitly: the ctor's `flags = 1` leaves autoFocus **ON** by default
// (`0x779a29`/`0x779a2e`, decision 1686), so a bare `CreateFrame("EditBox")` is an autoFocus
// box and would self-acquire on the first char. This test is about the other kind.
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(false)"#)
.unwrap();
assert!(!s.char_input("a"), "no focus, no autoFocus → not consumed");
assert!(!s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false
}));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
assert!(!s.has_keyboard_focus());
}
#[test]
fn focused_box_consumes_everything() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
// Empty text: these do nothing, but a focused box still consumes them.
assert!(s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false
}));
assert!(s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
extend: false
}));
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: false
}));
assert!(s.char_input("x"));
}
#[test]
fn set_focus_on_hidden_box_is_a_noop() {
let s = script();
s.run(
r#"
gained = 0
E = CreateFrame("EditBox", "E")
E:SetScript("OnEditFocusGained", function() gained = gained + 1 end)
E:Hide()
E:SetFocus()
"#,
)
.unwrap();
assert!(!s.eval::<bool>("return E:HasFocus()").unwrap());
assert!(!s.has_keyboard_focus());
assert_eq!(
s.eval::<i64>("return gained").unwrap(),
0,
"no focus gained"
);
}
#[test]
fn click_focuses_regardless_of_autofocus_and_transition_order_is_lost_then_gained() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
log = {}
local function wire(name, y)
local f = CreateFrame("EditBox", name)
f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, y)
f:SetSize(100, 20)
f:SetScript("OnEditFocusGained", function() table.insert(log, "gained"..name) end)
f:SetScript("OnEditFocusLost", function() table.insert(log, "lost"..name) end)
end
wire("A", 0) -- rect bottom 0..20
wire("B", 100) -- rect bottom 100..120
"#,
)
.unwrap();
s.resolve();
// Neither box has autoFocus, yet a click focuses each.
s.mouse_button(50.0, 10.0, "LeftButton", true);
s.mouse_button(50.0, 10.0, "LeftButton", false);
assert!(s.eval::<bool>("return A:HasFocus()").unwrap());
s.mouse_button(50.0, 110.0, "LeftButton", true);
s.mouse_button(50.0, 110.0, "LeftButton", false);
assert!(s.eval::<bool>("return B:HasFocus()").unwrap());
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["gainedA", "lostA", "gainedB"]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── §3 text buffer + editing + OnTextChanged/OnTextSet ───────────────────────────────────────
/// **`OnTextChanged` is deferred and coalesced** (decision 1831). An edit only raises the
/// `textChanged` dirty bit; the fire belongs to the drain (`0x77d3e0`) that the box's own OnUpdate
/// runs. So three typed characters are three marks on ONE box and produce exactly ONE fire — this
/// test asserted three before the law was checked.
#[test]
fn typing_coalesces_into_one_deferred_ontextchanged() {
let mut s = script();
s.run(
r#"
changed = 0
E = CreateFrame("EditBox", "E")
seen = ""
E:SetScript("OnTextChanged", function() changed = changed + 1 seen = E:GetText() end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.char_input("a"));
assert!(s.char_input("b"));
assert!(s.char_input("c"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
assert_eq!(
s.eval::<i64>("return changed").unwrap(),
0,
"the text is already there, but nothing has drained yet"
);
s.tick(0.0);
assert_eq!(
s.eval::<i64>("return changed").unwrap(),
1,
"three edits, one fire, carrying the final text"
);
assert_eq!(s.eval::<String>("return seen").unwrap(), "abc");
// Nothing is pending now, so a second drain fires nothing.
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
}
/// The two fires part company (decision 1831): **`OnTextSet` is synchronous, inside `SetText`
/// itself** (`0x77be6b`), while `OnTextChanged` waits for the drain. The equality short-circuit
/// still suppresses both. So writing "hi", "hi", "bye" before any drain logs two `set`s and then a
/// SINGLE `changed` carrying only the last value — `A → B` coalesces.
#[test]
fn set_text_fires_ontextset_at_once_and_ontextchanged_at_the_drain() {
let mut s = script();
s.run(
r#"
log = {}
E = CreateFrame("EditBox", "E")
E:SetScript("OnTextSet", function() table.insert(log, "set") end)
E:SetScript("OnTextChanged", function() table.insert(log, "changed") end)
E:SetText("hi") -- fires set NOW, marks changed for the drain
E:SetText("hi") -- unchanged → short-circuit, no events at all
E:SetText("bye") -- fires set NOW; the pending change simply becomes "bye"
"#,
)
.unwrap();
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["set", "set"], "no drain has run yet");
s.tick(0.0);
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["set", "set", "changed"]);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "bye");
}
#[test]
fn numeric_aborts_a_mixed_insert_wholesale() {
let s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetNumeric(true)
E:Insert("12") -- all digits: accepted
E:Insert("3a") -- one non-digit: the WHOLE insert aborts
"#,
)
.unwrap();
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "12");
}
#[test]
fn max_letters_trims_from_the_end() {
let s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetMaxLetters(3)
E:Insert("abcde")
"#,
)
.unwrap();
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 3);
}
// ── §4 selection / highlight / keys ──────────────────────────────────────────────────────────
#[test]
fn highlight_all_then_typing_replaces_the_selection() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetText("hello")
E:HighlightText(0, -1) -- select all
E:SetFocus()
"#,
)
.unwrap();
assert!(s.char_input("X"), "focused: consumed");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "X");
}
#[test]
fn paste_inserts_at_the_cursor_and_replaces_the_selection() {
let mut s = script();
s.run(
r#"
changed = 0
E = CreateFrame("EditBox", "E")
E:SetScript("OnTextChanged", function() changed = changed + 1 end)
E:SetText("ab")
E:SetFocus()
changed = 0 -- ignore the setup SetText's fire; count only the pastes below
"#,
)
.unwrap();
// Cursor sits at end after SetText → paste appends.
assert!(s.paste("CD"), "focused box consumes the paste");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abCD");
// Select-all then paste replaces the whole selection.
s.run("E:HighlightText(0, -1)").unwrap();
assert!(s.paste("xyz"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "xyz");
// Both pastes marked the same box, so the drain owes exactly one fire — not one per paste.
assert_eq!(s.eval::<i64>("return changed").unwrap(), 0);
s.tick(0.0);
assert_eq!(s.eval::<i64>("return changed").unwrap(), 1);
}
#[test]
fn paste_into_an_unfocused_box_is_not_consumed() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(false)"#)
.unwrap();
assert!(!s.paste("hi"), "no focus, no autoFocus → not consumed");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
}
#[test]
fn paste_strips_newlines_in_a_single_line_box_but_keeps_them_when_multiline() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
// Single-line: newlines/tabs and other control chars are dropped; spaces survive.
assert!(s.paste("one\ntwo\tthree\r"));
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"onetwothree"
);
// Multi-line box: the newline survives (a tab is still a dropped control char).
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetMultiLine(true); E:SetFocus()"#)
.unwrap();
assert!(s.paste("one\ntwo"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "one\ntwo");
}
#[test]
fn paste_honors_max_letters_and_numeric() {
// maxLetters trims the paste from the end.
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetMaxLetters(3); E:SetFocus()"#)
.unwrap();
assert!(s.paste("abcdef"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc");
// numeric aborts a paste that carries any non-digit (matching the typed-insert rule).
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetNumeric(true); E:SetFocus()"#)
.unwrap();
assert!(s.paste("12a3"), "consumed even though the insert aborts");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
assert!(s.paste("456"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "456");
}
#[test]
fn paste_does_not_fire_onspacepressed() {
let mut s = script();
s.run(
r#"
spaces = 0
E = CreateFrame("EditBox", "E")
E:SetScript("OnSpacePressed", function() spaces = spaces + 1 end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.paste("a b c"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "a b c");
assert_eq!(
s.eval::<i64>("return spaces").unwrap(),
0,
"a paste is not a typed space"
);
}
#[test]
fn backspace_deletes_the_selection() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetText("hello")
E:HighlightText(0, -1)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true
}));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
}
#[test]
fn enter_escape_tab_space_fire_their_slots() {
let mut s = script();
s.run(
r#"
log = {}
E = CreateFrame("EditBox", "E")
E:SetScript("OnEnterPressed", function() table.insert(log, "enter") end)
E:SetScript("OnEscapePressed", function() table.insert(log, "escape") end)
E:SetScript("OnTabPressed", function() table.insert(log, "tab") end)
E:SetScript("OnSpacePressed", function() table.insert(log, "space") end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.key_input("ENTER")); // single-line → OnEnterPressed
assert!(s.key_input("ESCAPE")); // fires, does NOT release focus
assert!(
s.eval::<bool>("return E:HasFocus()").unwrap(),
"ESCAPE keeps focus"
);
assert!(s.key_input("TAB"));
assert!(s.char_input(" ")); // a literal space fires OnSpacePressed
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["enter", "escape", "tab", "space"]);
// ENTER on a single-line box must NOT insert; only the space landed.
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), " ");
}
#[test]
fn multiline_enter_inserts_a_newline_without_onspacepressed() {
let mut s = script();
s.run(
r#"
spaces = 0
E = CreateFrame("EditBox", "E")
E:SetMultiLine(true)
E:SetScript("OnSpacePressed", function() spaces = spaces + 1 end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.key_input("ENTER"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "\n");
assert_eq!(s.eval::<i64>("return spaces").unwrap(), 0);
}
/// **Alt-arrow mode: the two real verbs, and the flag they share with the XML attribute.**
///
/// The §5 (`ignorearrows-alt-arrow-gate.md`) settled four things this pins:
///
/// · 5875 has **no `SetIgnoreArrows`** — the 48-entry EditBox method table
/// `[0x87bb68, 0x87bce8)` carries `SetAltArrowKeyMode`/`GetAltArrowKeyMode` at 46/47 and no
/// entry whose name contains "Ignore". benilla published the invented name for two rounds and
/// was missing both real ones.
/// · The XML attribute `ignoreArrows` and the Lua verbs drive **one** flag (`[E+0x318] & 0x10`).
/// · The setter's argument is `GetBoolOrDefault(L, 2, default = 1)` (`0x6f1c10`), not Lua
/// truthiness — an **absent** argument ENABLES, `""` ENABLES, `0` and `"0"` disable.
/// · The getter answers the **number 1 or nil**, never a boolean.
///
/// And the negative that matters: the engine core no longer swallows anything. The gate is on the
/// KEY and lives in the host (`UiKeyboardCapture::arrows_fall_through`), so an `EditAction` that
/// arrives here moves the caret whatever the flag says — it only ever arrives when ALT was held or
/// the key was not an arrow.
#[test]
fn alt_arrow_key_mode_is_the_flag_and_the_engine_core_no_longer_swallows_moves() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetText("abc")
E:SetFocus()
"#,
)
.unwrap();
// The invented name is gone.
assert!(
s.eval::<bool>("return E.SetIgnoreArrows == nil").unwrap(),
"5875 has no SetIgnoreArrows — publishing it was decision 1189's error"
);
// The getter: a number or nil, never a boolean.
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
None
);
s.run("E:SetAltArrowKeyMode(1)").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
Some(1)
);
assert!(
s.eval::<bool>("return type(E:GetAltArrowKeyMode()) == 'number'")
.unwrap(),
"the set arm pushes the double 1.0 (0x6f3810), not a boolean"
);
// `GetBoolOrDefault(default = 1)`, arm by arm — three of these are backwards under Lua
// truthiness, which is why the coercion has its own helper.
for (arg, want) in [
("", Some(1)), // ABSENT -> the default 1 -> enabled
("nil", None), // nil -> 0
("0", None), // __ftol(0) -> false
("-1", Some(1)), // __ftol(-1) != 0 -> true
(r#""0""#, None), // the string "0" -> false
(r#""""#, Some(1)), // "" matches no arm -> the default -> enabled
] {
s.run("E:SetAltArrowKeyMode(nil)").unwrap();
s.run(&format!("E:SetAltArrowKeyMode({arg})")).unwrap();
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
want,
"SetAltArrowKeyMode({arg})"
);
}
// The engine core does NOT swallow a Char move any more — the gate is on the key, upstream.
// `SetText` leaves the caret at the end, so one back-Char move puts it between 'b' and 'c'.
s.run(r#"E:SetAltArrowKeyMode(1) E:SetText("abc")"#)
.unwrap();
assert!(s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false
}));
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true
}));
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"ac",
"the caret moved, so BACKSPACE took 'b' — a flagged box that is handed the action acts on it"
);
}
/// The XML attribute is the same flag under its other name (`ignoreArrows` occurs once in the
/// image, at `0x879b78`, with one xref: the attribute push at `0x77a13a`).
#[test]
fn the_ignore_arrows_xml_attribute_is_alt_arrow_key_mode() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
s.run("E:SetAltArrowKeyMode(1)").unwrap();
assert_eq!(
s.eval::<Option<i64>>("return E:GetAltArrowKeyMode()")
.unwrap(),
Some(1)
);
}
// ── password + GetNumber ─────────────────────────────────────────────────────────────────────
#[test]
fn password_masks_the_display_but_gettext_returns_the_real_text() {
let s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetPassword(true)
E:SetText("secret")
"#,
)
.unwrap();
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "secret");
assert_eq!(
text_quad(&s).as_deref(),
Some("******"),
"the text region shows one '*' per character"
);
}
#[test]
fn get_number_parses_the_text() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetText("12.5")"#)
.unwrap();
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 12.5);
// Non-numeric text → 0.
s.run(r#"E:SetText("hello")"#).unwrap();
assert_eq!(s.eval::<f64>("return E:GetNumber()").unwrap(), 0.0);
}
// ── the EditBox override never fires generic OnChar/OnKeyDown (§2) ────────────────────────────
#[test]
fn typing_fires_the_generic_on_char_with_what_was_inserted() {
// **The half of the old law that was wrong** (wow-re, corrected 2026-08-29; decision 1686).
// `CSimpleEditBox` has its own input vtable which does not chain to the base — but Insert
// itself fires the generic `OnChar` slot (`+0x180`) at `0x77c13c`, through the **varargs**
// firer `0x7026f0` with fmt `"%s"` and the spliced string as the argument. The published
// negative came from censusing only the fixed-arity firer `0x702690`: one member of a
// two-member family.
let mut s = script();
s.run(
r#"
got = {}
E = CreateFrame("EditBox", "E")
E:EnableKeyboard(true)
E:SetScript("OnChar", function() table.insert(got, arg1) end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.char_input("k"), "the focused box consumed the character");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "k");
assert_eq!(
s.eval::<String>("return table.concat(got, ',')").unwrap(),
"k",
"OnChar fires once, with the inserted string",
);
// `SetText` is NOT an insert path and fires nothing — the seam the reference has too.
s.run(r#"E:SetText("zzz")"#).unwrap();
assert_eq!(
s.eval::<String>("return table.concat(got, ',')").unwrap(),
"k",
"SetText does not run Insert, so it does not fire OnChar",
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
#[test]
fn key_paths_never_fire_the_generic_on_key_down() {
// The surviving half (wow-re `frame-key-script-delivery.md` §1): the box's own key-down
// vtable (`0x77b160`) handles the event and never chains to the base, so a focused box's
// typing does not also run a generic `OnKeyDown` bound on that same box. Only the `OnChar`
// half of the original claim was corrected — this one was re-censused and stands.
let mut s = script();
s.run(
r#"
fired = 0
E = CreateFrame("EditBox", "E")
E:EnableKeyboard(true)
E:SetScript("OnKeyDown", function() fired = fired + 1 end)
E:SetFocus()
"#,
)
.unwrap();
assert!(s.char_input("k"), "the focused box consumed the character");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "k");
assert_eq!(
s.eval::<i64>("return fired").unwrap(),
0,
"the box's own vtable never chains to the generic OnKeyDown"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── history recall (`historyLines` / AddHistoryLine — decision 0288 P2) ──────────────────────
#[test]
fn history_recall_walks_up_and_down_and_restores_the_draft() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetHistoryLines(32)
E:AddHistoryLine("/say one")
E:AddHistoryLine("/g two")
E:SetText("draft")
E:SetFocus()
"#,
)
.unwrap();
// UP recalls newest-first; a second UP walks older; at the oldest it holds.
assert!(s.editbox_action(EditAction::HistoryPrev));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/say one");
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/say one");
// DOWN walks newer; past the newest the stashed live draft comes back.
s.editbox_action(EditAction::HistoryNext);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
s.editbox_action(EditAction::HistoryNext);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "draft");
// Not browsing: DOWN does nothing.
s.editbox_action(EditAction::HistoryNext);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "draft");
}
#[test]
fn typing_ends_the_history_browse() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetHistoryLines(32)
E:AddHistoryLine("older")
E:AddHistoryLine("newer")
E:SetFocus()
"#,
)
.unwrap();
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer");
// A typed char turns the recalled line into an ordinary draft: the next UP starts a FRESH
// browse from the newest entry (stashing the edited line as the new draft).
s.char_input("!");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer!");
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer");
s.editbox_action(EditAction::HistoryNext);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "newer!");
}
#[test]
fn programmatic_set_text_keeps_the_browse_and_focus_gain_resets_it() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetHistoryLines(32)
E:AddHistoryLine("/say one")
E:AddHistoryLine("/g two")
E:SetFocus()
"#,
)
.unwrap();
// Recall the newest, then rewrite the box the way the chat live parse does on a slash
// recall ("/g two" → Guild + "two"). The browse walk must survive: the next UP lands on
// the OLDER entry, not back on the newest (the "history only goes back 1" bug).
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
s.run(r#"E:SetText("two")"#).unwrap();
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/say one");
// Refocusing starts a fresh session: the stale walk drops, UP recalls the newest again.
s.run(r#"E:ClearFocus() E:SetFocus()"#).unwrap();
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "/g two");
}
#[test]
fn history_caps_at_history_lines_drop_oldest() {
let mut s = script();
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetHistoryLines(2)
E:AddHistoryLine("a")
E:AddHistoryLine("b")
E:AddHistoryLine("c")
E:SetFocus()
"#,
)
.unwrap();
assert_eq!(s.eval::<i64>("return E:GetHistoryLines()").unwrap(), 2);
// Only b/c survive: two UPs land on 'b', a third holds there.
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "c");
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "b");
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "b");
}
#[test]
fn history_off_by_default_and_up_is_still_consumed() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetText("t"); E:SetFocus()"#)
.unwrap();
// No historyLines: AddHistoryLine is a no-op, UP consumed but inert (a focused box eats every
// key — RF-0082 §2).
s.run(r#"E:AddHistoryLine("x")"#).unwrap();
assert!(s.editbox_action(EditAction::HistoryPrev));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "t");
}
// ── SetTextInsets (0288 P2 — the header-driven text-rect shrink) ─────────────────────────────
#[test]
fn text_insets_shrink_the_text_region_rect() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetWidth(400)
E:SetHeight(32)
E:SetTextInsets(15, 13, 2, 3)
E:SetText("hello")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let rect = quads
.iter()
.find_map(|q| match (&q.content, q.rect) {
(crate::script::QuadContent::Text { text: Some(t), .. }, Some(r)) if t == "hello" => {
Some(r)
}
_ => None,
})
.expect("the edit box text renders");
assert!((rect.left - 115.0).abs() < 0.01, "left inset 15: {rect:?}");
assert!(
(rect.right - 487.0).abs() < 0.01,
"right inset 13: {rect:?}"
);
assert!((rect.top - 80.0).abs() < 0.01, "top inset 2: {rect:?}");
assert!(
(rect.bottom - 53.0).abs() < 0.01,
"bottom inset 3: {rect:?}"
);
// The getter echoes.
let (l, r_, t, b) = s
.eval::<(f32, f32, f32, f32)>("return E:GetTextInsets()")
.unwrap();
assert_eq!((l, r_, t, b), (15.0, 13.0, 2.0, 3.0));
}
// The FontInstance half an EditBox inherits in the client: SetTextColor tints the box's text
// region (`ChatEdit_UpdateHeader` colors the typed text this way — its absence killed the chat
// header chunk mid-run and left the /w insets stale, the caret-in-the-header bug).
#[test]
fn set_text_color_tints_the_text_region() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetWidth(400)
E:SetHeight(32)
E:SetText("hello")
E:SetTextColor(1.0, 0.5, 0.25)
"#,
)
.unwrap();
s.resolve();
let color = s
.extract()
.iter()
.find_map(|q| match &q.content {
crate::script::QuadContent::Text {
text: Some(t),
color,
..
} if t == "hello" => Some(*color),
_ => None,
})
.expect("the edit box text renders");
assert_eq!(color, Some([1.0, 0.5, 0.25, 1.0]));
}
// ── the host text-UI seam: advances, caret/selection geometry, mouse, clipboard ─────────────
/// Build a focused 200×32 box at BOTTOMLEFT(100,50), resolve it, type `text`, and answer its
/// advance table with a synthetic monospace 7 px/byte — the standard rig for the geometry tests.
fn seam_rig(s: &mut UiScript, text: &str) {
s.set_screen_size(800.0, 600.0);
s.run(
r#"
E = CreateFrame("EditBox", "E")
E:SetWidth(200); E:SetHeight(32)
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetFocus()
"#,
)
.unwrap();
for ch in text.chars() {
s.char_input(&ch.to_string());
}
s.resolve();
answer_advances(s);
}
/// Answer any pending advance request at 7 px per byte.
fn answer_advances(s: &mut UiScript) {
if let Some(req) = s.editbox_advances_request() {
let cum: Vec<f32> = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect();
s.set_editbox_advances(req.id, req.key, cum, vec![0], 0.0);
}
}
/// The seam reports caret geometry from the advance table, and vanishes with focus or
/// visibility — the old prefix-string seam's laws, now in pixels.
#[test]
fn text_ui_reports_caret_geometry_and_focus() {
let mut s = script();
seam_rig(&mut s, "");
// Empty box: the advance table settles engine-side; the caret stands at the origin.
let ui = s
.focused_editbox_text_ui()
.expect("focused box has text-UI");
assert_eq!(ui.caret_x, 0.0);
assert_eq!(ui.display_from, 0);
assert!(ui.selection.is_empty());
// The empty box still emits its Text quad (Some("")) — the caret's ride-along.
assert!(
s.extract().iter().any(|q| matches!(
(&q.target, &q.content),
(t, QuadContent::Text { text: Some(x), .. }) if *t == ui.target && x.is_empty()
)),
"an empty focused box must still emit its text-region quad"
);
s.char_input("a");
s.char_input("b");
answer_advances(&mut s);
assert_eq!(s.focused_editbox_text_ui().unwrap().caret_x, 14.0);
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false,
});
assert_eq!(s.focused_editbox_text_ui().unwrap().caret_x, 7.0);
s.run("E:ClearFocus()").unwrap();
assert!(s.focused_editbox_text_ui().is_none(), "no focus, no caret");
s.run("E:SetFocus(); E:Hide()").unwrap();
assert!(s.focused_editbox_text_ui().is_none(), "hidden box: none");
}
/// Click places the cursor at the nearest char boundary (`0x77b800` → `0x77d0d0`), collapsing
/// any selection; dragging extends it (`0x77a860`); release ends the drag.
#[test]
fn click_places_cursor_and_drag_selects() {
let mut s = script();
seam_rig(&mut s, "hello world");
// Box left = 100; the text region fills it (no insets). Byte i sits at x = 100 + 7i.
// Click at x=121 → advance-space 21 → boundary 3.
assert!(s.mouse_button(121.0, 60.0, "LeftButton", true));
let ui = s.focused_editbox_text_ui().unwrap();
assert_eq!(ui.caret_x, 21.0, "cursor at byte 3");
assert!(ui.selection.is_empty(), "click collapses");
// Drag right to x=149 (byte 7): selection 3..7, cursor at 7.
s.mouse_move(149.0, 60.0);
let ui = s.focused_editbox_text_ui().unwrap();
assert_eq!(ui.selection, vec![(0, 21.0, 49.0)]);
assert_eq!(ui.caret_x, 49.0);
// Drag back LEFT past the anchor to x=107 (byte 1): the selection flips to 1..3.
s.mouse_move(107.0, 60.0);
let ui = s.focused_editbox_text_ui().unwrap();
assert_eq!(ui.selection, vec![(0, 7.0, 21.0)]);
// Release ends the drag: further moves change nothing.
s.mouse_button(107.0, 60.0, "LeftButton", false);
s.mouse_move(170.0, 60.0);
assert_eq!(
s.focused_editbox_text_ui().unwrap().selection,
vec![(0, 7.0, 21.0)]
);
// Nearest-boundary rounding: x=124.9 (advance 24.9): |24.9−21|=3.9 vs |28−24.9|=3.1 → byte 4.
s.mouse_button(124.9, 60.0, "LeftButton", true);
assert_eq!(s.focused_editbox_text_ui().unwrap().caret_x, 28.0);
s.mouse_button(124.9, 60.0, "LeftButton", false);
}
/// Ctrl+arrows jump word boundaries (alnum runs); Ctrl+Shift extends the selection there.
#[test]
fn ctrl_arrows_jump_words() {
let mut s = script();
seam_rig(&mut s, "abc def");
s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
extend: false,
});
s.editbox_action(EditAction::Move {
unit: EditUnit::Word,
back: false,
extend: false,
});
assert_eq!(
s.focused_editbox_text_ui().unwrap().caret_x,
21.0,
"end of 'abc'"
);
s.editbox_action(EditAction::Move {
unit: EditUnit::Word,
back: false,
extend: false,
});
assert_eq!(
s.focused_editbox_text_ui().unwrap().caret_x,
49.0,
"end of 'def'"
);
s.editbox_action(EditAction::Move {
unit: EditUnit::Word,
back: true,
extend: false,
});
assert_eq!(
s.focused_editbox_text_ui().unwrap().caret_x,
28.0,
"start of 'def'"
);
// Ctrl+Shift+LEFT from here selects back across 'abc'.
s.editbox_action(EditAction::Move {
unit: EditUnit::Word,
back: true,
extend: true,
});
assert_eq!(
s.focused_editbox_text_ui().unwrap().selection,
vec![(0, 0.0, 28.0)]
);
}
/// Copy needs a selection; cut copies then deletes; the password box yields the mask run, never
/// the real text (RF-0082 §4's placeholder law, mask stand-in).
#[test]
fn copy_cut_and_the_password_placeholder() {
let mut s = script();
seam_rig(&mut s, "secret");
assert_eq!(s.editbox_copy(), None, "no selection, no copy");
s.run("E:HighlightText(0, 3)").unwrap();
assert_eq!(s.editbox_copy().as_deref(), Some("sec"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "secret");
assert_eq!(s.editbox_cut().as_deref(), Some("sec"));
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "ret");
s.run(r#"P = CreateFrame("EditBox", "P"); P:SetPassword(true); P:SetFocus()"#)
.unwrap();
s.char_input("h");
s.char_input("i");
s.run("P:HighlightText(0, -1)").unwrap();
assert_eq!(
s.editbox_copy().as_deref(),
Some("**"),
"password copy must never yield the real text"
);
}
/// The OS-native delete family (no 1.12 counterpart — the host keymap's Option/Cmd/Ctrl
/// Backspace-Delete chords): a word delete takes the adjacent alnum run (+ the separators toward
/// it), an edge delete clears to the line end — and both collapse to "delete the selection" when
/// one exists, like every deletion gesture.
#[test]
fn word_and_edge_deletes() {
let mut s = script();
seam_rig(&mut s, "abc def ghi");
// Word-delete back from the end: "ghi" goes (boundary at 8).
s.editbox_action(EditAction::Delete {
unit: EditUnit::Word,
back: true,
});
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc def ");
// Again: the space + "def" go together (back-walk skips separators first).
s.editbox_action(EditAction::Delete {
unit: EditUnit::Word,
back: true,
});
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "abc ");
// Word-delete forward from HOME: the "abc" run goes, its trailing space survives.
s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
extend: false,
});
s.editbox_action(EditAction::Delete {
unit: EditUnit::Word,
back: false,
});
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), " ");
// Selection-first: with a live selection, a word delete removes exactly it.
s.run(r#"E:SetText("hello world"); E:HighlightText(2, 5)"#)
.unwrap();
s.editbox_action(EditAction::Delete {
unit: EditUnit::Word,
back: true,
});
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "he world");
// Edge-delete back (macOS Cmd+Backspace): clears to the start — the whole line from the end.
s.run(r#"E:SetText("clear me")"#).unwrap();
s.editbox_action(EditAction::Delete {
unit: EditUnit::Edge,
back: true,
});
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "");
// Inert at the boundary: nothing to delete consumes without firing OnTextChanged.
s.run("n = 0; E:SetScript('OnTextChanged', function() n = n + 1 end)")
.unwrap();
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Edge,
back: true
}));
assert_eq!(s.eval::<i64>("return n").unwrap(), 0);
}
/// SelectAll (the ref's Ctrl+A → `HighlightText(0, -1)` law): whole text selected, caret to the
/// end; typing replaces the lot.
#[test]
fn select_all_action() {
let mut s = script();
seam_rig(&mut s, "abc def");
s.editbox_action(EditAction::SelectAll);
let ui = s.focused_editbox_text_ui().unwrap();
assert_eq!(ui.selection, vec![(0, 0.0, 49.0)], "all 7 bytes selected");
assert_eq!(ui.caret_x, 49.0, "caret at the end");
s.char_input("z");
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"z",
"an insert replaces the whole selection"
);
}
/// The scroll window follows the cursor: typing past the box edge advances `display_from`
/// (whole chars); HOME snaps it back (`0x77da80`'s stay-visible invariant, the char-granular
/// `E+0x348` window).
#[test]
fn scroll_window_keeps_the_cursor_visible() {
let mut s = script();
// 40 bytes × 7px = 280px in a 200px box (avail 198): the window must slide.
seam_rig(&mut s, &"x".repeat(40));
let ui = s.focused_editbox_text_ui().unwrap();
assert!(ui.display_from > 0, "overlong text must scroll: {ui:?}");
assert!(ui.caret_x <= 198.0, "caret inside the window: {ui:?}");
assert_eq!(
ui.caret_x,
(40 - ui.display_from) as f32 * 7.0,
"caret x measures from the window origin"
);
// HOME: the window snaps back to 0.
s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
extend: false,
});
let ui = s.focused_editbox_text_ui().unwrap();
assert_eq!(ui.display_from, 0);
assert_eq!(ui.caret_x, 0.0);
}
/// The blink law (`0x77a790`): 0.5 s half-period toggle, reset shown by every edit.
#[test]
fn caret_blinks_on_tick_and_resets_on_edit() {
let mut s = script();
seam_rig(&mut s, "a");
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
s.tick(0.6); // crosses the 0.5 default → hidden
assert!(!s.focused_editbox_text_ui().unwrap().caret_on);
s.tick(0.6); // crosses again → shown
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
s.tick(0.6);
assert!(!s.focused_editbox_text_ui().unwrap().caret_on);
// An edit shows the caret immediately (blink reset).
s.char_input("b");
answer_advances(&mut s);
assert!(s.focused_editbox_text_ui().unwrap().caret_on);
}
// ── A hyperlink is ONE keypress (RF-0087 §6, decision 1077) ──────────────────────────────────
//
// The engine-level law lives in `markup`; these drive it the way a player does — through the
// focused box's public keyboard API — because that is the level the reported defect lived at:
// backspacing a shift-clicked item link chewed it one invisible byte at a time, and the byte that
// went first was the `r` of its trailing `|r`, which turned everything typed afterwards the item's
// colour.
/// An epic link exactly as the client's own builder formats one (`0x52adb0`).
const LINK: &str = "|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|r";
fn box_with_link(suffix: &str) -> UiScript {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
s.run(&format!("E:Insert(\"{LINK}{suffix}\")")).unwrap();
s
}
fn text_of(s: &UiScript) -> String {
s.eval::<String>("return E:GetText()").unwrap()
}
#[test]
fn one_backspace_deletes_a_whole_item_link() {
let mut s = box_with_link("");
assert!(s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true
}));
assert_eq!(text_of(&s), "", "colour prefix and trailing |r go with it");
// And from the far side of following text: the link survives until the text is gone, then goes
// whole — never half-eaten, which is what left a dangling `|c` colouring the rest of the line.
let mut s = box_with_link("ab");
for expected in ["|cffa335ee|Hitem:11684:0:0:0|h[Ironfoe]|h|ra", LINK, ""] {
s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true,
});
assert_eq!(text_of(&s), expected);
}
}
#[test]
fn one_arrow_crosses_a_whole_item_link() {
let mut s = box_with_link("");
let cursor = |s: &mut UiScript| {
s.editbox_action(EditAction::Move {
unit: EditUnit::Edge,
back: true,
extend: false,
});
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: false,
extend: false,
});
};
cursor(&mut s);
// One RIGHT from the start lands past the entire link — so typing there appends, and does not
// land between the `]` and its `|h`.
s.char_input("x");
assert_eq!(text_of(&s), format!("{LINK}x"));
}
#[test]
fn shift_arrow_selects_the_whole_link_and_typing_replaces_it() {
let mut s = box_with_link("");
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: true,
});
assert_eq!(
s.editbox_copy().as_deref(),
Some(LINK),
"one Shift+LEFT takes the whole unit"
);
s.char_input("x");
assert_eq!(text_of(&s), "x");
}
#[test]
fn typing_strictly_inside_a_link_is_refused() {
// Only the mouse can put the caret there, so place it the way a click would.
let mut s = box_with_link("");
s.run("E:HighlightText(35, 35)").unwrap(); // mid-"Ironfoe"
s.char_input("x");
assert_eq!(
text_of(&s),
LINK,
"the client swallows it rather than splitting the link"
);
// The same keystroke at the link's leading edge is allowed — the guard tests the PREVIOUS
// token too, which is what keeps the boundary usable.
s.run("E:HighlightText(0, 0)").unwrap();
s.char_input("x");
assert_eq!(text_of(&s), format!("x{LINK}"));
}
#[test]
fn a_typed_pipe_is_doubled_and_costs_one_letter() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus()"#)
.unwrap();
s.char_input("|");
assert_eq!(
text_of(&s),
"||",
"OnChar 0x77c200 inserts the literal at 0x879cac"
);
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 1);
}
#[test]
fn max_letters_counts_visible_letters_not_escape_bytes() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetFocus(); E:SetMaxLetters(20)"#)
.unwrap();
s.run(&format!("E:Insert(\"{LINK}\")")).unwrap();
// 43 bytes, but "[Ironfoe]" is 9 letters — comfortably under a 20-letter cap that a raw char
// count would have blown through, trimming the link's own tail off.
assert_eq!(text_of(&s), LINK);
assert_eq!(s.eval::<i64>("return E:GetNumLetters()").unwrap(), 9);
}
/// **Creating a box is not showing it** — and that is what keeps the on-show self-focus safe now
/// that autoFocus defaults ON (decision 1686). A frame born visible is not an effective-visibility
/// *transition*, so it never runs the OnShow override; only a real `Show()` does. Without this,
/// loading the shipped chain would hand the keyboard to whichever edit box happened to be
/// constructed first, and typing would go into it instead of to the game.
///
/// Pinned because it is load-bearing by *absence*: nothing in the on-show path mentions creation,
/// so the day frame construction starts firing OnShow, this is the test that says what it costs.
#[test]
fn creating_a_box_does_not_focus_it_the_way_showing_one_does() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E")"#).unwrap();
assert!(
!s.has_keyboard_focus(),
"a box born visible has not been SHOWN, so it takes no keyboard",
);
assert!(!s.eval::<bool>("return E:HasFocus()").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **`SetMaxLetters` gates the COUNT and not the type** — one of four widget bindings in the whole
/// registrar that calls `lua_gettop`, and its gate is exact (`cmp eax,2`), while the value goes
/// through a bare `lua_tonumber` with no `isnumber` guard (wow-re `numeric-arg-coercion-law.md`
/// Q1/Q3, VERIFIED).
///
/// That pairing is the opposite of the usual one, which is why it earns a test: benilla typed the
/// argument `i64` and so raised on `SetMaxLetters(nil)` — aux-addon's `gui/core.lua:288` writes
/// exactly that, and died at load on it — while accepting the wrong *number* of arguments
/// silently.
///
/// And `0` is **no limit**, not "no letters".
#[test]
fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E") E:SetFocus()"#)
.unwrap();
let max = |s: &UiScript| s.eval::<i64>("return E:GetMaxLetters()").unwrap();
s.run("E:SetMaxLetters(12)").unwrap();
assert_eq!(max(&s), 12);
// nil is 0 is UNLIMITED — the call completes, which is the whole point.
s.run("E:SetMaxLetters(nil)").unwrap();
assert_eq!(max(&s), 0);
s.run(r#"E:SetMaxLetters(5) E:SetText("abcdefgh")"#)
.unwrap();
assert_eq!(s.eval::<String>("return E:GetText()").unwrap().len(), 5);
s.run(r#"E:SetMaxLetters(nil) E:SetText("abcdefgh")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"abcdefgh",
"0 skips the trim block whole (0x77c085) — it is no limit, not no letters"
);
// A numeric string coerces; anything else is 0.
s.run(r#"E:SetMaxLetters("12")"#).unwrap();
assert_eq!(max(&s), 12);
s.run("E:SetMaxLetters({})").unwrap();
assert_eq!(max(&s), 0);
// The COUNT is exact — too few AND too many both raise.
assert!(s.run("E:SetMaxLetters()").is_err(), "too few raises");
assert!(
s.run("E:SetMaxLetters(50, 60)").is_err(),
"too many raises too"
);
}
/// **A `CSimpleEditBox` is born with FIVE regions, and `GetRegions` hands them to Lua before any
/// authored one.** wow-re `scratch/rf85-editbox-caret.md` §1: the ctor builds the text FontString
/// (`E+0x328`, `0x779bee`), three selection-highlight `CSimpleTexture`s (`E+0x350/0x354/0x358`,
/// loop `0x779c41`–`0x779c72`) and the caret (`E+0x368`, `0x779c86`) — in that order. `GetRegions
/// 0x773f60` walks `[frame+0x1b8]`, one flat creation-ordered list, oldest first, no filter
/// (`scratch/widget-list-bindings.md`), and insertion is at the TAIL. So the authored `<Layers>`
/// regions start at index 6.
///
/// The report is pfUI's `skins/blizzard/friends.lua` l.379 —
/// `local _,_,_,_,_,left,right = GuildControlPopupFrameEditBox:GetRegions()` — which skips exactly
/// those five and takes the box's two border textures. It died on a nil `left` for as long as
/// benilla returned only the authored regions.
#[test]
fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<EditBox name="Box">
<Size><AbsDimension x="120" y="20"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="BoxLeft" file="Interface\Left"/>
<Texture name="BoxRight" file="Interface\Right"/>
</Layer>
</Layers>
<FontString inherits="ChatFontNormal"/>
</EditBox>
</Ui>"#,
)
.unwrap();
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Five engine regions, then the two authored textures — and NOT a sixth from the embedded
// `<FontString>`, which declares the ctor's object rather than adding one (RF-0028).
assert_eq!(
s.eval::<i64>("return Box:GetNumRegions()").unwrap(),
7,
"5 ctor regions + 2 authored textures, and the <FontString> adds none"
);
assert_eq!(
s.eval::<i64>("return select('#', Box:GetRegions())")
.unwrap(),
7,
"GetNumRegions is exactly the length GetRegions enumerates"
);
// pfUI's own read, verbatim in shape: skip five, take the authored pair.
let (left, right) = s
.eval::<(String, String)>(
"local _,_,_,_,_,l,r = Box:GetRegions() return l:GetTexture(), r:GetTexture()",
)
.unwrap();
assert_eq!(
left, r"Interface\Left",
"region 6 is the first authored one"
);
assert_eq!(right, r"Interface\Right", "region 7 is the second");
// The first is the text FontString the box actually types into.
s.run(r#"Box:SetText("typed")"#).unwrap();
assert_eq!(
s.eval::<String>("local t = Box:GetRegions() return t:GetText()")
.unwrap(),
"typed",
"region 1 is the ctor's embedded text FontString"
);
// 2..5 are the three selection quads and the caret: real regions, carrying no art of their own
// (benilla paints both host-side — the gap named on EditBoxState).
assert!(
s.eval::<bool>(
"local a,b,c,d,e = Box:GetRegions() \
return b:GetTexture() == nil and c:GetTexture() == nil \
and d:GetTexture() == nil and e:GetTexture() == nil"
)
.unwrap(),
"the selection trio and the caret are blank quads"
);
}
/// `SetNumber` is `SetText` with Lua's own `%.14g` in front of it — the two bindings are
/// byte-identical in the reference (`0x798690` / `0x7984c0`), so the numeric verb does no numeric
/// work of its own. Decision 1831; the expected strings are the reference's executed output.
#[test]
fn set_number_formats_like_the_references_printf() {
let s = UiScript::new().unwrap();
s.run(r#"box = CreateFrame("EditBox", "NumBox", UIParent)"#)
.unwrap();
for (input, want) in [
("3", "3"),
("0.8", "0.8"),
("-0.5", "-0.5"),
("0.1 + 0.2", "0.3"),
("1/3", "0.33333333333333"),
("1e13", "10000000000000"),
// The three-digit exponent, which is MSVC's `%g` and not C99's. `1e+020`, never `1e+20`.
("1e14", "1e+014"),
("1e20", "1e+020"),
("1e-5", "1e-005"),
// Just inside the fixed-notation window.
("1e-4", "0.0001"),
// `%g` drops the sign on negative zero.
("-0.0", "0"),
] {
s.run(&format!("box:SetNumber({input})")).unwrap();
assert_eq!(
s.eval::<String>("return box:GetText()").unwrap(),
want,
"SetNumber({input})"
);
}
// A STRING is accepted and passed through verbatim — the gate is `lua_isstring`, so the
// argument is never parsed as a number.
s.run(r#"box:SetNumber("abc")"#).unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "abc");
// Everything else raises, including an ABSENT argument. `luaL_error` does not return.
for bad in [
"box:SetNumber()",
"box:SetNumber(nil)",
"box:SetNumber(true)",
] {
assert!(s.run(bad).is_err(), "{bad} must raise");
}
}
/// A `numeric` box takes the reference's wholesale abort: the clear-all has already run when the
/// insert fails the digit test, so the box is left EMPTY rather than partly filled. The money
/// boxes (`MoneyInputFrame.xml`) are numeric, which is why this matters — though the money path
/// itself only ever passes non-negative integers.
#[test]
fn set_number_on_a_numeric_box_empties_it_when_the_text_is_not_all_digits() {
let s = UiScript::new().unwrap();
s.run(r#"box = CreateFrame("EditBox", "NumOnly", UIParent) box:SetNumeric(true)"#)
.unwrap();
s.run("box:SetNumber(1234)").unwrap();
assert_eq!(s.eval::<String>("return box:GetText()").unwrap(), "1234");
// The minus sign fails the digit test, and the abort is wholesale.
s.run("box:SetNumber(-5)").unwrap();
assert_eq!(
s.eval::<String>("return box:GetText()").unwrap(),
"",
"a sign empties a numeric box rather than partly filling it"
);
}
/// `SetMaxBytes` (1960): the exact count gate and raw coerce of its sibling, a BYTE cap on the
/// buffer, and -1 as the no-limit sentinel where `SetMaxLetters` reads 0.
#[test]
fn set_max_bytes_caps_the_buffer_in_bytes_with_minus_one_unlimited() {
let s = script();
s.run(r#"E = CreateFrame("EditBox", "E") E:SetFocus()"#)
.unwrap();
let max = |s: &UiScript| s.eval::<i64>("return E:GetMaxBytes()").unwrap();
assert_eq!(max(&s), -1, "unlimited from the ctor");
s.run("E:SetMaxBytes(4)").unwrap();
assert_eq!(max(&s), 4);
// Four bytes hold two two-byte letters and not a third.
s.run(r#"E:SetText("ééé")"#).unwrap();
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "éé");
s.run(r#"E:SetMaxBytes(0) E:SetText("ééé")"#).unwrap();
assert_eq!(max(&s), -1, "a non-positive value is unlimited");
assert_eq!(s.eval::<String>("return E:GetText()").unwrap(), "ééé");
s.run(r#"E:SetMaxBytes("3") E:SetText("abcd")"#).unwrap();
assert_eq!(
s.eval::<String>("return E:GetText()").unwrap(),
"abc",
"a numeric string coerces"
);
s.run("E:SetMaxBytes(nil)").unwrap();
assert_eq!(max(&s), -1, "nil coerces to 0, which is unlimited");
assert!(s.run("E:SetMaxBytes()").is_err(), "too few raises");
assert!(s.run("E:SetMaxBytes(1, 2)").is_err(), "too many raises");
}
/// **`OnCursorChanged(x, y, w, h)` fires when the caret moves** — the edge the shipped
/// `MailFrame.xml` and `HelpFrame.xml` wire `ScrollingEdit_OnCursorChanged` to, and the reason a
/// multiline box follows its caret as you type past the bottom (decision 2141).
///
/// The four args are the reference's (wow-re's RF-0085 caret law, VERIFIED): `x` the caret's
/// advance along its line, `y` **negative-downward** by row (which is what
/// `ScrollingEdit_OnCursorChanged`'s `cursorOffset = y` then `-this.cursorOffset` reads back as a
/// positive distance), `w` the constant `4.0`, `h` the line height.
#[test]
fn the_caret_flush_fires_on_cursor_changed_with_the_references_four_args() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
fires = {}
E = CreateFrame("EditBox", "E")
E:SetWidth(200); E:SetHeight(64)
E:SetPoint("BOTTOMLEFT", 100, 50)
E:SetMultiLine(true)
E:SetScript("OnCursorChanged", function()
table.insert(fires, { x = arg1, y = arg2, w = arg3, h = arg4 })
end)
E:SetFocus()
"#,
)
.unwrap();
s.resolve();
// Three bytes on row 0, then a wrap onto row 1 — the host answers the rows and the pitch.
for ch in "abcdef".chars() {
s.char_input(&ch.to_string());
}
s.resolve();
if let Some(req) = s.editbox_advances_request() {
// A plain monotonic 7 px/byte table (the rig's own), wrapped into two rows at byte 3 —
// `caret_row_x` subtracts the row start's cumulative advance, which is the whole point.
let cum: Vec<f32> = (0..=req.text.len()).map(|i| i as f32 * 7.0).collect();
s.set_editbox_advances(req.id, req.key, cum, vec![0, 3], 12.0);
}
s.tick(0.016);
assert!(s.errors().is_empty(), "{:?}", s.errors());
let n: i64 = s.eval("return table.getn(fires)").unwrap();
assert!(n >= 1, "the flush fired at least once");
let (x, y, w, h): (f64, f64, f64, f64) = s
.eval("local f = fires[table.getn(fires)] return f.x, f.y, f.w, f.h")
.unwrap();
// The caret sits after 6 bytes: row 1 (rows start at 0 and 3), 3 bytes along it.
assert_eq!(
x, 21.0,
"x is the advance from the ROW's start, not the text's"
);
assert_eq!(
y, -12.0,
"y is minus the row index times the pitch — downward is negative"
);
assert_eq!(w, 4.0, "w is the reference's constant, not a measurement");
assert_eq!(h, 12.0, "h is the line height");
// **The edge is a CHANGE.** A tick that moves nothing fires nothing — which is what makes
// `ScrollingEdit_OnUpdate`'s `if (this.cursorOffset)` loop terminate instead of re-scrolling
// every frame.
let before: i64 = s.eval("return table.getn(fires)").unwrap();
s.tick(0.016);
s.tick(0.016);
assert_eq!(
s.eval::<i64>("return table.getn(fires)").unwrap(),
before,
"two quiet ticks fire nothing"
);
// …and a caret move fires again.
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false,
});
s.tick(0.016);
assert!(
s.eval::<i64>("return table.getn(fires)").unwrap() > before,
"moving the caret one char fires the flush"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}