Files
benilla-octo/crates/benilla-app/src/ui_script/macro_tests.rs
T
SamandClaude Fable 5.1 edd6628329 the tooltip and five windows go stock, and the battleground keeps score
- the tooltip is the reference's own file: `GameTooltip.xml` loads off the chain, ours is deleted, and every verb it calls was already bound, so this is a plain swap; retired with it: the `$parentThicken` plate fill that thickened the plate from the art's ~73% to ~84% opacity — the reference's plate is the raw `UI-Tooltip-Background` tint, and that is what ships; the one engine fact the transcription had been hiding: the reference's instance declares no size, and a frame with one anchor and no size resolves to NO rect under the reference's own layout law — our declared size had been a harness crutch (1968); and the dressing room goes stock with `<DressUpModel>` as a widget kind (factory `0x495c00`): a `PlayerModel` plus its own `Undress`/`Dress`/`TryOn`, where `Undress` strips the body and not the hands, and Close is read off the window since the stock OnHide queues nothing (1969)
- the mail window and the auction house are the reference's own: `MailFrame.xml` loads off the chain and brings the stationery picker, invoice pane and autocomplete ours had left out; the stationery family is built off wow-re's carve over a list computed off `Stationery.dbc` rows × the player's bags (`0x622270`, not the bank); the selection rides `CMSG_SEND_MAIL` field 6 and a send with none silently aborts; its two events fire where the client fires them (1970); `Blizzard_AuctionUI` loads on demand from the `AUCTION_HOUSE_SHOW` arm, and three root fixes its Lua surfaced: `GetAuctionItemClasses()` is static — read at the addon's load, before any session, so the class tree is a login-scoped push; `SetAuctionCompareItem` (`0x535d70`) on the vendor compare's one body; `NEW_AUCTION_UPDATE` resets the create form; its dressing-room pane shares the app's one room (1971)
- the battleground keeps score: `WorldStateFrame.xml` loads off the chain and ours is deleted; the stock file is three things where ours was one: the readout, the capture bars, and the battleground scoreboard this client never had, built off wow-re's round — `MSG_PVP_LOG_DATA` both ways (throttled to 5000 ms client-side, `0x4aa170`) and `CMSG_LEAVE_BATTLEFIELD`; eleven score verbs over a board the app pushes, where the filter is a SORT, not a compaction — team first, killing blows descending, deaths ascending, honor descending, name — so the filtered count shrinks while `GetBattlefieldScore` still reaches every wire row; `HIDE_OUTDOOR_WORLD_STATE` joins the options uvar block, a capture point is a bar, and the scoreboard and battlefield-minimap toggles become bound specs at the install's own `SHIFT-SPACE`/`SHIFT-M` (1972)
- the trade skill and craft windows go stock as `Blizzard_TradeSkillUI`/`Blizzard_CraftUI` on demand, from the reference's own UIParent arms; our two transcriptions are deleted; the four item-link verbs are built off wow-re's carve on the client's link builder (`0x52adb0`) — zero values on any miss, `GetTradeSkillReagentItemLink` raising Blizzard's own typo'd Usage, kept, and `GetCraftItemLink` the `|Henchant:` link in fixed white; `SetTextColor`'s channels coerce nil to 0 on FontString, Font and Button where ours had raised (1973); and the Inspect, Talent, Macro and Raid addons load on demand — `benilla.toc` lists no `Interface\AddOns` row, and below level 10 the talent addon is never even loaded; the gates walk every reached addon and found `Blizzard_GMSurveyUI`'s four engine verbs nothing answers, reachable only through `GMSURVEY_DISPLAY`, which nothing fires (1889; 1967)

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

