- the world map's arrow shows on every zone sheet, and alt-clicking a town jumps there: the stock file creates the player arrow as a `Model` child of `WorldMapFrame` and lays a zone sheet's explored-area overlays as ARTWORK textures on `WorldMapDetailFrame` — one `(strata, level)` bucket, where the draw layer outranks the frame — and benilla drew a model's scene at the frame's own layer-0 slot, so every overlay painted over it (the continent sheet has no overlays, which is why it was the only sheet with an arrow); wow-re's carve: a layer batch carries three sub-arrays — quads, the string batch, a render-callback list — that `0x76fb00` drains in that order, and `0x76d160` registers a model's callback only for layer 2 (`0x76d17f cmp ebx,2`), so a model's scene is ARTWORK content, neither a separate pass nor a layer-0 slot; a `ModelPane` quad now keys at the ARTWORK content rank, which re-orders the pet page's happiness icon (a sibling's BACKGROUND quad) under the pane's scene — the reference's own order, harmless because the bake is transparent where the model is not; a model against a LATER-linked ARTWORK font string in the same bucket is still ordered by link stamp, named not built; and the alt-click jump, which required the topmost frame under the cursor to be `WorldMapButton` itself while the stock file parents every POI icon and blip to that button, now walks up from whatever was hit to the button and takes its rect — the map's own chrome still answers nothing (1992) - `UIParent` is the reference's own: `UIParent.xml` loads off the chain at its toc's slot, right after `WorldFrame.xml`, sourcing `UIParent.lua` and `LocaleProperties.lua` itself, and our `UIParent.xml` + `UiPanels.xml` — 3086 lines of transcribed `UIParent.lua` — are deleted; the one unbound verb the stock file ticks, `CheckReadyCheckTime`, was already 1989's; back from the reference: all sixty-six registered events and their arms (`PLAYER_CONTROL_LOST`/`_GAINED`, `RAID_ROSTER_UPDATE`, the trade-request pair, `PLAYER_SKINNED`, `GOSSIP_ENTER_CODE`), the ESC ladder as written — StaticPopup, the options window's Cancel, the game menu, `CloseMenus`, `SpellStopCasting`, `SpellStopTargeting`, `CloseAllWindows`, `ClearTarget`, else open — so ESC no longer clears a held cursor item or cancels the colour picker, two arms ours had grown that the reference does not have; the fade/flash/pulse kits walked from `UIParent`'s own OnUpdate in place of three 1×1 driver frames of ours, so a cinematic's `UIParent:Hide()` stops the walks as it does there; the managed bottom-stack positions without our `UIParent_RegisterManagedPositionListener` extension, which no shipped file registered any more; `LowHealthFrame`/`OutOfControlFrame`; and the `firstTimeLoaded` close on re-show; what stayed ours moved out — the window tab template and its OnUpdate settle (1004's async-measure adapter) and `BENILLA_FALLBACK_ICON` to `ScrollTemplates.xml`, 0561's bag verbs to `ContainerFrameAdapters`, the script log's slot row to its own file, `OptionsFrameCancel = OptionsFrameCloseButton` beside the two options aliases — and the Auction/Inspect/Macro/Talent panel rows, verbatim copies of the LoadOnDemand addons' own registrations, go; the reference also decides that the area spirit healer's dialog accepts on show with one CANCEL button, that losing control closes every window bar the reference's exceptions, and that the bottom-bar clearance reads the saved globals `SHOW_MULTI_ACTIONBAR_1`/`_2` rather than the bars' shown state; three integration binaries that kept private copies of the manifest loader use the shared one now (1988) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
778 lines
34 KiB
Rust
778 lines
34 KiB
Rust
//! The shipped `assets/ui/KeyBindingsPage.xml` + OptionsFrame.xml's Keybindings body — the
|
|
//! Options window's Keybindings category over the engine's binding table (decision 1008,
|
|
//! superseding 0997's standalone window; the provenance block in the XML).
|
|
//!
|
|
//! What these guard: the module + the options window load clean together; the page is an
|
|
//! ordinary category (body-swapped, Defaults live, the Unbind button only here); the section
|
|
//! tree is the honest tree's non-empty categories in 1.12 `Bindings.xml` order, COLLAPSED by
|
|
//! default (the era's own state) and toggling like the era's expandable sections; the capture
|
|
//! flow — select a capsule → the host arm arms → the canonical chord lands through
|
|
//! `KeyBindings_OnHostKey` — binds LIVE-COMMIT (every mutation queues the host persist,
|
|
//! 1008's law; closing the window keeps everything), steals with the red 1.12 message only
|
|
//! when the victim goes bare, takes the wheel onto ANY command (1295 — the engine refuses
|
|
//! nothing but a key string that is not a key, and that refusal restores the old key); the
|
|
//! character-specific checkbox runs 1.12's set model with the era's
|
|
//! confirm-on-uncheck-only; search surfaces binding matches as LIVE rows under the
|
|
//! Keybindings redirect head; and the action bar's abbreviation law (`GetBindingText`
|
|
//! transcribed in UIParent.xml) reads `s-2`, the ref's own Lua.
|
|
//!
|
|
//! Labels are read the way the page reads them — `KeyBindings_String(token, raw)`, the
|
|
//! GlobalStrings global with the raw token as fallback ([`label`]). The harness carries the
|
|
//! real 1.12 GlobalStrings.lua since the dialog engine became the reference's file (1960; the
|
|
//! stock StaticPopup.lua reads the strings at load), so a label here is the app's own
|
|
//! "Movement Keys" / "Jump", never the raw token the strings-less harness used to show.
|
|
|
|
use benilla_ui::script::keybind::{KeybindCommand, KeybindRequest};
|
|
use benilla_ui::script::{QuadContent, UiScript};
|
|
|
|
use crate::bindings::commands::SPECS;
|
|
|
|
/// The page's real neighbourhood, in the manifest's own order, with the registry seeded the
|
|
/// way the app seeds it (`crate::bindings::seed_bindings` — registration before any show).
|
|
pub(crate) fn harness() -> UiScript {
|
|
let mut s = UiScript::new().unwrap();
|
|
let cmds: Vec<KeybindCommand> = SPECS
|
|
.iter()
|
|
.map(|spec| KeybindCommand {
|
|
name: spec.name,
|
|
category: spec.category,
|
|
run_on_up: spec.run_on_up(),
|
|
default1: spec.d1,
|
|
default2: spec.d2,
|
|
})
|
|
.collect();
|
|
s.register_bindings(&cmds);
|
|
s.set_screen_size(1024.0, 768.0);
|
|
for file in [
|
|
"Interface\\FrameXML\\Fonts.xml",
|
|
r"Interface\FrameXML\MoneyFrame.lua",
|
|
r"Interface\FrameXML\MoneyFrame.xml",
|
|
r"Interface\FrameXML\UIParent.xml",
|
|
r"Interface\FrameXML\UIPanelTemplates.lua",
|
|
r"Interface\FrameXML\UIPanelTemplates.xml",
|
|
"Interface\\FrameXML\\BasicControls.xml",
|
|
"Interface\\FrameXML\\LocaleProperties.lua",
|
|
"Interface\\FrameXML\\GlobalStrings.lua",
|
|
"Interface\\FrameXML\\StaticPopup.xml",
|
|
"Interface\\FrameXML\\GameTooltip.xml",
|
|
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
|
"ScrollTemplates.xml",
|
|
"KeyBindingsPage.xml",
|
|
"OptionsFrame.xml",
|
|
"GameMenuFrame.xml",
|
|
] {
|
|
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
|
|
// REFERENCE's own file and must come off the player's chain, which
|
|
// `std::fs::read_to_string` under `assets/ui` cannot do — it goes looking for
|
|
// `assets/ui/Interface/FrameXML/...` and fails. The shared loader resolves both shapes, and
|
|
// its own doc already records this consolidation happening once before. Hand-rolling it
|
|
// here is what made this kit break the moment a file it loads migrated (1751).
|
|
// `load_ui_strict` for the files whose art actually matters here: an unknown template is
|
|
// a loader WARNING, not an error, so a silently skinless window would otherwise pass.
|
|
if file == "KeyBindingsPage.xml" || file == "OptionsFrame.xml" {
|
|
super::test_ui::load_ui_strict(&s, file);
|
|
} else {
|
|
super::test_ui::load_ui(&s, file);
|
|
}
|
|
}
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
s
|
|
}
|
|
|
|
/// A label as the page resolves it: the GlobalStrings global named `token`, else `raw`.
|
|
pub(crate) fn label(s: &UiScript, token: &str, raw: &str) -> String {
|
|
s.eval::<String>(&format!(r#"return KeyBindings_String("{token}", "{raw}")"#))
|
|
.unwrap()
|
|
}
|
|
|
|
/// Open the options window on the Keybindings page.
|
|
pub(crate) fn on_page(s: &mut UiScript) {
|
|
s.run(r#"ShowUIPanel(OptionsFrame); OptionsFrame_SelectCategory("Keybindings")"#)
|
|
.unwrap();
|
|
assert!(s.errors().is_empty(), "on page: {:?}", s.errors());
|
|
}
|
|
|
|
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
|
|
|
|
#[test]
|
|
fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
|
|
let mut s = harness();
|
|
on_page(&mut s);
|
|
assert!(s
|
|
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
|
|
.unwrap());
|
|
assert!(
|
|
s.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
|
|
.unwrap(),
|
|
"Unbind Key exists on this page"
|
|
);
|
|
assert!(
|
|
!s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
|
|
.unwrap(),
|
|
"…disabled until a capsule is selected"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
|
.unwrap());
|
|
// The section tree: the registry's category tokens, first-appearance order — exactly
|
|
// 1.12's file order — every section a COLLAPSED header row (the era default).
|
|
let mut expected: Vec<&str> = Vec::new();
|
|
for spec in SPECS {
|
|
if !expected.contains(&spec.category) {
|
|
expected.push(spec.category);
|
|
}
|
|
}
|
|
for (i, token) in expected.iter().enumerate() {
|
|
let text = s
|
|
.eval::<String>(&format!("return {ROW}{}HeaderText:GetText()", i + 1))
|
|
.unwrap();
|
|
assert_eq!(text, label(&s, token, token), "section {}", i + 1);
|
|
}
|
|
assert!(
|
|
!s.eval::<bool>(&format!("return {ROW}{}:IsVisible()", expected.len() + 1))
|
|
.unwrap(),
|
|
"all sections collapsed: nothing past the headers"
|
|
);
|
|
// The multibar section is real (1008): its header token sits in the tree.
|
|
assert!(expected.contains(&"BINDING_HEADER_MULTIACTIONBAR"));
|
|
// Expanding Movement puts its rows under the header, byte-real defaults on the capsules.
|
|
s.run(&format!("{ROW}1Header:Click()")).unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {ROW}2Description:GetText()"))
|
|
.unwrap(),
|
|
label(&s, "BINDING_NAME_MOVEANDSTEER", "MOVEANDSTEER")
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {ROW}2Key1ButtonText:GetText()"))
|
|
.unwrap(),
|
|
label(&s, "KEY_BUTTON3", "BUTTON3")
|
|
);
|
|
// The same rows with the strings pinned by hand, so the expectation is literal 1.12 text.
|
|
s.run(
|
|
r#"BINDING_HEADER_MOVEMENT = "Movement Keys"
|
|
BINDING_NAME_MOVEANDSTEER = "Move and Steer"
|
|
KEY_BUTTON3 = "Middle Mouse"
|
|
KeyBindingsPage_Update()"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {ROW}1HeaderText:GetText()"))
|
|
.unwrap(),
|
|
"Movement Keys"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {ROW}2Description:GetText()"))
|
|
.unwrap(),
|
|
"Move and Steer"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {ROW}2Key1ButtonText:GetText()"))
|
|
.unwrap(),
|
|
"Middle Mouse"
|
|
);
|
|
// Collapse again: the next header slides back up under the first.
|
|
s.run(&format!("{ROW}1Header:Click()")).unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {ROW}2HeaderText:GetText()"))
|
|
.unwrap(),
|
|
label(&s, "BINDING_HEADER_CHAT", "BINDING_HEADER_CHAT")
|
|
);
|
|
// Leaving the page hides its body and the Unbind button.
|
|
s.run(r#"OptionsFrame_SelectCategory("Controls")"#).unwrap();
|
|
assert!(!s
|
|
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
|
|
.unwrap());
|
|
assert!(!s
|
|
.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
|
|
.unwrap());
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn the_capture_flow_binds_steals_and_refuses_like_112() {
|
|
let mut s = harness();
|
|
on_page(&mut s);
|
|
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
|
|
s.take_keybind_requests(); // drop any noise before the flow under test
|
|
// Row 3 is MOVEFORWARD (W, UP). Selecting its Key 1 capsule arms the host seam.
|
|
assert!(!s.bind_capture_armed());
|
|
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
|
|
assert!(
|
|
s.bind_capture_armed(),
|
|
"a selected capsule arms the capture"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
|
|
.unwrap(),
|
|
"Unbind arms with the selection"
|
|
);
|
|
// The host hands back a canonical chord: J binds into slot 1, W's old seat; UP survives
|
|
// in slot 2; the capture disarms; the table is LIVE and the bind COMMITTED (Save queued —
|
|
// 1008's live-commit law, where 0997 waited for Okay).
|
|
//
|
|
// **`J`, not `F`** — this leg is about the plain bind, so its key has to be one nothing else
|
|
// holds, and `F` stopped being one when 1745 registered `ASSISTTARGET` on its byte-real
|
|
// default. The steal path has its own leg below (`T`), which is where a taken key belongs.
|
|
s.run(r#"KeyBindings_OnHostKey("J")"#).unwrap();
|
|
assert!(!s.bind_capture_armed(), "a completed bind disarms");
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "J" and k2 == "UP""#
|
|
)
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("W")"#).unwrap(),
|
|
"",
|
|
"the old key is free"
|
|
);
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
|
|
.unwrap(),
|
|
"Key Bound Successfully"
|
|
);
|
|
// Stealing the LAST key of another command names the victim in red (1.12's
|
|
// KEY_UNBOUND_ERROR). T is ATTACKTARGET's only key.
|
|
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
|
|
s.run(r#"KeyBindings_OnHostKey("T")"#).unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("T")"#).unwrap(),
|
|
"MOVEFORWARD"
|
|
);
|
|
let victim = label(&s, "BINDING_NAME_ATTACKTARGET", "ATTACKTARGET");
|
|
assert!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
|
|
.unwrap()
|
|
.contains(&victim),
|
|
"the newly-bare victim is named"
|
|
);
|
|
// **The wheel binds like any other key** (B265, decision 1295) — including onto MOVEFORWARD,
|
|
// which has press+release state. This block used to assert the opposite; `0x4b7490` never
|
|
// reads a command node, so the refusal it asserted is not in the client.
|
|
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
|
|
s.run(r#"KeyBindings_OnHostKey("MOUSEWHEELUP")"#).unwrap();
|
|
assert!(
|
|
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
|
|
.unwrap(),
|
|
"the notch takes the slot"
|
|
);
|
|
// …and the notch STEALS the wheel from the camera, whose last key it was — so the page says
|
|
// so in red, exactly as it would for any other stolen key. That the wheel's own default
|
|
// victim is a real command is half of why the old refusal read as plausible.
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
|
|
.unwrap(),
|
|
format!(
|
|
"|cffff0000{} Function is Now Unbound!|r",
|
|
label(&s, "BINDING_NAME_CAMERAZOOMIN", "CAMERAZOOMIN")
|
|
)
|
|
);
|
|
// The refusal that IS there is the key-string validator, and this is the page arm that
|
|
// reports it: a chord the engine will not take restores the slot's old key and says why
|
|
// (1.12's KeyBindingFrame_SetBinding, its wheel-shaped message and all).
|
|
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
|
|
s.run(r#"KeyBindings_OnHostKey("SCROLLLOCK")"#).unwrap();
|
|
assert!(
|
|
s.eval::<bool>(r#"local k1 = GetBindingKey("MOVEFORWARD"); return k1 == "MOUSEWHEELUP""#)
|
|
.unwrap(),
|
|
"the refused slot restored its key"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
|
|
.unwrap(),
|
|
"Can't bind mousewheel to actions with up and down states"
|
|
);
|
|
// A right-click on the armed capsule deselects without binding.
|
|
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
|
|
assert!(s.bind_capture_armed());
|
|
s.run(&format!(r#"{ROW}3Key1Button:Click("RightButton")"#))
|
|
.unwrap();
|
|
assert!(!s.bind_capture_armed(), "right-click deselects");
|
|
// Hiding the window disarms a straggling capture (the OnHide hook — a locked-out client
|
|
// otherwise: the armed seam swallows all input with no window on screen).
|
|
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
|
|
assert!(s.bind_capture_armed());
|
|
s.run("HideUIPanel(OptionsFrame)").unwrap();
|
|
assert!(!s.bind_capture_armed(), "OnHide disarms");
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
|
|
let mut s = harness();
|
|
on_page(&mut s);
|
|
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
|
|
s.take_keybind_requests();
|
|
// JUMP is Movement's 8th command → row 9 under the header. Unbind its Key 1: SPACE goes,
|
|
// NUMPAD0 slides into slot 1 (the 1.12 slot dance), and the change commits at once.
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {ROW}9Description:GetText()"))
|
|
.unwrap(),
|
|
label(&s, "BINDING_NAME_JUMP", "JUMP")
|
|
);
|
|
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
|
|
s.run("OptionsFrameContainerUnbind:Click()").unwrap();
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "NUMPAD0" and k2 == nil"#
|
|
)
|
|
.unwrap());
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
|
|
// LIVE-COMMIT is the law now (1008): bind G, close the window — the bind KEEPS (0997's
|
|
// Cancel/ESC revert died with the standalone window).
|
|
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
|
|
s.run(r#"KeyBindings_OnHostKey("G")"#).unwrap();
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
|
|
s.run("OptionsFrameCloseButton:Click()").unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
|
|
"JUMP",
|
|
"closing keeps the live-committed bind"
|
|
);
|
|
// Reset To Default: the page's Defaults button behind the era confirm — JUMP's real
|
|
// defaults return and the reset itself commits.
|
|
on_page(&mut s);
|
|
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
|
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
|
s.run("StaticPopup1Button1:Click()").unwrap();
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "SPACE" and k2 == "NUMPAD0""#
|
|
)
|
|
.unwrap());
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
|
|
let mut s = harness();
|
|
on_page(&mut s);
|
|
// The ladder (the ESC binding's own body): the options rung hides the window — since 1008
|
|
// that IS the whole gesture for keybinds too (live-commit; nothing to revert).
|
|
s.run("ToggleGameMenu()").unwrap();
|
|
assert!(!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
|
|
|
|
// The character-specific checkbox (1.12's set model, era confirm-on-uncheck-only law):
|
|
// CHECK switches to the character set and saves it into existence at once.
|
|
on_page(&mut s);
|
|
s.take_keybind_requests();
|
|
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
|
|
.unwrap();
|
|
assert_eq!(s.current_binding_set(), 2);
|
|
assert!(s.character_bindings_exist());
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
|
|
// UNCHECK is destructive: the box springs back and the 1.12 confirm decides. Cancel
|
|
// first — still on the character set.
|
|
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
|
|
.unwrap();
|
|
assert!(
|
|
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
|
"leaving the character set confirms the permanent delete"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>(
|
|
"return OptionsFrameContainerBodyKeybindingsCharacterRowCheck:GetChecked() ~= nil"
|
|
)
|
|
.unwrap(),
|
|
"the box springs back until the popup decides"
|
|
);
|
|
s.run("StaticPopup1Button2:Click()").unwrap();
|
|
assert_eq!(s.current_binding_set(), 2);
|
|
assert!(s.character_bindings_exist());
|
|
// Accept: back to the account set, the character set dropped (load-then-save order — the
|
|
// account file must not inherit the character binds).
|
|
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
|
|
.unwrap();
|
|
s.run("StaticPopup1Button1:Click()").unwrap();
|
|
assert_eq!(s.current_binding_set(), 1);
|
|
assert!(
|
|
!s.character_bindings_exist(),
|
|
"the confirmed delete dropped set 2"
|
|
);
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
|
|
let mut s = harness();
|
|
s.run(r#"BINDING_NAME_JUMP = "Jump""#).unwrap();
|
|
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
|
s.take_keybind_requests();
|
|
// A query that matches one binding and no CVar row: the Keybindings group head shows,
|
|
// with the match painted LIVE on the search pool (the era reflows its real rows the
|
|
// same way).
|
|
s.run(r#"OptionsFrameSearchBox:SetText("jump")"#).unwrap();
|
|
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
|
|
assert!(s
|
|
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
|
|
.unwrap(),
|
|
"Jump"
|
|
);
|
|
assert!(!s
|
|
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch2:IsVisible()")
|
|
.unwrap());
|
|
// The result row is LIVE: its capsule arms, the bind lands and commits, the row relabels.
|
|
s.run("OptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
|
|
.unwrap();
|
|
assert!(s.bind_capture_armed());
|
|
s.run(r#"KeyBindings_OnHostKey("H")"#).unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingAction("H")"#).unwrap(),
|
|
"JUMP"
|
|
);
|
|
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
|
|
.unwrap(),
|
|
"H"
|
|
);
|
|
// The head is the era redirect: clicking it ends the search on the Keybindings page.
|
|
s.run("OptionsFrameContainerBodySearchHeadKeybindings:Click()")
|
|
.unwrap();
|
|
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrame.selectedCategory")
|
|
.unwrap(),
|
|
"Keybindings"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
|
|
.unwrap());
|
|
assert!(!s
|
|
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
|
|
.unwrap());
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
}
|
|
|
|
#[test]
|
|
fn the_action_bar_abbreviation_is_the_refs_own_getbindingtext() {
|
|
let s = harness();
|
|
// ref UIParent.lua:1819 transcribed (UIParent.xml): one modifier abbreviates…
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingText("SHIFT-2", "KEY_", 1)"#)
|
|
.unwrap(),
|
|
"s-2",
|
|
"the director's SHIF… truncation reads s-2 now"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingText("ALT-Z", "KEY_", 1)"#)
|
|
.unwrap(),
|
|
"a-Z"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingText("W", "KEY_", 1)"#)
|
|
.unwrap(),
|
|
"W"
|
|
);
|
|
// …two or more collapse to the ref's dot — including the CTRL-- oddity, whose second
|
|
// dash the ref's dash-counting loop counts as a second modifier (their quirk, pinned).
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingText("CTRL-SHIFT-2", "KEY_", 1)"#)
|
|
.unwrap(),
|
|
"·"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingText("CTRL--", "KEY_", 1)"#)
|
|
.unwrap(),
|
|
"·"
|
|
);
|
|
// The full form keeps the prefixes and reads the KEY_* global when it exists.
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingText("SHIFT-2", "KEY_")"#)
|
|
.unwrap(),
|
|
"SHIFT-2"
|
|
);
|
|
s.run(r#"KEY_SPACE = "Spacebar""#).unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetBindingText("SPACE", "KEY_")"#)
|
|
.unwrap(),
|
|
"Spacebar"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>(r#"return GetBindingText(nil) == """#)
|
|
.unwrap());
|
|
}
|
|
|
|
/// The wheel and the bar's seat (the director's scuff report, same day 1008 landed). The wheel
|
|
/// is the QuestLog/Trainer lesson relearned: a spin bubbles up the PARENT chain (pointer.rs),
|
|
/// so a handler on the sibling faux frame never sees it — it lives on the page body, which is
|
|
/// mouse-enabled so a spin over a row's NAME (a plain-Frame area no child claims) is caught
|
|
/// too, not just the bubbles from capsule/header Buttons. The bar rides the gutter INSIDE the
|
|
/// body: the kit hangs it 6px right of the faux frame's edge, so the frame ends at the rows'
|
|
/// own -32 — anchoring it to body-right hung the bar on the window border.
|
|
#[test]
|
|
fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
|
|
let mut s = harness();
|
|
on_page(&mut s);
|
|
// Every section open: 100+ flat rows — the list overflows its 19 slots and the bar shows.
|
|
s.run(
|
|
r#"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, true) end
|
|
KeyBindingsPage_Update()"#,
|
|
)
|
|
.unwrap();
|
|
const SF: &str = "OptionsFrameContainerBodyKeybindingsScrollFrame";
|
|
assert!(s
|
|
.eval::<bool>(&format!("return {SF}ScrollBar:IsVisible()"))
|
|
.unwrap());
|
|
s.resolve();
|
|
// The page area's OWN scroll (B217) stays out of it: this page fills the body exactly, so the
|
|
// outer range is 0 and its bar never joins this one in the gutter the two share.
|
|
for _ in 0..4 {
|
|
s.tick(0.016);
|
|
s.resolve();
|
|
}
|
|
// **B217's property is the BAR, not the range, and since 1860 those differ.** The page scroll
|
|
// now reports a nonzero range here, and that is not a defect in either half: the chain's
|
|
// `FauxScrollFrameTemplate` carries a real `<ScrollChild>` which `FauxScrollFrame_Update` sizes
|
|
// to `numItems * valueStep` (~5275px with every section open), our old template had none, and
|
|
// the range is measured by unioning the scroll child's whole SUBTREE — the reference's own
|
|
// `0x786f80` recursion, which "re-enters itself for each shown child frame" with no clip or
|
|
// ScrollFrame exception (wow-re `system/ui/ui.md`, decision 1338). The reference would measure
|
|
// the same; it simply never nests a faux list inside a real-scroll page, which our options
|
|
// window is alone in doing.
|
|
//
|
|
// What B217 actually cleared — a second bar in the shared gutter — still holds, and that is
|
|
// what is asserted.
|
|
assert!(
|
|
!s.eval::<bool>("return OptionsFrameContainerScrollBar:IsVisible()")
|
|
.unwrap(),
|
|
"only one bar is on screen: the list's own owns the gutter"
|
|
);
|
|
let body_right = s
|
|
.eval::<f64>("return OptionsFrameContainerBodyKeybindings:GetRight()")
|
|
.unwrap();
|
|
let rows_right = s.eval::<f64>(&format!("return {ROW}1:GetRight()")).unwrap();
|
|
let bar_left = s
|
|
.eval::<f64>(&format!("return {SF}ScrollBar:GetLeft()"))
|
|
.unwrap();
|
|
let bar_right = s
|
|
.eval::<f64>(&format!("return {SF}ScrollBar:GetRight()"))
|
|
.unwrap();
|
|
assert!(
|
|
bar_left >= rows_right,
|
|
"bar starts right of the rows ({bar_left} vs {rows_right})"
|
|
);
|
|
assert!(
|
|
bar_right <= body_right - 8.0,
|
|
"bar inset from the body edge ({bar_right} vs body {body_right})"
|
|
);
|
|
// …wearing its TROUGH: the recessed channel (the shared kit's, shown and hidden with the bar),
|
|
// which seats ITSELF on the bar — 31 wide against the bar's 16, hung 8 units left, and
|
|
// overhanging it 21/20 so each arrow drops into the 16-tall socket the art carries (B224; the
|
|
// law and its ref citation are in BenillaScrollTrough_Seat). Asserted off the ARROWS here,
|
|
// which is the half the eye judges: 5 units of cap above the up arrow, 4 below the down arrow.
|
|
const TROUGH: &str = "OptionsFrameContainerBodyKeybindingsScrollFrameScrollBarTrough";
|
|
let bar = format!("{SF}ScrollBar");
|
|
let g = |f: &str, m: &str| s.eval::<f64>(&format!("return {f}:{m}()")).unwrap();
|
|
assert!(
|
|
s.eval::<bool>(&format!("return {TROUGH}:IsVisible()"))
|
|
.unwrap(),
|
|
"the trough shows with the bar"
|
|
);
|
|
assert!((g(TROUGH, "GetWidth") - 31.0).abs() < 0.01);
|
|
assert!((bar_left - g(TROUGH, "GetLeft") - 8.0).abs() < 0.01);
|
|
assert!((g(TROUGH, "GetTop") - g(&bar, "GetTop") - 21.0).abs() < 0.01);
|
|
assert!((g(&bar, "GetBottom") - g(TROUGH, "GetBottom") - 20.0).abs() < 0.01);
|
|
assert!(
|
|
(g(TROUGH, "GetTop") - g(&format!("{bar}ScrollUpButton"), "GetTop") - 5.0).abs() < 0.01
|
|
);
|
|
assert!(
|
|
(g(&format!("{bar}ScrollDownButton"), "GetBottom") - g(TROUGH, "GetBottom") - 4.0).abs()
|
|
< 0.01
|
|
);
|
|
// A list that FITS shows neither.
|
|
let set_all = |s: &UiScript, open: bool| {
|
|
s.run(&format!(
|
|
"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, {}) end
|
|
KeyBindingsPage_Update()",
|
|
if open { "true" } else { "nil" }
|
|
))
|
|
.unwrap();
|
|
};
|
|
set_all(&s, false);
|
|
for f in [format!("{SF}ScrollBar"), TROUGH.to_string()] {
|
|
assert!(
|
|
!s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
|
|
"{f} goes away when the list fits"
|
|
);
|
|
}
|
|
set_all(&s, true);
|
|
s.resolve();
|
|
|
|
// A spin over a row NAME — the dead zone when the handler lived on the sibling.
|
|
let steer = label(&s, "BINDING_NAME_MOVEANDSTEER", "MOVEANDSTEER");
|
|
let quads = s.extract();
|
|
let (wx, wy) = quads
|
|
.iter()
|
|
.find_map(|q| match &q.content {
|
|
QuadContent::Text { text: Some(t), .. } if *t == steer => q
|
|
.rect
|
|
.map(|r| ((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)),
|
|
_ => None,
|
|
})
|
|
.expect("a MOVEANDSTEER description quad");
|
|
s.mouse_wheel(wx, wy, -1.0);
|
|
assert!(s.errors().is_empty(), "wheel over a name: {:?}", s.errors());
|
|
// The spin REACHED the list, which is what this dead zone was about. The distance is the
|
|
// reference's half-bar page (`ScrollFrameTemplate_OnMouseWheel` moves `GetHeight()/2` pixels),
|
|
// not the single row our deleted kit stepped — 1860 re-pointed that everywhere it is asserted.
|
|
let after_name = s
|
|
.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
|
|
.unwrap();
|
|
assert!(
|
|
after_name > 1.0,
|
|
"a spin over a row NAME scrolled the list by a page, got {after_name}"
|
|
);
|
|
// …and over a CAPSULE (a Button: the bubble path, capsule → row → body). GetLeft-family
|
|
// reads are scale-LOCAL (the 1.12 contract; this window wears ERA_WINDOW_SCALE) — the
|
|
// pointer lives in screen px, so the aim converts through GetEffectiveScale.
|
|
let (cx, cy) = {
|
|
let k = s
|
|
.eval::<f64>(&format!("return {ROW}2Key1Button:GetEffectiveScale()"))
|
|
.unwrap();
|
|
let l = s
|
|
.eval::<f64>(&format!("return {ROW}2Key1Button:GetLeft()"))
|
|
.unwrap();
|
|
let r = s
|
|
.eval::<f64>(&format!("return {ROW}2Key1Button:GetRight()"))
|
|
.unwrap();
|
|
let t = s
|
|
.eval::<f64>(&format!("return {ROW}2Key1Button:GetTop()"))
|
|
.unwrap();
|
|
let b = s
|
|
.eval::<f64>(&format!("return {ROW}2Key1Button:GetBottom()"))
|
|
.unwrap();
|
|
((((l + r) * 0.5 * k) as f32), (((t + b) * 0.5 * k) as f32))
|
|
};
|
|
s.mouse_wheel(cx, cy, -1.0);
|
|
let after_capsule = s
|
|
.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
|
|
.unwrap();
|
|
assert!(
|
|
after_capsule > after_name,
|
|
"a spin over a CAPSULE bubbles to the page too: {after_name} -> {after_capsule}"
|
|
);
|
|
// While a capsule is armed the wheel is a BIND, never a scroll (1.12's law) — in the app
|
|
// the host seam swallows the spin before the UI sees one; the Lua guard is the belt for
|
|
// a spin that reaches the page anyway.
|
|
s.run(&format!("{ROW}2Key1Button:Click()")).unwrap();
|
|
s.mouse_wheel(wx, wy, -1.0);
|
|
assert_eq!(
|
|
s.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
|
|
.unwrap(),
|
|
after_capsule,
|
|
"armed: the wheel must not scroll"
|
|
);
|
|
s.run("KeyBindings_SetSelected(nil)").unwrap();
|
|
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
|
}
|
|
|
|
/// **The pet lane reaches the page** (B218 / decision 1052). 1008 registered the two multibars
|
|
/// and stopped; 1.12's `BONUSACTIONBUTTON1-10` — "Secondary Action Button" in its own window —
|
|
/// never joined, so the pet bar was the one visible bar with no way to bind it.
|
|
///
|
|
/// Pinned on both sides of the seam, because the report is about the *page*: the registry carries
|
|
/// the ten rows under the ACTION BAR header with their byte-real `CTRL-1..CTRL-0` defaults (1.12
|
|
/// files them under l.121's header, having none of their own), and the page's own search — the
|
|
/// path a player actually finds a binding by — paints the row live with that key on its capsule.
|
|
#[test]
|
|
fn the_pet_lane_is_registered_under_the_action_bar_header() {
|
|
let mut s = harness();
|
|
on_page(&mut s);
|
|
|
|
// Registry side: the whole lane, in Bindings.xml order right after the shapeshift block,
|
|
// sharing the action bar's category token.
|
|
let category = |s: &mut UiScript, name: &str| {
|
|
s.eval::<String>(&format!(
|
|
r#"for i = 1, GetNumBindings() do
|
|
local n, c = GetBinding(i)
|
|
if n == "{name}" then return c end
|
|
end"#
|
|
))
|
|
.unwrap()
|
|
};
|
|
for i in 1..=10 {
|
|
let name = format!("BONUSACTIONBUTTON{i}");
|
|
assert_eq!(
|
|
category(&mut s, &name),
|
|
"BINDING_HEADER_ACTIONBAR",
|
|
"{name} files under the action bar, as 1.12 does"
|
|
);
|
|
let key = s
|
|
.eval::<String>(&format!(r#"return GetBindingKey("{name}")"#))
|
|
.unwrap();
|
|
assert_eq!(key, format!("CTRL-{}", i % 10), "{name}'s 1.12 default");
|
|
}
|
|
// …and it is a runOnUp pair like the action bar's own rows — which decides that a press
|
|
// delivers a release half, and NOT whether the row may wear a wheel chord (1295). It takes
|
|
// the wheel like any other command; the notch then runs both halves of the pair.
|
|
assert_eq!(
|
|
s.eval::<Option<u32>>(r#"return SetBinding("MOUSEWHEELUP", "BONUSACTIONBUTTON1")"#)
|
|
.unwrap(),
|
|
Some(1),
|
|
"a press+release command takes the wheel — the refusal was ours, B265"
|
|
);
|
|
|
|
// Page side: search finds it and paints a live row with the capsule filled.
|
|
// The search tags are the DISPLAY names uppercased (era AddSearchTags), so the query is the
|
|
// label the app's strings give the row, not its token.
|
|
let query = label(&s, "BINDING_NAME_BONUSACTIONBUTTON1", "BONUSACTIONBUTTON1").to_lowercase();
|
|
s.run(&format!(r#"OptionsFrameSearchBox:SetText("{query}")"#))
|
|
.unwrap();
|
|
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
|
|
assert!(s
|
|
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
|
|
.unwrap(),
|
|
label(&s, "BINDING_NAME_BONUSACTIONBUTTON1", "BONUSACTIONBUTTON1"),
|
|
"the row wears the app's own string"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
|
|
.unwrap(),
|
|
"CTRL-1"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// **Every registry body is real Lua.** 167 rows carry a Lua chunk as a Rust string literal, and
|
|
/// nothing compiled them: a typo in one shipped as a command that errors on its first press, in a
|
|
/// file whose tests all live one layer up at the page. `loadstring` is the whole check — running
|
|
/// them needs the FrameXML the harness deliberately does not load — and it is exactly the check
|
|
/// that a hand-typed string wants.
|
|
#[test]
|
|
fn every_command_body_compiles() {
|
|
use crate::bindings::commands::Kind;
|
|
|
|
let s = UiScript::new().unwrap();
|
|
for spec in SPECS {
|
|
let bodies: Vec<&str> = match &spec.kind {
|
|
Kind::Held | Kind::Host => Vec::new(),
|
|
Kind::Edge(body) => vec![*body],
|
|
Kind::EdgeUpDown(down, up) => vec![*down, *up],
|
|
};
|
|
for body in bodies {
|
|
s.run(&format!(
|
|
"local f, err = loadstring({body:?}); if not f then BenillaBodyError = {name:?} .. \": \" .. err end",
|
|
body = body,
|
|
name = spec.name
|
|
))
|
|
.unwrap_or_else(|e| panic!("{}: {e}", spec.name));
|
|
}
|
|
}
|
|
let bad: Vec<String> = s.errors();
|
|
assert!(bad.is_empty(), "script errors: {bad:?}");
|
|
s.run("if BenillaBodyError then error(BenillaBodyError) end")
|
|
.unwrap_or_else(|e| panic!("a command body is not valid Lua: {e}"));
|
|
}
|