Files
benilla-octo/crates/benilla-ui/src/script/editbox/tests.rs
T
SamandClaude Fable 5 8ae42cc7ab 71 more key commands, and the quest windows wear the reference's names
- the binding registry goes 130 → 201 of the client's 228 live commands: a coverage gate makes registered ∪ recorded-absent exactly the 1.12 surface, each absence naming the Lua globals whose arrival refutes it — which is how 37 rows whose mechanism had already landed came home (the keyring, the pet book, the raid tab, the six action-bar pages, both vertical multibars) — and then four mechanisms bring 71 more: the five camera views with the reference's default table and its sixteen camera CVars read out of the binary (NextView/PrevView do NOT wrap), self-cast riding UseAction's third argument with autoSelfCast a real CVar at last, the friendly target cone / assist / last-hostile as one cycler with a side (the two sides use different deadness tests on purpose, and assist neither requires an assistable target nor swings), and REPLY2 — reply to the last person you told; the friendly cone also stops borrowing the enemy side's in-combat score bonus, which would have shoved a healer targeting you to the head of every cycle
- the key names close their gaps: NUMPADEQUALS, F13-F24 and BUTTON6+ were keys the binding window listed and the file saved that the dispatcher could never press — named both ways now, with the namer and the codec structurally unable to disagree again; platform= becomes the OS gate it really is (off a Mac build the iTunes-remote category never even exists), and the Mac 1.12 slice settles that its F13 IS print-screen while F14/F15 map to UNKNOWN on purpose and stay unbindable; and the last-enemy stamp gains the four conjuncts it shipped without — you resolve, are not dead, not a ghost, not mounted, and the new target is alive-or-feigning — so TargetLastEnemy can no longer land somewhere the real client's never would
- the quest windows wear the reference's names: ~40 of the quest window's globals were ours (the reference's name usually sitting in the comment beside them), its two six-item reward pools merge into the reference's single ten-item run with the update function ported line-for-line, the four panels and the quest log take REAL scroll frames (the thumb drags now), the gossip window gets its greeting panel, the two mail panes' inert painted-at-rest scrollbars become real ones, the Skills detail pane nests as the reference has it, and nine private constants take their real names; the one that broke a feature detect: our QuestLogCount — a name 1.12 doesn't publish — made pfUI conclude it was running on TBC and die in that branch
- an EditBox is born with its constructor's five regions (the text, three selection quads and the caret), so GetRegions answers the reference's own count and authored regions start at index 6 — and a bare <NormalTexture/> now builds a live blank texture, since the element is what creates the region, not its file=; OpacityFrame lands, its first customer having arrived; and the equip / auto-equip / use bind confirms complete the soulbind arc — three different predicates with no quality leg (borrowing the loot arm's would have silenced the confirm on every white bind-on-equip item), Accept re-issuing the original action with the reference's suppress flag because 1.12 ships no confirm packet, and the soulstone use going through the same gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:16:49 +07:00

1453 lines
52 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 ───────────────────────────────────────────────────────
#[test]
fn autofocus_does_not_focus_on_show_but_self_acquires_first_event() {
let mut s = script();
s.run(r#"E = CreateFrame("EditBox", "E"); E:SetAutoFocus(true)"#)
.unwrap();
// autoFocus does NOT focus on show — 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 ───────────────────────────────────────
#[test]
fn typing_builds_text_and_fires_ontextchanged_per_mutation() {
let mut s = script();
s.run(
r#"
changed = 0
E = CreateFrame("EditBox", "E")
E:SetScript("OnTextChanged", function() changed = changed + 1 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(), 3);
}
#[test]
fn set_text_short_circuits_on_same_value_else_fires_textset_then_textchanged() {
let 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 then changed
E:SetText("hi") -- unchanged → short-circuit, no events
E:SetText("bye") -- fires set then changed
"#,
)
.unwrap();
let log: Vec<String> = s.eval("return log").unwrap();
assert_eq!(log, vec!["set", "changed", "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");
// Each paste that changed the text fired OnTextChanged once.
assert_eq!(s.eval::<i64>("return changed").unwrap(), 2);
}
#[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"
);
}