716 lines
31 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.
//! The shipped `assets/ui/MacroFrame.xml` — the macro editor + its name/icon popup (decision
//! 0983), driven against the engine's own macro table.
//!
//! What these guard, end to end through the real file: the window loads clean in its real
//! neighbourhood; NEW → pick an icon → OKAY actually creates a macro on the right tab; typing in
//! the body and closing the window commits it (the `MacroFrame_SaveMacro` line that makes the
//! whole editor work); the two tabs address the two index ranges; DELETE removes and re-selects;
//! and a macro button is a drag source that loads the cursor with the macro payload.
//!
//! The **saving** half is deliberately exercised through the window rather than the bindings: the
//! bindings' own round trip is `benilla_ui::script::macros`' unit tests, and what can only break
//! here is the wiring — a tab that forgets to save, an OKAY that creates on the wrong tab.
use benilla_ui::script::{CursorPayload, UiScript};
/// The window's real neighbourhood, in the manifest's own order.
/// Click the first macro in the list.
///
/// The reference selects **nothing** when the window opens: `MacroFrame_OnShow` calls only
/// `MacroFrame_Update`, and that function merely *highlights* whichever macro is already selected —
/// it never assigns `MacroFrame.selectedMacro`. Our retired file added a benilla-only
/// `BenillaMacroFrame_EnsureSelection` that picked the first one, and every test here leaned on it
/// without saying so (decision 1848). A player clicks; so do these.
fn select_first(s: &UiScript) {
s.run("MacroButton1:Click()").unwrap();
}
/// [`harness`] with a chosen player name.
///
/// The character tab's label is built in its own `OnLoad` — `format(CHARACTER_SPECIFIC_MACROS,
/// UnitName("player"))` — so the name has to be in place BEFORE the load, not after it. That is
/// also why the name is how a test chooses the label's width (decision 1848).
fn harness_named(player: &str) -> UiScript {
harness_with(player)
}
fn harness() -> UiScript {
harness_with("Probefour")
}
fn harness_with(player: &str) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The global strings the file reads by name (the app runs the real `GlobalStrings.lua`; here
// only the handful this window formats need to exist).
s.run(
r#"
CREATE_MACROS = "Create Macros"
GENERAL_MACROS = "General Macros"
CHARACTER_SPECIFIC_MACROS = "%s Specific Macros"
ENTER_MACRO_LABEL = "Enter Macro Commands:"
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"
MACRO_POPUP_CHOOSE_ICON = "Choose an Icon:"
CHANGE_MACRO_NAME_ICON = "Change Name/Icon"
DELETE = "Delete"
NEW = "New"
EXIT = "Exit"
CANCEL = "Cancel"
OKAY = "Okay"
MACROS = "Macros"
-- The tooltip plate colours the body's backdrop reads (the app gets these from
-- UIParent.lua's own globals; the window only needs them to exist).
TOOLTIP_DEFAULT_COLOR = { r = 1.0, g = 1.0, b = 1.0 }
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
"#,
)
.unwrap();
// **A synchronous measurer, because the app has one.** `PanelTemplates_TabResize` sizes a tab
// from its label's laid-out width at OnLoad, and `measured_wh` answers that inline only when a
// host font engine is installed — which `ui_script::extract` does (`AtlasMeasurer`). Without
// one the measure is pending, the width reads 0, and the stock file never re-runs TabResize
// because the reference's own measure is synchronous. This harness used to model the async
// round trip and lean on our retired file's OnUpdate re-check (decision 1848).
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
// `CHARACTER_SPECIFIC_MACROS` is a `%s` template the character tab formats
// `UnitName("player")` into, in its OnLoad — so the player exists before the load, not after.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some(player.into()),
level: 60,
..Default::default()
}),
);
// Through the shared loader, because the window is off the chain now and a private disk-only
// reader cannot name it (decision 1848).
for file in [
r"Interface\FrameXML\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// The chain's `PanelTemplates_SelectTab` reaches for `GameTooltip` unguarded.
"Interface\\FrameXML\\GameTooltip.xml",
"UIParent.xml", // `ShowMacroFrame` lives here now
// **ScrollTemplates BEFORE UIPanelTemplates, the manifest's own order.** Ours still
// carries dead `FauxScrollFrame_*` copies the chain overrides by loading after (1846's
// step 3, deliberately not done); the other way round OUR copies win — the silent
// drift that record names.
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// `ClassTrainerListScrollFrameTemplate` — the icon chooser's scroll frame inherits it.
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
"MicroMenu.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button
] {
super::test_ui::load_ui(&s, file);
}
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `MacroFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_MacroUI");
s.run("MacroFrame_LoadUI()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The icon chooser's list is the app's push; three entries is enough to index into.
s.set_macro_icons(vec![
"Interface\\Icons\\Ability_Ambush".into(),
"Interface\\Icons\\Ability_BackStab".into(),
"Interface\\Icons\\Spell_Fire_FlameBolt".into(),
]);
s
}
/// Assert no script error has been collected, naming the step that produced it.
fn no_errors(s: &UiScript, step: &str) {
assert!(s.errors().is_empty(), "{step}: {:?}", s.errors());
}
/// The whole creation flow through the window's own buttons: NEW opens the popup, an icon click
/// selects, OKAY enables and creates. This is the path a player takes, and every step of it is
/// wiring this file owns.
#[test]
fn new_then_pick_an_icon_then_okay_creates_the_macro() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
no_errors(&s, "show");
// OKAY starts disabled: no name, no icon.
s.run("MacroNewButton:Click()").unwrap();
assert!(s
.eval::<bool>("return MacroPopupFrame:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return MacroPopupOkayButton:IsEnabled() ~= 0")
.unwrap(),
"a nameless, iconless macro cannot be created"
);
s.run(r#"MacroPopupEditBox:SetText("Ambush")"#).unwrap();
s.run("MacroPopupButton1:Click()").unwrap();
assert!(
s.eval::<bool>("return MacroPopupOkayButton:IsEnabled() ~= 0")
.unwrap(),
"a name and an icon enable OKAY"
);
s.run("MacroPopupOkayButton:Click()").unwrap();
no_errors(&s, "okay");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(1, 0),
"created on the ACCOUNT tab (macroBase 0)"
);
let (name, tex) = s
.eval::<(String, String)>("local n, t = GetMacroInfo(1) return n, t")
.unwrap();
assert_eq!(name, "Ambush");
assert_eq!(tex, "Interface\\Icons\\Ability_Ambush");
assert!(
!s.eval::<bool>("return MacroPopupFrame:IsVisible()")
.unwrap(),
"OKAY closes the popup"
);
// …and the new macro is selected and shown in the detail pane.
assert_eq!(
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
.unwrap(),
"Ambush"
);
}
/// The body editor's commit path — the single line that makes the window an editor at all
/// (`MacroFrame_SaveMacro`, called from the tab switch, the list click, and the window's OnHide).
/// A regression here silently discards everything the player typed.
#[test]
fn typing_a_body_and_closing_the_window_commits_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
s.run(r#"MacroFrameText:SetText("/cast Ambush\n/say pew")"#)
.unwrap();
// The window's dirty flag comes from the box's `OnTextChanged`, which the drain owes until the
// next frame (decision 1831).
s.tick(0.0);
no_errors(&s, "type");
assert!(
s.eval::<bool>("return MacroFrame.textChanged == 1")
.unwrap(),
"OnTextChanged marks the window dirty"
);
// The character counter is the ref's own MACROFRAME_CHAR_LIMIT fill.
assert_eq!(
s.eval::<String>("return MacroFrameCharLimitText:GetText()")
.unwrap(),
"21/255 Characters Used"
);
s.run(r#"HideUIPanel(MacroFrame)"#).unwrap();
no_errors(&s, "hide");
assert_eq!(
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
.unwrap(),
"/cast Ambush\n/say pew",
"closing the window commits the body"
);
}
/// The two tabs address the two index ranges, and switching tabs saves first. The 19 is the whole
/// point: `MacroFrame.macroBase` is 0 or MAX_MACROS, and every binding takes `macroBase + i`.
#[test]
fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run(r#"CreateMacro("Acct", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
// Type into the account macro, then switch tabs WITHOUT any explicit save.
s.run(r#"MacroFrameText:SetText("/say account")"#).unwrap();
s.tick(0.0); // as above — the edit marks, the drain notifies (1831)
s.run("MacroFrameTab2:Click()").unwrap();
no_errors(&s, "tab 2");
assert_eq!(
s.eval::<String>("local _, _, b = GetMacroInfo(1) return b")
.unwrap(),
"/say account",
"the tab switch saved the body first"
);
assert_eq!(s.eval::<i64>("return MacroFrame.macroBase").unwrap(), 18);
// Creating on this tab lands at 19 (the character range's base + 1).
s.run("MacroNewButton:Click()").unwrap();
s.run(r#"MacroPopupEditBox:SetText("Char")"#).unwrap();
s.run("MacroPopupButton2:Click()").unwrap();
s.run("MacroPopupOkayButton:Click()").unwrap();
no_errors(&s, "create on tab 2");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(1, 1)
);
assert_eq!(
s.eval::<String>("return GetMacroInfo(19)").unwrap(),
"Char",
"the character tab's first slot is index 19"
);
// …and back to tab 1 shows the account macro again.
s.run("MacroFrameTab1:Click()").unwrap();
assert_eq!(s.eval::<i64>("return MacroFrame.macroBase").unwrap(), 0);
assert_eq!(
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
.unwrap(),
"Acct"
);
}
/// DELETE removes the macro and leaves the window on a sane selection — the ref re-runs its own
/// OnLoad, which re-selects the first macro (or clears the detail pane when none is left).
#[test]
fn delete_removes_the_macro_and_re_selects() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
s.run(r#"CreateMacro("One", 1, "/say one")"#).unwrap();
s.run(r#"CreateMacro("Two", 2, "/say two")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
s.run("MacroDeleteButton:Click()").unwrap();
no_errors(&s, "delete");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(1, 0)
);
// The list closed its gap, so slot 1 is now the survivor and it is what's selected.
assert_eq!(s.eval::<String>("return GetMacroInfo(1)").unwrap(), "Two");
assert_eq!(
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
.unwrap(),
"Two"
);
// Deleting the last one clears the detail pane rather than leaving a stale selection.
s.run("MacroDeleteButton:Click()").unwrap();
no_errors(&s, "delete last");
assert_eq!(
s.eval::<(i64, i64)>("local a, c = GetNumMacros() return a, c")
.unwrap(),
(0, 0)
);
assert!(
!s.eval::<bool>("return MacroFrameSelectedMacroButton:IsVisible()")
.unwrap(),
"no selection, no detail pane"
);
}
/// A macro button is a DRAG SOURCE: `OnDragStart` loads the cursor with the macro payload, which
/// `PlaceAction` then packs onto a bar slot under the MACRO tag. This is the only route a macro
/// reaches the action bar.
#[test]
fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
.unwrap();
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
// A REAL drag gesture — press, then move — the way `bag_tests` drives one. Calling
// `GetScript("OnDragStart")(button)` leaves `this` nil, and the stock handler reads it
// (decision 1848); our retired file's took the button as an argument.
s.resolve();
let (bx, by): (f32, f32) = s.eval("return MacroButton1:GetCenter()").unwrap();
s.mouse_button(bx, by, "LeftButton", true);
s.mouse_move(bx + 60.0, by + 60.0);
no_errors(&s, "drag");
let payload = s.cursor_payload();
assert!(
matches!(&payload, Some(CursorPayload::Macro(m)) if m.index == 1),
"the macro payload, carrying its index: {payload:?}"
);
assert_eq!(
s.eval::<(String, i64)>("local k, i = GetCursorInfo() return k, i")
.unwrap(),
("macro".to_string(), 1)
);
// Placing it on a bar slot packs the MACRO tag (0x40 << 24) with the macro index.
let mut s = s;
s.run("PlaceAction(1)").unwrap();
assert_eq!(s.take_action_sets(), vec![(1, 0x4000_0000 | 1)]);
}
/// The icon chooser's grid: 20 buttons over the app-pushed list, the tail hidden rather than
/// blank, and a click marking the selection.
#[test]
fn the_icon_chooser_shows_the_pushed_list_and_hides_its_tail() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
s.run("MacroNewButton:Click()").unwrap();
no_errors(&s, "popup");
assert_eq!(s.eval::<i64>("return GetNumMacroIcons()").unwrap(), 3);
for i in 1..=3 {
assert!(
s.eval::<bool>(&format!("return MacroPopupButton{i}:IsVisible()"))
.unwrap(),
"button {i} shows an icon"
);
}
assert!(
!s.eval::<bool>("return MacroPopupButton4:IsVisible()")
.unwrap(),
"past the end of the list the button hides — not a blank square"
);
s.run("MacroPopupButton3:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return MacroPopupFrame.selectedIcon")
.unwrap(),
3
);
assert!(
s.eval::<bool>("return MacroPopupButton3:GetChecked()")
.unwrap(),
"the picked icon is checked"
);
}
/// **The two tabs fit inside the 384-wide window** — the director's own report (2026-08-05: "tab is
/// overflowing"), and the check 0983 never had.
///
/// 0983 dropped the reference's `PanelTemplates_TabResize(-15, …)` tuning as redundant with the
/// template's own +0 fit. It isn't: at +0 a tab is `text + 40`, and two of those plus the 65-unit
/// left inset run off a 384-wide plate — the more so on tab 2, whose label carries a player name and
/// so has no width anyone can know when the window is written. The reference's answer is the −15
/// padding on both and a 150 cap on tab 2; this pins both arms, including the cap actually engaging.
///
/// **Driven through a SYNCHRONOUS measurer, and that is a correction** (decision 1848). This used
/// to feed widths through the async round trip and lean on our retired file's OnUpdate re-check.
/// The stock window has no such re-check: its tabs call `PanelTemplates_TabResize` once, in their
/// own OnLoad, and the reference's measure is inline — so a client whose measure is pending at that
/// moment can never size its tabs at all. The app installs `AtlasMeasurer`, so this harness
/// installs one too; modelling the async path here was modelling a configuration the app does not
/// have.
///
/// The label widths therefore come from the text rather than being chosen, so the two arms are a
/// SHORT character name and a LONG one, and the cap is asserted against the label the measurer
/// actually produced.
#[test]
fn the_two_tabs_fit_inside_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
/// `sideWidths` — 2 × the tab's 16-unit end slice (TabButtonTemplate, UiPanels.xml).
const SIDES: f64 = 32.0;
/// The reference's own padding for this window's tabs (Blizzard_MacroUI.xml l.488/511).
const PAD: f64 = -15.0;
/// …and its cap on tab 2 (l.511).
const CAP: f64 = 150.0;
// A short name and a long one. `CHARACTER_SPECIFIC_MACROS` formats the name into tab 2's
// label, so the name is how the label's width is chosen.
for (name, cap_should_bind) in [("Probe", false), ("Bartholomewthelongnamed", true)] {
let mut s = harness_named(name);
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
s.resolve();
let (w1, w2, left1, label1, label2): (f64, f64, f64, f64, f64) = s
.eval(
"return MacroFrameTab1:GetWidth(), MacroFrameTab2:GetWidth(), \
MacroFrameTab1:GetLeft() - MacroFrame:GetLeft(), \
MacroFrameTab1Text:GetStringWidth(), MacroFrameTab2Text:GetStringWidth()",
)
.unwrap();
assert!(label1 > 0.0 && label2 > 0.0, "both labels measured");
assert_eq!(
w1,
label1 + PAD + SIDES,
"tab 1 is text − 15 + the end slices"
);
let uncapped = label2 + PAD + SIDES;
if cap_should_bind {
assert_eq!(
w2,
CAP + PAD + SIDES,
"the reference's 150 cap binds on a long name"
);
assert!(
uncapped > CAP + PAD + SIDES,
"…and the name really was over it"
);
} else {
assert_eq!(w2, uncapped, "a short name is under the cap");
}
assert!(
w2 <= CAP + PAD + SIDES,
"the clamp may tighten the reference's cap, never loosen it"
);
// The property the clamp exists for, checked outright: the tab ends inside the plate the
// player can actually see, not merely inside the 384-unit frame rect.
let tab2_right: f64 = s
.eval("return MacroFrameTab2:GetLeft() + MacroFrameTab2:GetWidth()")
.unwrap();
assert!(
tab2_right <= 349.0,
"tab 2 ends at {tab2_right}, past the drawn plate's last opaque column (349)"
);
assert!(
left1 + w1 + w2 <= 384.0,
"the tab row ({left1} + {w1} + {w2}) runs off the 384-wide window"
);
no_errors(&s, "tab fit");
}
}
/// **The icon chooser's scroll bar sits ON the popup's plate**, not off its right edge over the
/// world. 0983 sized the faux scroll frame to the icon grid (252×152) instead of transcribing the
/// reference's rect, which pushed the bar — anchored 6 units right of the frame — to popup x
/// 282..298, i.e. straddling the 297-wide plate's edge.
///
/// The frame is invisible, so its rect has exactly one observable consequence and this is it.
#[test]
fn the_icon_choosers_scroll_bar_sits_on_the_popup_plate() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ShowMacroFrame() MacroNewButton:Click()").unwrap();
s.resolve();
let (bar_left, bar_right, plate_left, plate_right): (f64, f64, f64, f64) = s
.eval(
"local b = MacroPopupScrollFrameScrollBar local p = MacroPopupFrame \
return b:GetLeft(), b:GetRight(), p:GetLeft(), p:GetRight()",
)
.unwrap();
assert!(
bar_left >= plate_left && bar_right <= plate_right,
"scroll bar {bar_left}..{bar_right} is outside the popup plate {plate_left}..{plate_right}"
);
// The reference's own seat: 6 units right of a scroll frame whose right edge is 39 in from the
// popup's — so 264..280 of 297. Pinned as a number so a future re-anchor has to mean it.
assert_eq!(
(bar_left - plate_left, bar_right - plate_left),
(264.0, 280.0)
);
no_errors(&s, "popup scroll bar");
}
// `pump_measures` stood here: it answered the measure round-trip every frame so a fit that
// OSCILLATED would show. Nothing oscillates now and nothing can — this harness installs a
// synchronous measurer, because the app does (`AtlasMeasurer`), and the stock tabs size themselves
// once in their own OnLoad with no re-check to converge. Decision 1848.
/// **The tab row is stable and inside the window, frame after frame** — the director's repro
/// (2026-08-05: a 9-character character name, "tab is still slightly overlapping"), and the check
/// the first fix did not have.
///
/// `the_two_tabs_fit_inside_the_window` feeds one measure and ticks once, which cannot catch this:
/// the defect is a FEEDBACK LOOP. The fit sets a width on the label; that changed the label's box;
/// and `GetStringWidth` used to report the box rather than the string, so the next frame refit off
/// a smaller number, uncapped, un-set the box, re-measured wider, capped again — a tab that changed
/// width every single frame and could be photographed at any point in the cycle.
#[test]
fn the_tab_row_settles_and_stays_inside_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
// The name is chosen at construction, because the stock tab builds its label in its own
// OnLoad; and there is no measure round trip to pump any more (decision 1848). The loop stays:
// "it does not change every frame" is still the property, it is just satisfied on the first
// pass now rather than after a convergence.
let mut s = harness_named("Onehunter");
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
let mut widths = Vec::new();
for _ in 0..12 {
s.tick(0.016);
s.resolve();
widths.push(
s.eval::<(f64, f64, f64)>(
"return MacroFrameTab1:GetWidth(), MacroFrameTab2:GetWidth(), \
MacroFrameTab2:GetRight() - MacroFrame:GetLeft()",
)
.unwrap(),
);
}
let last = *widths.last().unwrap();
let settled = &widths[widths.len() - 4..];
assert!(
settled.iter().all(|w| *w == last),
"the tab fit never settles — it changes every frame: {widths:?}"
);
// 344, not 384: this window's art stops 40 units short of its frame rect (MacroFrame.xml's
// `benillaTabRightInset`), and the frame rect is not what the player sees.
assert!(
last.2 <= 344.0,
"tab 2 ends at {} of a window whose plate stops at 344: {widths:?}",
last.2
);
no_errors(&s, "tab settle");
}
/// **The structural cap: no character name can push the tab row off the window** — the guarantee
/// the reference does not have (its 150 is a number hand-tuned to its own font) and the one the
/// director asked for by name. A tab clamps at its parent's right edge whatever it was asked for,
/// so this holds for a name no window author could have anticipated.
#[test]
fn no_character_name_can_push_the_tab_row_off_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
// The `capped = false` arm is gone with decision 1848: it stripped a `benillaTabMaxWidth`
// field of our own, and the reference has no such switch — its cap is the literal `150` passed
// to `PanelTemplates_TabResize` in the tab's OnLoad, which nothing can turn off. So the
// guarantee is now just the guarantee, checked against four names including a 64-character one.
for name in ["Ai", "Onehunter", "Bartholomewthethird", &"W".repeat(64)] {
let mut s = harness_named(name);
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
s.tick(0.016);
s.resolve();
let (right, w2): (f64, f64) = s
.eval(
"return MacroFrameTab2:GetRight() - MacroFrame:GetLeft(), \
MacroFrameTab2:GetWidth()",
)
.unwrap();
assert!(
right <= 344.0,
"{name:?}: the tab row ends at {right}, past the drawn plate's edge at 344 \
(tab 2 is {w2} wide)"
);
no_errors(&s, name);
}
}
/// **The hover highlight IS the tab, at every width and from the first frame** — the director's
/// report on the landed 1002 build: in one screenshot the glow stood proud of tab 1 and fell well
/// short of tab 2.
///
/// Both halves were the same defect, and it is 1002's own read-back trap in the one line 1002 did
/// not fix: the old kit sized the highlight from the label and then capped it at `tab:GetWidth()`,
/// which serves the last RESOLVED rect — inside a settle, the template's pre-fit 115. So every tab
/// in the client wore a 129-unit highlight regardless of its own width, and the settle's change-gate
/// latched it there. The fix is structural (the highlight anchors to the tab's two side edges), so
/// this test asserts the property that makes the whole class of bug impossible: **no frame, no
/// label, no clamp can put the glow at a width other than its tab's.**
#[test]
fn the_tab_highlight_is_exactly_its_tab() {
let _data = benilla_formats::wow_data_or_skip!();
// A name under the reference's cap, one over it, and one long enough that the structural
// drawn-edge clamp (1002) is what sets the width — all three must hold the same property.
for name in ["Ai", "Onehunter", &"W".repeat(40)] {
// The name is chosen at construction (the stock tab labels itself in its own OnLoad) and
// there is no measure round trip to pump — decision 1848. The frame loop stays: the
// highlight tracking its tab on EVERY frame is the property, not just on the first.
let mut s = harness_named(name);
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
for frame in 0..12 {
s.tick(0.016);
s.resolve();
for tab in ["MacroFrameTab1", "MacroFrameTab2"] {
let (tl, tr, hl, hr): (f64, f64, f64, f64) = s
.eval(&format!(
"return {tab}:GetLeft(), {tab}:GetRight(), \
{tab}HighlightTexture:GetLeft(), {tab}HighlightTexture:GetRight()"
))
.unwrap();
// **The two tabs carry DIFFERENT highlight widths, and that is the reference's,
// not a loader bug.** Exactly one `<OnLoad>` runs — the most-derived one; the
// template's is installed and then *destroyed*, because `SetScript` releases the
// slot's single ref before it even looks at the new body (wow-re
// `template-onload-replacement-law.md`). So a tab gets whichever formula ITS OWN
// body ends with:
//
// * tab 2's body is just `PanelTemplates_TabResize(-15, nil, nil, 150)`, whose
// own last statement sets the highlight to `tabWidth` — so highlight == width;
// * tab 1's body RE-STATES `TabButtonTemplate`'s second line verbatim
// (`HighlightTexture:SetWidth(this:GetTextWidth() + 31)`) after calling
// TabResize — so its highlight is independent of its width, and 14 wider.
//
// That re-statement idiom is only necessary if the template's handler does not
// run, and the reference uses it deliberately: `FriendsFrame.xml:610`/`:899` copy
// both template lines while `:626`/`:881` declare none — a controlled pair in one
// file. Our retired transcription made the two tabs agree; the migration reverts
// that (decision 1848).
let label: f64 = s
.eval(&format!("return {tab}Text:GetStringWidth()"))
.unwrap();
let want = if tab == "MacroFrameTab1" {
label + 31.0
} else {
tr - tl
};
assert_eq!(
hr - hl,
want,
"{tab} f{frame} ({name}): highlight {} wide, label {label}, tab {}",
hr - hl,
tr - tl
);
// …and seated where the reference seats it: `TabButtonTemplate` anchors the
// highlight `BOTTOM` with a `(2, -8)` offset, so it is **CENTRED** on the tab and
// nudged 2 right — not left-aligned at `tl + 2`, which is what this asserted while
// the template was ours (decision 1848). With the overhang above, centring is what
// keeps the extra 14 units split evenly rather than all trailing off one end.
assert_eq!(
(hl + hr) / 2.0,
(tl + tr) / 2.0 + 2.0,
"{tab} f{frame} ({name}): highlight not centred on the tab"
);
}
}
no_errors(&s, "tab highlight");
}
}
/// Clicking "Change Name/Icon" opens the icon picker.
///
/// **This harness cannot catch the defect that made it raise in-game**, and that is worth saying
/// out loud: a harness lists its own dependencies, so it had
/// `ClassTrainerFrameTemplates.xml` while the shipped manifest did not. The window worked here and
/// was broken in the client. What catches THAT is
/// `every_template_the_manifest_inherits_is_declared_by_the_manifest`, plus the end-to-end drive in
/// `shipped_xml_tests`. Decision 1862.
///
/// The raise it reproduced:
///
/// ```text
/// Blizzard_MacroUI.lua:233: attempt to perform arithmetic on local 'macroPopupOffset' (a nil value)
/// MacroPopupFrame_Update <- MacroPopupFrame_OnShow <- MacroEditButton_OnClick
/// ```
#[test]
fn the_icon_picker_opens_without_raising() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
select_first(&s);
let _ = s.errors();
s.run("MacroEditButton:Click()").unwrap();
assert!(
s.errors().is_empty(),
"opening the icon picker must not raise: {:?}",
s.errors()
);
assert!(
s.eval::<bool>("return MacroPopupFrame:IsShown() and true or false")
.unwrap(),
"the picker is up"
);
}