Files
benilla-octo/crates/benilla-app/src/ui_script/action_bar_tests.rs
T
SamandClaude Fable 5.1 4c61e6b7c6 the action bars are the reference's own, and a gift can be wrapped
- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938)
- a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943)
- a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942)
- the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945)

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

1883 lines
80 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.
use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, UiScript};
/// The queued action ids alone — `take_action_uses` carries `UseAction`'s self-cast modifier
/// beside the id since 1745, and every assertion in this file is about the id.
fn action_ids(s: &mut UiScript) -> Vec<u32> {
s.take_action_uses().into_iter().map(|u| u.action).collect()
}
/// Load the real `assets/ui/ActionBar.xml` (the shipped default bar) into a bare engine and
/// drive it with a synthetic action snapshot — the slice-1 chain minus Bevy: template
/// expansion over 12 instances, the vanilla bonus-page formula, icon paint on events, empty
/// slots drawing no icon, and a physical click queuing the right UseAction id.
#[test]
fn shipped_action_bar_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
// `load_ui` returns the same `report.frames` the disk reader asserted on, so this
// count is the one that always stood here — moved, not re-derived.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
let frames = super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml")
+ super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml")
+ super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
assert_eq!(
frames, 80,
"what the three stock files declare (1938): MainMenuBar.xml's 8 — the bar, the XP StatusBar, \
the overlay frame, the max-level rail, the art frame, the performance bar and its button, \
the exhaustion tick; ActionBarFrame.xml's 14 — 12 ActionButtons and the 2 page arrows; \
BonusActionBarFrame.xml's 24 — the bonus frame with its 12 buttons and the shapeshift frame \
with its 10; plus one $parentCooldown per action, bonus and shapeshift button (12 + 12 + 10). \
Ours built 59 for the same seats: no shapeshift bar in the file (StanceBar.xml's), no \
overlay frame, no performance-bar button"
);
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
// A warrior in battle stance: offset 1 ⇒ the bar shows actions 73..84.
s.set_bonus_bar_offset(1);
s.set_action(
73,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
s.set_action(
74,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Rogue_Ambush".into()),
kind: 0x00,
action: 101,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The app fires this on the offset's edge (ui_action/feed.rs); the stock bonus frame shows on
// it and slides up over the main bar for BONUSACTIONBAR_SLIDETIME (0.15 s) — one OnUpdate
// paints the start, the next lands it.
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.tick(10.0);
s.tick(0.2);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The stance page is painted by BonusActionButton1/2, not by the main bar (1897/1938). The
// 1024-wide bar centers at BOTTOM of the 1024-wide screen ⇒ bar left edge = 0; the bonus
// frame lands at the bar's BOTTOMLEFT + (BONUSACTIONBAR_XPOS 4, BONUSACTIONBAR_YPOS 43), is
// 43 high, and its button 1 sits at its own BOTTOMLEFT + (5, 4), 36×36 ⇒ x[9,45] y[4,40] —
// one pixel right of the main bar's (8, 4). The reference's own geometry, off
// BonusActionBarFrame.xml:54-96; the chain stride is 36 + 6 = 42.
s.resolve();
let quads = s.extract();
let icon = |path: &str| {
quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
.and_then(|q| q.rect)
};
let r = icon("Interface\\Icons\\Ability_SteelMelee").expect("bonus button 1 icon");
assert_eq!((r.left, r.bottom, r.right, r.top), (9.0, 4.0, 45.0, 40.0));
let r2 = icon("Interface\\Icons\\Ability_Rogue_Ambush").expect("bonus button 2 icon");
assert_eq!(r2.left, 9.0 + 42.0); // bonus button 2 left = 51
// Only an OCCUPIED button is shown — an empty one hides while showgrid == 0
// (ActionButton.lua:69-70) — so two rings, two icons.
let rings = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-Quickslot2"))
})
.count();
assert_eq!(
rings, 2,
"the two occupied bonus buttons; every empty main and bonus slot is hidden"
);
let icons = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("Icons"))
})
.count();
assert_eq!(icons, 2, "empty slots draw no icon quad");
// A physical click at (26, 22) lands on BonusActionButton1 (9..45 × 4..40, two frame levels
// above the hidden main slot) and queues UseAction(73) — the stance page's id, not 1.
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
assert_eq!(action_ids(&mut s), vec![73]);
// The keybinding entry (the app's key feed runs `ActionButtonDown/Up(i)` on the two
// key edges — the ref's ACTIONBUTTONn binding, ActionButton.lua:15-45): UP fires UseAction
// directly with no checkCursor (a keybind never places, decision 0216 §7) — but ONLY from
// the PUSHED state a DOWN set, so a stray release with no press is the ref's own no-op.
s.run("ActionButtonUp(2)").unwrap();
assert!(
s.take_action_uses().is_empty(),
"an Up without a Down is a no-op (the PUSHED gate)"
);
let depressed = |s: &UiScript| {
s.extract()
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-Quickslot-Depress"))
})
.count()
};
s.run("ActionButtonDown(2)").unwrap();
assert_eq!(depressed(&s), 1, "key DOWN shows the pushed texture");
assert_eq!(
s.eval::<String>("return BonusActionButton2:GetButtonState()")
.unwrap(),
"PUSHED",
"with the overlay up a key drives the BONUS button (ActionButton.lua:15-22)"
);
s.run("ActionButtonUp(2)").unwrap();
assert_eq!(action_ids(&mut s), vec![74], "key '2' fires action 74");
assert_eq!(depressed(&s), 0, "key UP restores the normal state");
// Stance drops (offset 0): the bar re-pages to actions 1..12 — all empty here, icons clear.
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
// The overlay descends carrying the old form's page (`lastBonusBar`) and hides at the end
// of its slide — one OnUpdate more than BONUSACTIONBAR_SLIDETIME (paint, then advance).
s.tick(0.2);
s.tick(0.01);
s.resolve();
let icons_after = s
.extract()
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("Icons"))
})
.count();
assert_eq!(icons_after, 0, "re-page to an empty page clears the icons");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
fn load_action_bar(s: &UiScript) {
super::test_ui::load_ui(s, "Cooldown.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(s, "UIParent.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(s, "MoneyFrame.xml");
super::test_ui::load_ui(s, "GameTooltip.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml");
// The options window: `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared there, as the
// reference declares them in UIOptionsFrame_Init (1938) — and the manifest loads it before
// the bars.
super::test_ui::load_ui(s, "UiPanels.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
super::test_ui::load_ui(s, "ScrollTemplates.xml");
super::test_ui::load_ui(s, "KeyBindingsPage.xml");
super::test_ui::load_ui(s, "OptionsFrame.xml");
}
/// The state/feedback layer (decision 0137 phase 4) through the REAL shipped XML: a pushed
/// cooldown + `ACTIONBAR_UPDATE_COOLDOWN` arms the button's Cooldown widget, `IsCurrentAction` +
/// `ACTIONBAR_UPDATE_STATE` checks the ring, and `IsUsableAction`'s OOM pair blue-tints the icon.
#[test]
fn state_feedback_drives_cooldown_checked_and_usable_through_the_xml() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // a nonzero GetTime epoch
// A running 10 s cooldown with 6 s left + the update event: the widget shows mid-sweep.
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
s.resolve();
let sweep = s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
_ => None,
});
let (fraction, flash) = sweep.expect("the button's Cooldown widget is showing");
assert!(
(fraction - 0.4).abs() < 1e-3,
"6 s of 10 s left ⇒ the sweep sits at 40%, got {fraction}"
);
assert_eq!(flash, None);
// The checked ring on the current action (the transcribed UpdateState).
s.set_action_state(
1,
Some(ActionState {
usable: true,
current: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_STATE", vec![]);
assert!(s.eval::<bool>("return ActionButton1:GetChecked()").unwrap());
// The OOM blue tint (the transcribed UpdateUsable): usable=false + notEnoughMana=true.
s.set_action_state(
1,
Some(ActionState {
usable: false,
not_enough_mana: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.resolve();
let icon_color = s.extract().into_iter().find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("Spell_Fire_FlameBolt") => Some(*color),
_ => None,
});
let c = icon_color.expect("icon quad").expect("vertex color set");
assert_eq!(
(c[0], c[1], c[2]),
(0.5, 0.5, 1.0),
"the ref's out-of-power blue-grey"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pie paints OVER its button's art. The Cooldown child is born at button-level+1, and the
/// draw key's LEVEL term outranks 0884's bucket-wide layer term — so the sweep quad must sort
/// after the icon (BACKGROUND) and after the button's own special textures. A regression here is
/// invisible to every store/feed instrument (the triple still pushes; only the pixels vanish
/// under the icon), which is exactly why the order is pinned end-to-end through the real XML.
#[test]
fn the_cooldown_sweep_paints_over_the_buttons_icon_and_ring() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0);
s.set_action_state(
1,
Some(ActionState {
usable: true,
cooldown: Some((6_000, 10_000, true)),
..Default::default()
}),
);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
s.resolve();
let quads = s.extract();
let pos = |pred: &dyn Fn(&QuadContent) -> bool| quads.iter().position(|q| pred(&q.content));
let icon = pos(&|c| {
matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("Spell_Fire_FlameBolt"))
})
.expect("the icon texture quad");
let ring = pos(
&|c| matches!(c, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Quickslot2")),
)
.expect("the NormalTexture ring quad");
let sweep = pos(&|c| matches!(c, QuadContent::Cooldown { .. })).expect("the sweep quad");
assert!(
icon < sweep,
"the sweep (index {sweep}) must paint over the icon (index {icon})"
);
assert!(
ring < sweep,
"the sweep (index {sweep}) must paint over the button ring (index {ring})"
);
}
/// An action button is a TWO-button button (decision 0908; director's report B200: "I can't right
/// click food on my bar to eat it or right click spells"). The ref's `ActionButton_OnLoad`
/// registers `("LeftButtonUp", "RightButtonUp")` (ActionButton.lua:109) and its OnClick body reads
/// no `arg1` — so either button runs the same fork and right-click USES the action. The widget
/// default is `{"LeftButtonUp"}` (`benilla_ui::script::button::wants_click`), so without the
/// explicit registration the input path silently swallowed every right-click on the bar. Driven
/// through the real shipped XML and the real `mouse_button` path, which is the only place the
/// registration set is consulted.
#[test]
fn a_right_click_on_an_action_button_uses_the_action() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_11".into()),
kind: 0x80, // an ITEM action — the food/mount case the report is about
action: 4540,
count: 5,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Button 1's center (geometry as in the tests above): (26, 22).
s.mouse_button(26.0, 22.0, "RightButton", true);
s.mouse_button(26.0, 22.0, "RightButton", false);
assert_eq!(
action_ids(&mut s),
vec![1],
"right-click queues the same UseAction a left-click does"
);
// The middle button is registered by neither the ref nor us: it stays swallowed, which is what
// proves the assertion above is the REGISTRATION and not the gate having been removed.
s.mouse_button(26.0, 22.0, "MiddleButton", true);
s.mouse_button(26.0, 22.0, "MiddleButton", false);
assert!(
s.take_action_uses().is_empty(),
"an unregistered button still reaches nothing"
);
// Shift+right-click picks up, exactly as shift+left does — the OnClick fork is button-blind.
s.set_modifiers(true, false, false);
s.mouse_button(26.0, 22.0, "RightButton", true);
s.mouse_button(26.0, 22.0, "RightButton", false);
s.set_modifiers(false, false, false);
assert!(s.take_action_uses().is_empty());
assert!(
s.cursor_payload().is_some(),
"shift+right-click carries the action, like shift+left"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Decision 0216 §7 (byte-verified 0218 §4) driven through the REAL shipped XML, not the engine
/// unit tests directly — the modifier-key mirror gating `PickupAction` vs `UseAction`, end to end.
#[test]
fn shift_click_picks_up_not_uses() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Button 1's center (unchanged geometry from the test above): (26, 22).
s.set_modifiers(true, false, false); // IsShiftKeyDown() true
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.take_action_uses().is_empty(),
"shift-click PICKS UP, never queues a use"
);
assert!(s.cursor_payload().is_some(), "action 1 is on the cursor");
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"slot cleared"
);
assert_eq!(
s.take_action_sets(),
vec![(1, 0)],
"picking up queues the clear-the-slot send"
);
// A PLAIN click while holding routes through checkCursor=1 to a place — closes the loop back
// onto the same (now empty) slot: no shift needed once something is already held.
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
assert!(s.take_action_uses().is_empty(), "routed to place, not use");
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert!(s.eval::<bool>("return HasAction(1)").unwrap());
assert_eq!(s.take_action_sets(), vec![(1, 111)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Lock ActionBars** (decision 1136) — the `LOCK_ACTIONBAR` uvar the Options window's Action Bars
/// row and the `TOGGLEACTIONBARLOCK` binding both write, guarding the two drag ends the way the
/// reference does (ActionBarFrame.xml:23-38).
///
/// The teeth are the second half: the reference leaves the shift-click pick-up in `OnClick`
/// UNGUARDED (l.12-22), so a locked bar still yields to the deliberate gesture. Guarding it too
/// would be a "sensible" tightening that silently diverges — and would leave a locked bar with no
/// way to rearrange it at all.
#[test]
fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
assert_eq!(
s.eval::<String>("return LOCK_ACTIONBAR").unwrap(),
"0",
"the bar ships unlocked, the reference's own default"
);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnDragStart\")()")
.unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked bar does not give the action up to a drag"
);
assert!(
s.eval::<bool>("return HasAction(1)").unwrap(),
"slot intact"
);
assert!(
s.take_action_sets().is_empty(),
"and nothing is sent to the server"
);
// Shift-click still picks up — the reference's unguarded fork, and the way out of a locked bar.
s.set_modifiers(true, false, false);
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.cursor_payload().is_some(),
"shift-click is not what the lock stops"
);
// The receiving end is guarded too: the held action cannot be dropped back by a drag…
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()")
.unwrap();
assert!(
s.cursor_payload().is_some(),
"a locked slot refuses the drop"
);
// …and unlocking makes both ends live again.
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()")
.unwrap();
assert!(s.cursor_payload().is_none(), "unlocked, the drop lands");
assert_eq!(
s.take_action_sets(),
vec![(1, 0), (1, 111)],
"the shift-pickup's clear and the unlocked drop's set — and nothing from the two \
refused gestures between them"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A physical drag from button 1 onto OCCUPIED button 2: the byte-verified action-bar hop (0218
/// §4) — the displaced action lands on the cursor, TWO independent `action_sets` entries across
/// the one gesture (0218 §4: "a drag-swap is two sends, never atomic").
#[test]
fn drag_drop_onto_another_button_hops_the_displaced_action() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
consumable: false,
}),
);
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_B".into()),
kind: 0x00,
action: 222,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Button 1 center (26, 22), button 2 center (68, 22) — same geometry as the end-to-end test.
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_move(40.0, 22.0); // past the 4px drag-start threshold
let consumed = s.mouse_button(68.0, 22.0, "LeftButton", false);
assert!(consumed, "OnReceiveDrag consumed the release");
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"slot 1 emptied"
);
assert!(s.eval::<bool>("return HasAction(2)").unwrap());
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A",
"slot 2 now shows the placed action"
);
let (kind, src) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
assert_eq!(
(kind.as_str(), src),
("action", 2),
"the displaced action hopped on, sourced from slot 2"
);
// Two independent sends across the one gesture: the pickup's clear, then the place's write.
assert_eq!(s.take_action_sets(), vec![(1, 0), (2, 111)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Count fontstring, on the reference's own gate (`ActionButton_UpdateCount`, ref
/// ActionButton.lua:285-292): **`IsConsumableAction`**, never "count > 0". The director's report —
/// a mount on the bar wearing a stack number "1" — is the non-consumable case: a mount holds an
/// on-use spell with zero charges and `InventoryType` 0, so `IsConsumableAction 0x4e5250` answers
/// false and the ref paints nothing at all. It repaints on `ACTIONBAR_SLOT_CHANGED` alongside the
/// icon (the same event the identity resolve fires).
///
/// **The gate rides the SLOT, not the state map** (decision 1301). It used to be pushed through
/// `set_action_state`, and this test set that up *before* the repaint — the opposite of the
/// runtime order, where the identity feed fires `ACTIONBAR_SLOT_CHANGED` a whole system before the
/// state feed writes anything. That inversion is why a passing test sat over a fresh character
/// whose food showed no stack number at all. Every push here is now one `set_action`, which is
/// the only order the runtime can produce.
#[test]
fn count_fontstring_follows_is_consumable_action_not_the_bag_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
// Multi-digit counts, deliberately: the static HotKey labels are single characters
// ("1".."9","0","-","="), so a single-digit count could false-positive match an unrelated
// button's hotkey text rather than the Count fontstring actually under test.
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00, // SPELL
action: 111,
count: 42, // the app never actually sets this for a spell — proves the XML, not the feed
consumable: false,
}),
);
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
kind: 0x80, // ITEM — a stack of food
action: 117,
count: 15,
consumable: true,
}),
);
// The report's own shape: an ITEM action the player holds eleven of, which is NOT consumable
// (a mount). The count is fed all the same; the gate is what must suppress it.
s.set_action(
3,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Mount_Undeadhorse".into()),
kind: 0x80,
action: 13332,
count: 11,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Read the fontstrings by name, not by scanning painted text: a single-character count
// ("0") is indistinguishable from a neighbouring button's static HotKey label by content.
let count_of = |s: &UiScript, n: u32| {
s.eval::<String>(&format!("return ActionButton{n}Count:GetText() or \"\""))
.unwrap()
};
assert_eq!(
count_of(&s, 1),
"",
"SPELL kind never shows a count, however GetActionCount answers"
);
assert_eq!(
count_of(&s, 2),
"15",
"a consumable ITEM shows its bag count"
);
assert_eq!(
count_of(&s, 3),
"",
"B201: a NON-consumable ITEM (a mount) shows no stack number, whatever the count says"
);
// …and it really is painted, not just set (the multi-digit value is unambiguous in the quads).
assert!(
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "15")),
"the consumable's count reaches the screen"
);
// Eat the stack down to nothing. A *consumable* keeps its fontstring and reads a literal "0"
// — the ref's `SetText(GetActionCount(...))` is unconditional inside the gate, and 0216 §7's
// `count > 0` blank was ours, not the reference's.
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
kind: 0x80,
action: 117,
count: 0,
consumable: true,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(2)]);
s.resolve();
assert_eq!(
count_of(&s, 2),
"0",
"a spent consumable reads 0, it does not go blank"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The macro-name line (ref `ActionButton_Update:236-238`, "Update Macro Text") through the REAL
/// shipped XML: a MACRO slot's button reads its macro's name under the icon, a SPELL slot's reads
/// nothing, and a macro slot that empties loses the name through the same unconditional write.
/// B340 (decision 1636): the template declared `$parentName` and nothing ever set it, so every
/// macro on the bar was nameless.
#[test]
fn macro_name_line_follows_get_action_text_through_the_xml() {
use benilla_ui::script::{MacroState, MacroView};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_macros(MacroState {
account: vec![MacroView {
name: "spawn".into(),
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
body: ".spawn 16032".into(),
local_only: false,
}],
character: Vec::new(),
});
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Racial_Cannibalize".into()),
kind: 0x40, // MACRO
action: 1, // macro index 1
count: 0,
consumable: false,
}),
);
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00, // SPELL
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// Read the fontstrings by name (the count test's reason: painted text is ambiguous).
let name_of = |s: &UiScript, n: u32| {
s.eval::<String>(&format!("return ActionButton{n}Name:GetText() or \"\""))
.unwrap()
};
assert_eq!(
name_of(&s, 1),
"spawn",
"a MACRO slot wears its macro's name"
);
assert_eq!(name_of(&s, 2), "", "a SPELL slot has no name line");
assert!(
s.extract()
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "spawn")),
"the name reaches the screen"
);
// The slot empties: the ref's write is unconditional, so a nil clears the line.
s.set_action(1, None);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(1)]);
s.resolve();
assert_eq!(name_of(&s, 1), "", "an emptied slot loses the name");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The shipped **bag BAR** — `assets/ui/BagFrame.xml` — materialized frame for frame, and then
/// driven end to end: the bar's own backpack toggle opens the backpack window, the fed stack
/// paints in its slot's well, the slot's clicks queue the right intents, and the toggle shuts it
/// again. It lives in this file because the bar seats on `MainMenuBarArtFrame` (ActionBar.xml) —
/// the toggle's anchor arithmetic below is the reason.
///
/// **What decision 1751 changed.** This asserted `report.frames == 259` over a breakdown that
/// counted five bag WINDOWS and a keyring window (37 + 4×42 + 42, plus the bar's own handful).
/// Those windows are gone from this file: the live ones are the reference's `ContainerFrame1..12`,
/// executed off the player's own patch chain, and `BagFrame.xml` is the BAR and nothing else —
/// this client's stand-in for the reference's `MainMenuBarBagButtons.xml`. So the count is
/// recounted from what the file declares today, and the drive reaches the reference's window
/// through the bar's own button rather than showing one of ours by name.
#[test]
fn shipped_bag_frame_drives_end_to_end() {
let _data = benilla_formats::wow_data_or_skip!();
use super::test_ui::{bag_open, bag_slot_button, centre_of, load_ui, BAG_UI};
use benilla_ui::script::{ContainerSlot, ContainerState};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// [`BAG_UI`] is `benilla.toc`'s own order for everything a bag window needs. Three files join
// it at the positions the manifest gives them:
// * ActionBar.xml straight after Cooldown.xml — the bag bar is anchored INTO
// MainMenuBarArtFrame, so the bar must exist before the bag bar loads or the toggle's
// cross-file `relativeTo` silently falls back to the screen root (which would land in the
// right place here anyway, by the 1024-wide coincidence: screen BOTTOMRIGHT == the
// full-width bar's art-frame BOTTOMRIGHT — so the failure would be invisible);
// * StackSplit.xml and MerchantFrame.xml after the bags — the reference's
// `ContainerFrameItemButton_OnClick` reads `StackSplitFrame` on both arms and
// `MerchantFrame:IsShown()` on the right one, so a slot click raises without them. That
// dependency is the reference's, not ours; MerchantFrame.xml is additionally the home of
// the `BenillaMoney_*` coin rig this chain's windows call.
let mut bar_frames = 0;
for file in BAG_UI {
let frames = load_ui(&s, file);
if *file == "Cooldown.xml" {
load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
load_ui(&s, "UIParent.xml");
load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_ui(&s, "MoneyFrame.xml");
load_ui(&s, "GameTooltip.xml");
load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
}
if *file == "Interface\\FrameXML\\MainMenuBarBagButtons.xml" {
bar_frames = frames;
}
}
load_ui(&s, "Interface\\FrameXML\\StackSplitFrame.xml");
load_ui(&s, "Interface\\FrameXML\\MerchantFrame.xml");
assert_eq!(
bar_frames, 16,
"the bag bar is six CheckButtons — MainMenuBarBackpackButton, CharacterBag0..3Slot, \
KeyRingButton — each carrying one $parentItemAnim Model (6 + 6), plus a $parentCooldown \
Model on each of the four slots that inherit PaperDollItemSlotButtonTemplate (+4). The \
deleted BagFrame.xml built 12: it mirrored the six buttons and their push cards but had \
no cooldown on a bag-bar slot at all, which is the reference's own and is what the swap \
to Interface\\FrameXML\\MainMenuBarBagButtons.xml brought with it (1751 window 3)"
);
// The app's feed: a backpack with Tough Jerky ×5 in slot 1.
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: None,
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Nothing open at load: no jerky on screen.
s.resolve();
let jerky_visible = |quads: &[benilla_ui::script::ExtractedQuad]| {
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Misc_Food_16"))
})
};
assert!(!jerky_visible(&s.extract()), "no bag window at load");
// Click the toggle → the backpack window opens and slot 1 paints the jerky. The toggle seats
// on the bar's art frame BOTTOMRIGHT +(-6,2), 37×37: art frame BOTTOMRIGHT is the bar's
// (full-width, bottom-anchored) corner (1024,0) ⇒ toggle x[981,1018] y[2,39], center
// (999.5,20.5). That arithmetic is THIS file's — the button is `BagFrame.xml`'s and its seat
// is `ActionBar.xml`'s — so the click stays at literal coordinates rather than going through
// `centre_of`: hitting them is part of what is being tested.
s.mouse_button(999.0, 20.0, "LeftButton", true);
s.mouse_button(999.0, 20.0, "LeftButton", false);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(bag_open(&s, 0), "the bar's toggle opened the backpack");
let quads = s.extract();
let icon = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Misc_Food_16"))
})
.and_then(|q| q.rect)
.expect("slot 1 icon visible after toggle");
// WHERE inside the window that well sits is the reference's arithmetic, not ours
// (`ContainerFrame_GenerateFrame` + `updateContainerFrameAnchors`), so it is asked of the
// button rather than pinned to numbers this tree no longer owns — and asked by GetID, since
// the reference numbers its buttons backwards (`…Item1` is the bag's LAST slot). The property
// is what it always was: the fed stack paints in the well that says it is game slot 1.
let button = bag_slot_button(&s, 0, 1);
let (bx, by) = centre_of(&mut s, &button);
assert!(
icon.left <= bx && bx <= icon.right && icon.bottom <= by && by <= icon.top,
"the jerky icon {icon:?} is not painted on slot 1's button ({button} at {bx},{by})"
);
// The stack count renders as text.
assert!(
quads
.iter()
.any(|q| { matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "5") }),
"stack count shows"
);
// LEFT-click picks the item up onto the cursor — a local drag, no wire use queued until a
// place (ref ContainerFrameItemButton_OnClick's left arm: PickupContainerItem).
s.mouse_button(bx, by, "LeftButton", true);
s.mouse_button(bx, by, "LeftButton", false);
assert!(
s.take_container_uses().is_empty(),
"left-click is a pickup, not a use"
);
assert!(
s.cursor_item().is_some(),
"left-click put the item on the cursor"
);
assert!(
s.take_container_moves().is_empty(),
"a pickup alone queues no move"
);
// RIGHT-click while holding. **This assertion inverted with 1751, and the inversion is the
// point of recording it.** Our own `BenillaBagSlot_OnClick` had a cursor-cancel arm — a
// benilla divergence — so this used to assert that the pickup was cancelled and nothing sent.
// The reference's right arm has no cursor test at all: it falls through to
// `UseContainerItem(bag, slot)` like any other right-click, and the cursor keeps its payload.
// Pinned as what it now IS rather than dropped, so the swap is visible here. Whether the host
// should treat a use-while-holding as a place is a fidelity question for the bag arc, not
// this file's to answer.
s.mouse_button(bx, by, "RightButton", true);
s.mouse_button(bx, by, "RightButton", false);
assert_eq!(
s.take_container_uses(),
vec![(0, 1)],
"the reference's right arm uses the slot even with a full cursor"
);
assert!(
s.cursor_item().is_some(),
"…and leaves the held item where it was"
);
s.run("ClearCursor()").unwrap();
// RIGHT-click with an empty cursor uses slot 1 (UseContainerItem → the app's use/equip fork).
s.mouse_button(bx, by, "RightButton", true);
s.mouse_button(bx, by, "RightButton", false);
assert_eq!(s.take_container_uses(), vec![(0, 1)]);
// Toggle again → shut.
s.mouse_button(999.0, 20.0, "LeftButton", true);
s.mouse_button(999.0, 20.0, "LeftButton", false);
s.resolve();
assert!(!bag_open(&s, 0), "toggle closes the window");
assert!(!jerky_visible(&s.extract()), "…and its slots with it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The empty-wells regression (director-reported, 2026-07-10): the bar-level event fan ran
/// UpdateUsable on EMPTY buttons, whose `IsUsableAction` answers (nil, nil) — the 0.4 grey
/// `SetVertexColor` landed on the texture-less icon region and drew a solid grey plate over every
/// empty well. The fix is the ref's own HasAction gate on the fan (ActionButton.lua registers the
/// state handlers only while the button has an action). This drives the exact failing sequence —
/// a usable/cooldown event with empties on the bar — and asserts no empty well gains a solid.
#[test]
fn state_events_leave_empty_wells_untinted() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
// One occupied slot; 2..12 empty.
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
kind: 0x00,
action: 133,
count: 0,
consumable: false,
}),
);
s.set_action_state(
1,
Some(ActionState {
usable: false,
not_enough_mana: true,
..Default::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The failing edges: the fanned usable/cooldown/state events with empties on the bar.
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.fire_event("ACTIONBAR_UPDATE_COOLDOWN", vec![]);
s.fire_event("ACTIONBAR_UPDATE_STATE", vec![]);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.resolve();
// No texture-less colored quad anywhere on the button row (the icon regions of empty wells
// must stay path-None + color-None); the occupied icon still carries its OOM blue.
let mut oom_icon = None;
for q in s.extract() {
match &q.content {
QuadContent::Texture {
path: None,
color: Some(c),
..
} if q.rect.is_some_and(|r| r.right - r.left <= 40.0) => {
// Well-sized only: the XP bar's 1024-wide black backdrop is a legitimate solid.
panic!("an empty well gained a solid color quad: {c:?}")
}
QuadContent::Texture {
path: Some(p),
color,
..
} if p.contains("Spell_Fire_FlameBolt") => oom_icon = *color,
_ => {}
}
}
assert_eq!(
oom_icon,
Some([0.5, 0.5, 1.0, 1.0]),
"the occupied button still tints OOM blue"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The white-buttons regression (director-reported 2026-08-07, decision 1108): a slot that was
/// OCCUPIED — UpdateUsable painted its icon's 1/1/1 usable tint — then goes EMPTY (the feed's
/// character-switch diff: `set_action(None)` + `ACTIONBAR_SLOT_CHANGED`) kept the tint on the
/// now-artless icon region and drew it as a solid WHITE square. Two laws close it, both asserted
/// here: the empty arm HIDES the icon (ref ActionButton.lua l.168), and the engine emits nothing
/// for a texture-less region whatever its surviving tint (`0x7706e0` — the draw gate is `+0xcc`,
/// never the colour).
#[test]
fn an_occupied_slot_going_empty_leaves_no_white_plate() {
use benilla_ui::script::ActionState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
s.set_action(
3,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Spell_Nature_HealingTouch".into()),
kind: 0x00,
action: 5185,
count: 0,
consumable: false,
}),
);
s.set_action_state(
3,
Some(ActionState {
usable: true,
..Default::default()
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The usable pass paints the occupied icon's 1/1/1 usable tint — the tint that survives.
s.fire_event("ACTIONBAR_UPDATE_USABLE", vec![]);
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(&q.content,
QuadContent::Texture { path: Some(p), .. } if p.contains("Spell_Nature_HealingTouch"))),
"the occupied slot draws its icon"
);
// The character switch: the new character's table has nothing in slot 3.
s.set_action(3, None);
s.set_action_state(3, None);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(3)]);
s.resolve();
for q in s.extract() {
match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("Spell_Nature_HealingTouch") =>
{
panic!("the emptied slot still draws the old icon")
}
QuadContent::Texture {
path: None,
color: Some(c),
..
} if q.rect.is_some_and(|r| r.right - r.left <= 40.0) => {
// Well-sized only, as in the sibling test: page-wide solids are legitimate.
panic!("the emptied slot draws its surviving tint as a solid plate: {c:?}")
}
_ => {}
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The bonus action bar exists and stays hidden** — the same posture 1219 gave the vertical
/// multibars, and the largest single session-start row in the corpus.
///
/// `ref-BonusActionBarFrame.xml` l.54 instantiates `BonusActionBarFrame` as a real
/// `parent="MainMenuBar"` frame carrying `hidden="true"`, with `BonusActionButton1..12` inside.
/// benilla models the bonus page by re-paging the MAIN bar, so we never show this one — but four
/// addons died at `CT_BarMod\CT_BarModOptions.lua:154`,
/// `getglobal("BonusActionButton" .. i):ClearAllPoints()`, which is pure layout and needs only
/// that the buttons be there.
///
/// The last assertion is the one that keeps it honest: declaring a hidden bar must not change what
/// the visible bar shows.
#[test]
fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
assert!(
s.eval::<bool>("return BonusActionBarFrame ~= nil").unwrap(),
"5 corpus addons index BonusActionBarFrame by name"
);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"it must ship HIDDEN — benilla re-pages the main bar instead of showing this one"
);
for i in [1, 12] {
assert!(
s.eval::<bool>(&format!("return BonusActionButton{i} ~= nil"))
.unwrap(),
"BonusActionButton{i} must exist"
);
// CT_BarModOptions.lua:154's exact pair, on a bar that has never been shown.
s.run(&format!("BonusActionButton{i}:ClearAllPoints()"))
.unwrap();
s.run(&format!(
"BonusActionButton{i}:SetPoint(\"TOP\", \"ActionButton1\", \"BOTTOM\", 0, -4)"
))
.unwrap();
}
// A hidden bar changes nothing about the visible one. (An EMPTY main-bar button is itself
// hidden under the reference — `ActionButton_Update` hides a slot with no action while
// `showgrid == 0`, ActionButton.lua:69-70 — so slot 1 is occupied first.)
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(1)]);
assert!(
s.eval::<bool>("return ActionButton1:IsShown()").unwrap(),
"the main bar is untouched"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The reference's action-bar constants are real globals, not comments.**
///
/// `ActionButton.lua:1-9` defines them; this file and `MultiBars.xml` cited them in comments and
/// defined none. An addon reading one got nil — `zBar.lua:40` is the shape,
/// `to = to or value.max or NUM_ACTIONBAR_BUTTONS` feeding a numeric `for`, which raises
/// `'for' limit must be a number`. Only the use-probe could find it: nothing else touches anything.
///
/// `CURRENT_ACTIONBAR_PAGE` is asserted ABSENT on purpose. It is the reference's mutable page
/// cursor and benilla does not page the main bar that way; a frozen 1 would be silently wrong
/// forever, where nil fails loudly. Pinned so a later "completeness" pass cannot quietly add it.
#[test]
fn the_reference_action_bar_constants_are_defined() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
for (name, want) in [
("NUM_ACTIONBAR_PAGES", 6),
("NUM_ACTIONBAR_BUTTONS", 12),
("BOTTOMLEFT_ACTIONBAR_PAGE", 6),
("BOTTOMRIGHT_ACTIONBAR_PAGE", 5),
("LEFT_ACTIONBAR_PAGE", 4),
("RIGHT_ACTIONBAR_PAGE", 3),
] {
assert_eq!(
s.eval::<i64>(&format!("return {name}")).unwrap(),
want,
"{name} must be the reference's value"
);
}
// zBar's exact expression, which raised before these existed.
assert_eq!(
s.eval::<i64>("local to = nil or nil or NUM_ACTIONBAR_BUTTONS local n = 0 for i = 1, to do n = n + 1 end return n")
.unwrap(),
12,
"zBar.lua:40's numeric for must have a limit"
);
// `CURRENT_ACTIONBAR_PAGE` was asserted ABSENT here, on the grounds that a frozen 1 lies where
// nil fails loudly. That objection is discharged, not overruled: the bar pages now, so the
// global is live state the paged-id formula reads rather than a frozen number. It is therefore
// asserted as state — present, and MOVING — instead of as one of the constants above.
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
2,
"a frozen 1 would still be a lie — this one has to move"
);
s.run("ActionBar_PageDown()").unwrap();
}
/// **The reference's two-level action-button split, both halves inheritable by name.**
///
/// `ActionButtonTemplate` (ref ActionButtonTemplate.xml:3) is regions only and carries NO scripts;
/// `ActionBarButtonTemplate` (ref ActionBarFrame.xml:4) inherits it and adds the handlers. Ours
/// conflated them under one `Benilla*` name, so an addon inheriting either reference name got a
/// bare frame — no art, no regions, and no error (1203's silent shape).
///
/// `zBar.xml:7` is the corpus shape: it inherits `ActionBarButtonTemplate`, wires its own OnLoad,
/// and then reads `getglobal(button:GetName().."NormalTexture")` — a derived name it only has
/// because the template declares `$parentNormalTexture`. That read is where it died.
///
/// The last assertion is the one that keeps our own bars safe: the alias must still resolve to the
/// full thing, or 48 `inherits=` sites across four files silently lose their handlers.
#[test]
fn both_reference_action_button_templates_are_inheritable() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
// zBar's exact shape: inherit the bar template, supply your own OnLoad.
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<CheckButton name="ZLikeButton" inherits="ActionBarButtonTemplate" parent="UIParent" id="1">
<Anchors><Anchor point="CENTER"/></Anchors>
</CheckButton>
<CheckButton name="BareLikeButton" inherits="ActionButtonTemplate" id="1">
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</CheckButton>
</Ui>"#,
)
.unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
assert!(
!report
.warnings
.iter()
.any(|w| w.contains("unknown template")),
"both names must resolve: {:?}",
report.warnings
);
// The derived name zBar reads, on a button built from each half.
for owner in ["ZLikeButton", "BareLikeButton"] {
assert!(
s.eval::<bool>(&format!("return {owner}NormalTexture ~= nil"))
.unwrap(),
"{owner}NormalTexture — zBar.lua:88's read"
);
assert!(
s.eval::<bool>(&format!(
"return {owner}Icon ~= nil and {owner}Cooldown ~= nil"
))
.unwrap(),
"{owner} must carry the template's regions"
);
}
// The base half carries NO handlers, exactly as the reference's does — an addon inheriting it
// wires its own, and must not silently receive ours.
assert!(
!s.eval::<bool>("return BareLikeButton:GetScript(\"OnClick\") ~= nil")
.unwrap(),
"ActionButtonTemplate is regions only; handlers belong to the bar half"
);
assert!(
s.eval::<bool>("return ZLikeButton:GetScript(\"OnClick\") ~= nil")
.unwrap(),
"ActionBarButtonTemplate carries the handler set"
);
// ...and our own alias still resolves to the full thing.
assert!(
s.eval::<bool>("return ActionButton1NormalTexture ~= nil and ActionButton1:GetScript(\"OnClick\") ~= nil")
.unwrap(),
"BenillaActionButtonTemplate's 48 inherits= sites must be untouched by the split"
);
}
/// Main-bar paging — `CURRENT_ACTIONBAR_PAGE` and the three verbs around it.
///
/// The data was always there (the app owns all 120 action slots); only the selector was missing,
/// and its absence was visible on screen as page arrows with no `OnClick`. `Bartender2.lua:686`
/// died on the nil `ChangeActionBarPage` at session start.
///
/// Two things are asserted that a reconstruction would get wrong. **A bonus page outranks the
/// paged one** — the reference's own `ActionButton_GetPagedID` takes the bonus branch first, so
/// paging must be the `else` arm and not an addition. And **page-up wraps to the literal page 1**
/// while page-down rescans for the last viewable page: that asymmetry is the reference's, and it
/// is observable the moment a page is blanked from `VIEWABLE_ACTION_BAR_PAGES`.
///
/// Since 1500 the blanking is driven here the way the client drives it — by raising the two bottom
/// multibars — rather than read off a declaration. All six pages are viewable at rest now, because
/// every extra bar ships off and nothing has claimed a page yet.
#[test]
fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
// Fonts first: the pane's check-box labels colour from `RED_FONT_COLOR` in their own OnLoad.
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
] {
// `test_ui::load_ui`, not a disk read: this list names chain entries now (the reputation
// pane and the templates it inherits through), and `assets/ui` cannot answer for those.
super::test_ui::load_ui(&s, file);
}
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"page 1 button 1 is action 1"
);
assert!(
s.eval::<bool>(
"for i = 1, NUM_ACTIONBAR_PAGES do \
if not VIEWABLE_ACTION_BAR_PAGES[i] then return false end \
end return true"
)
.unwrap(),
"all six pages are viewable at rest — every extra bar ships off (1500)"
);
// The NUMERAL is the only output of paging the player can actually see on the bar itself, and
// it went unwritten for as long as paging existed: the arrows worked, the twelve buttons
// repainted, and the "1" beside them was a declared literal nothing ever touched — so clicking
// them read as doing nothing at all (director, 2026-08-22). It seeds at the page's own value.
let page_text = |s: &UiScript| {
s.eval::<String>("return MainMenuBarPageNumber:GetText()")
.unwrap()
};
assert_eq!(page_text(&s), "1", "the load seed is the current page");
// Raise the two bottom bars the way the client does, which is what takes pages 6 and 5 out of
// the cycle from here on.
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 MultiActionBar_Update()")
.unwrap();
// Page up walks to 2, so button 1 shows action 13.
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 2);
assert_eq!(page_text(&s), "2", "the numeral follows the page up");
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
13
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton12)")
.unwrap(),
24
);
// Down again, and below page 1 it rescans to the LAST viewable page.
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
4,
"page-down off the bottom rescans for the last VIEWABLE page — 4, not 6, because the \
two raised bottom multibars own pages 5 and 6"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
37
);
assert_eq!(
page_text(&s),
"4",
"the numeral follows a wrap, not just a step"
);
// The pages the bottom bars already display are unreachable from the main bar — which is
// exactly what MultiActionBar_Update did above. Without it, paging up lands on a duplicate of
// the twelve actions already on screen below.
assert!(s
.eval::<bool>(
"return VIEWABLE_ACTION_BAR_PAGES[5] == nil and VIEWABLE_ACTION_BAR_PAGES[6] == nil"
)
.unwrap());
s.run("CURRENT_ACTIONBAR_PAGE = 4 ActionBar_PageUp()")
.unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
1,
"walking up from the last viewable page skips 5 and 6 and wraps to the LITERAL 1 — the \
reference's own asymmetry with page-down, which rescans instead"
);
// The bonus branch is GUARDED by the page (ActionButton.lua:447: `button.isBonus and
// CURRENT_ACTIONBAR_PAGE == 1`) — and a MAIN-bar button has no isBonus at all, so it never
// takes that branch: the bonus offset is BonusActionBarFrame's own twelve buttons' business
// (1897, adopted with 1938). Main-bar slot 1 on page 3 is action 25 whatever the offset.
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
s.set_bonus_bar_offset(1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
25,
"a main-bar button follows its page, never the bonus offset (ActionButton.lua:447-456)"
);
s.run("CURRENT_ACTIONBAR_PAGE = 1").unwrap();
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
73,
"the bonus frame's own button, on page 1 with offset 1, is 72 + 1"
);
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
25,
"and off page 1 even the bonus button follows the page: the conjunct 1897 found missing"
);
s.set_bonus_bar_offset(0);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
25,
"and the page comes back when the form drops"
);
}
/// The form/stance/stealth swap transition (decision 1524; ref BonusActionBarFrame.lua:1-98).
/// Entering a form slides the BonusActionBarFrame replica up over 0.15s — the main bar keeps
/// painting the OLD page underneath until the landing, which is also the one moment the sound
/// (igBonusBarOpen) plays. The overlay then STAYS shown while the form holds (the ref-visible
/// state addons read), a direct form→form swap repaints without re-sliding or re-sounding, and
/// dropping the form slides it back down carrying the old form's page (lastBonusBar), silently.
/// Keys route to the overlay's buttons from the FIRST slide frame — the swap's feel half: a key
/// pressed the instant you enter stealth already drives the stealth page.
#[test]
fn bonus_bar_slides_up_with_sound_and_down_without() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.tick(10.0); // a nonzero clock epoch
let _ = s.take_sounds();
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"no form, no overlay"
);
// ── Enter cat form: offset 0→1, the app's edge (ui_action/feed.rs) fires the event ─────────
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"show"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar holds the OLD page under the rising overlay"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
73,
"the overlay paints the bonus page from the first slide frame"
);
assert!(s.take_sounds().is_empty(), "no sound until the bar lands");
// Keys route to the overlay immediately (ref ActionButton.lua:15-45's IsShown fork).
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(
action_ids(&mut s),
vec![73],
"a key pressed mid-slide already drives the bonus page"
);
// …and the same button with the SELF-CAST modifier. `SELFACTIONBUTTON1`-`12` (`ALT-1`…`ALT-=`)
// are `ActionButtonUp(id, 1)` and nothing else, so this is the whole of what those twelve
// bindings do that the plain twelve do not (1745).
s.run("ActionButtonDown(1) ActionButtonUp(1, 1)").unwrap();
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect::<Vec<_>>(),
vec![(73, false)],
"the BONUS branch of stock ActionButtonUp passes a literal 0 for onSelf (ActionButton.lua:38) \
— in a stance, ALT-1 drives the bonus page without self-cast; the reference's own rule, \
which 1745's expectation of (73, true) had smoothed over"
);
s.run("BonusActionBarFrame:Hide() ActionButtonDown(1) ActionButtonUp(1, 1)")
.unwrap();
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect::<Vec<_>>(),
vec![(1, true)],
"on the main-bar branch onSelf reaches UseAction's third argument (ActionButton.lua:51)"
);
s.run("BonusActionBarFrame:Show()").unwrap();
// The slide, frame by frame. Stock `BonusActionBar_OnUpdate` paints the position the timer
// has REACHED and then advances it (BonusActionBarFrame.lua:37-49), so the first OnUpdate
// after a Show paints the start (top = 0 over the bar's bottom edge at y=0), the second the
// half-risen replica (0.5 * 43), and the one that finds the timer past BONUSACTIONBAR_SLIDETIME
// lands it. Silent until then.
s.tick(0.075);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(top.abs() < 0.01, "first frame top = {top}, want 0");
assert!(s.take_sounds().is_empty());
s.tick(0.075);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 21.5).abs() < 0.6,
"half-slide top = {top}, want ~21.5"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1
);
assert!(s.take_sounds().is_empty());
// The landing edge: snap to 43 and THE sound. The main bar does NOT adopt the bonus page:
// a main-bar button has no `isBonus`, so `ActionButton_GetPagedID` keeps it on its page and the
// overlay IS the stance page (ActionButton.lua:447-456; 1897, adopted with 1938).
s.tick(0.01);
assert_eq!(
s.take_sounds(),
vec![benilla_ui::script::SoundRequest::KitName(
"igBonusBarOpen".into()
)]
);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!((top - 43.0).abs() < 0.01, "landed top = {top}, want 43");
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"none"
);
assert!(
s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"the overlay stays up while the form holds — the ref-visible state"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar keeps its page under the landed overlay"
);
s.tick(0.5);
assert!(s.take_sounds().is_empty(), "a landed bar never re-sounds");
// ── A direct form→form swap (stance dance, powershift): repaint, no slide, no sound ────────
s.set_bonus_bar_offset(3);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"none"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
97
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar stays on its page through a form swap"
);
s.tick(0.2);
assert!(
s.take_sounds().is_empty(),
"form→form never re-slides or re-sounds"
);
// ── Drop the form: the overlay descends carrying the OLD form's page, silently ─────────────
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"hide"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"the main bar returns to the page immediately — it is being revealed"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
97,
"the descending overlay carries the OLD form's page (lastBonusBar)"
);
// A key mid-descent still drives the old form's page — ref GetPagedID's lastBonusBar
// stand-in while the frame is still shown.
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(action_ids(&mut s), vec![97]);
// Paint-then-advance again: the frame that finds the timer past the slide time is the one
// that hides (BonusActionBarFrame.lua:51-66), so the descent takes one OnUpdate more than
// the time itself.
s.tick(0.2);
s.tick(0.01);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
"the descent ends hidden"
);
assert!(
s.take_sounds().is_empty(),
"the down-slide is silent — the ref plays only on open"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A form flip mid-slide turns the bar around from where it is (our progress fraction), rather
/// than the ref's timer arithmetic, which mirror-jumps the position — the mechanism, not the
/// quirk (1524).
#[test]
fn bonus_bar_turnaround_continues_from_position() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
s.tick(10.0);
let _ = s.take_sounds();
// Up to half height, then the form drops mid-slide.
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.tick(0.075);
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
assert_eq!(
s.eval::<String>("return BonusActionBarFrame.mode").unwrap(),
"hide"
);
// A third of the way back down from the turnaround point: 43 * (0.5 - 0.03/0.15).
// `HideBonusActionBar` keeps a running timer (it resets it only when `completed`,
// BonusActionBarFrame.lua:86-88), so the descent starts where the rise had got to: the rise's
// one OnUpdate advanced the timer to 0.075 (half), and the hide arm paints (1 − 0.075/0.15) · 43
// = 21.5 on its first frame, then 12.9 on the next — paint, then advance.
s.tick(0.03);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 21.5).abs() < 0.6,
"turnaround descends from 21.5, top = {top}"
);
s.tick(0.03);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 12.9).abs() < 0.6,
"next frame top = {top}, want ~12.9"
);
s.tick(0.2);
s.tick(0.01);
assert!(!s
.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap());
assert!(
s.take_sounds().is_empty(),
"an aborted rise never lands, so it never sounds"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The two page arrows must not sit on top of each other.**
///
/// They are 32x32 squares stacked only 20 px apart, so their raw frame rects overlap by 12 px —
/// and the hit-test walks the draw order in reverse, so the later-declared DOWN button owned that
/// band: the bottom third of the visible UP arrow paged the bar the wrong way. The reference's
/// `<HitRectInsets>` (±6 horizontal, ±7 vertical) shrink each square to the 20x18 arrow it
/// actually draws, which separates them — the insets are behaviour here, not decoration.
#[test]
fn the_page_arrows_do_not_steal_each_other_s_clicks() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
// 1875 that file is the reference's own, so a harness that drives the XP bar has to load it too
// or the tick raises on its first event.
// In manifest order: the fonts its check-box labels colour from, the panel templates those
// boxes inherit through, then the pane.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
super::test_ui::load_ui(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(&s, r"Interface\FrameXML\ReputationFrame.xml");
// The post-login state, which is what a player clicks into. Without it `ExhaustionTick_Update`
// never runs, and the rested marker — DIALOG strata, declared CENTER on the XP strip, which is
// exactly where the arrows are — sits unhidden over both and eats every click. That is the
// reference's own declaration (`hidden="false"`, ref-MainMenuBar.xml l.415), hidden at runtime
// when `GetXPExhaustion()` is nil, so firing the event is the harness's job, not a fix.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
let centre = |s: &UiScript, name: &str| {
s.eval::<(f64, f64)>(&format!(
"return ({name}:GetLeft() + {name}:GetRight()) / 2, \
({name}:GetBottom() + {name}:GetTop()) / 2"
))
.unwrap()
};
for name in ["ActionBarUpButton", "ActionBarDownButton"] {
let (x, y) = centre(&s, name);
assert_eq!(
s.hit_test_name(x as f32, y as f32).as_deref(),
Some(name),
"{name} must eat the click at its own centre"
);
}
// The contested band, measured rather than assumed: the two 32x32 squares are 20 px apart, so
// raw they share the 12 px above the UP button's bottom edge. 8 px up from that edge is inside
// the up arrow's own drawn art AND inside the down button's raw square — the exact pixel the
// player aims at and the exact pixel the later-declared down button used to win.
//
// With the ref's ±7 vertical insets the two hit rects become disjoint (up keeps its top 18 px,
// down its own), so this point resolves UP, which is what the arrow under the cursor says.
let (x, up_bottom) = s
.eval::<(f64, f64)>(
"return (ActionBarUpButton:GetLeft() + ActionBarUpButton:GetRight()) / 2, \
ActionBarUpButton:GetBottom()",
)
.unwrap();
assert_eq!(
s.hit_test_name(x as f32, up_bottom as f32 + 8.0).as_deref(),
Some("ActionBarUpButton"),
"the lower third of the visible UP arrow must page up, not down"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}