- a tooltip change costs ~1.3 ms — five whole-interface recomputes, each working as designed: the quad re-convert at 474 µs, the Lua tick at 287, the layout resolve at 200, bevy's `Assets<Mesh>` tax at ~270 and the mesh rebuild at 146 — pinned with new hover probes (a jitter dial, a `WOW_PROBE_HOVER_DUTY` duty cycle so run drift cancels, an `AssetChanged` tax meter), and the layout preamble's two whole-roster scans become lists maintained at the writer (52–83 µs → 1–11) - the splice compared this frame's entries against last frame's by index, so one inserted highlight shifted the 160 entries behind it and read as "everything changed"; it now aligns on `z` — an identity, not a position — and a `Text` entry that writes only quads became spliceable, so a tooltip change re-converts 22 entries instead of 520 (`convert` +439 µs → +107), with `WOW_UI_SPLICE_VERIFY` running the full conversion behind every spliced frame: 0 mismatches over 1,078 - a spell-less macro is a usable action button, not the 0.4 grey — the reference's usable compute has a dedicated leg answering usable for the bound-spell zero, cross-checked at the bytes in the reference's disassembly — and the button's Name line is written unconditionally from `GetActionText` as the reference does, so the macro's name sits under its icon and a `/cast` of a spell the character does not know now greys, which is also 1.12's behaviour (B340) - a capture bounds its run in wall-clock seconds, because a frame cap is no cap at the ~1 fps a starved `nextDrawable` leaves — 1800 frames is thirty minutes there — and the teardown backstop runs on every run as a liveness guarantee, carrying the exit code the run asked for; the Multisampling row reads Off / 2x / 4x, since "1x" contradicts the no-multisampling byte both of the reference's enumerators normalise to and the bit-depth prefix was identical on every row Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1669 lines
65 KiB
Rust
1669 lines
65 KiB
Rust
use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, UiScript};
|
||
|
||
/// 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);
|
||
for file in ["Cooldown.xml", "ActionBar.xml"] {
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"{file}: loader errors: {:?}",
|
||
report.errors
|
||
);
|
||
if file == "ActionBar.xml" {
|
||
assert_eq!(
|
||
report.frames, 62,
|
||
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \
|
||
+ BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223), + ReputationWatchBar with its status bar and its numerals overlay (3 — hidden, ref ReputationFrame.xml:869-994)"
|
||
);
|
||
}
|
||
}
|
||
|
||
// 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![]);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
|
||
// Button 1 paints action 73's icon seated in the art frame's first well: the 1024-wide bar
|
||
// centers at BOTTOM of the 1024-wide screen ⇒ bar/art-frame left edge = (1024-1024)/2 = 0.
|
||
// Button1 anchors the art frame's BOTTOMLEFT +(8,4), 36×36 ⇒ x[8,44] y[4,40]; the icon fills
|
||
// the button (owner-sized) ⇒ the same rect. The chain stride is 36 + 6 = 42, so button i's left
|
||
// edge is 8 + (i-1)*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("button 1 icon");
|
||
assert_eq!((r.left, r.bottom, r.right, r.top), (8.0, 4.0, 44.0, 40.0));
|
||
let r2 = icon("Interface\\Icons\\Ability_Rogue_Ambush").expect("button 2 icon");
|
||
assert_eq!(r2.left, 8.0 + 42.0); // button 2 left = 50
|
||
// Twelve quickslot rings (every button draws its NormalTexture), two icons only.
|
||
let rings = quads
|
||
.iter()
|
||
.filter(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||
if p.contains("UI-Quickslot2"))
|
||
})
|
||
.count();
|
||
assert_eq!(rings, 12);
|
||
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 on button 1 queues UseAction(73) — the stance page's id, not 1. Button 1's
|
||
// center is (8+18, 4+18) = (26, 22).
|
||
s.mouse_button(26.0, 22.0, "LeftButton", true);
|
||
s.mouse_button(26.0, 22.0, "LeftButton", false);
|
||
assert_eq!(s.take_action_uses(), 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 ActionButton2:GetButtonState()")
|
||
.unwrap(),
|
||
"PUSHED"
|
||
);
|
||
s.run("ActionButtonUp(2)").unwrap();
|
||
assert_eq!(s.take_action_uses(), 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![]);
|
||
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) {
|
||
for file in ["Cooldown.xml", "ActionBar.xml"] {
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(s, &doc, &|_| None);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"{file}: loader errors: {:?}",
|
||
report.errors
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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!(
|
||
s.take_action_uses(),
|
||
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("BenillaActionButton_OnDragStart(ActionButton1)")
|
||
.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("BenillaActionButton_OnReceiveDrag(ActionButton1)")
|
||
.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("BenillaActionButton_OnReceiveDrag(ActionButton1)")
|
||
.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());
|
||
}
|
||
|
||
#[test]
|
||
fn shipped_bag_frame_drives_end_to_end() {
|
||
use benilla_ui::script::{ContainerSlot, ContainerState};
|
||
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The bag toggle now seats on the main action bar (MainMenuBarArtFrame, ActionBar.xml);
|
||
// load the bar FIRST so the toggle's cross-file relativeTo resolves to the real art frame at
|
||
// SetPoint time. The app loads ActionBar before BagFrame (load_default_ui order); without it the
|
||
// anchor would silently fall back to the screen root (it only lands right here by the 1024-wide
|
||
// coincidence — screen BOTTOMRIGHT == the full-width bar's art-frame BOTTOMRIGHT).
|
||
for file in ["Cooldown.xml", "ActionBar.xml"] {
|
||
let abtext = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let abdoc = benilla_ui::framexml::parse(&abtext).unwrap();
|
||
benilla_ui::loader::load(&s, &abdoc, &|_| None);
|
||
}
|
||
// UiPanels.xml before the bag file: `ToggleBackpack` calls the GLOBAL `CloseAllBags`, which
|
||
// lives there (homed in the panel glue rather than the bag's own file, deliberately — see its
|
||
// comment). Production loads both; this test was under-loading relative to it, and started
|
||
// failing the moment the toggle went through the reference's names instead of a local body.
|
||
// Loading it is the honest fix: the dependency is real, not an artifact.
|
||
{
|
||
let panels = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/UiPanels.xml"),
|
||
)
|
||
.unwrap();
|
||
let pdoc = benilla_ui::framexml::parse(&panels).unwrap();
|
||
let _ = benilla_ui::loader::load(&s, &pdoc, &|_| None);
|
||
}
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/BagFrame.xml"),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"loader errors: {:?}",
|
||
report.errors
|
||
);
|
||
assert_eq!(
|
||
report.frames, 259,
|
||
"backpack (window + 16 slots × 2 [button + Cooldown child] + 3 money coins \
|
||
+ close = 37) + 4 equipped-bag windows (window + 20 slots × 2 + close = 42 each = 168) \
|
||
+ the KEYRING window (another 42 — the same template, decision 0765) \
|
||
+ the bag-bar toggle (1) + its 4 bar slots (4) + the keyring button (1) \
|
||
— 0216 slice 2 + the slot Cooldown children \
|
||
+ one $parentItemAnim card per bag-bar button (6 — decision 0887)"
|
||
);
|
||
// The re-skinned bag purse reuses BenillaMoney_Set/_Clear (they live in MerchantFrame.xml); load
|
||
// it too so the shared helper is defined when BenillaBagFrame_Update runs (the same reuse the loot
|
||
// window makes — at runtime every FrameXML file is loaded before any event fires).
|
||
let mtext = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/MerchantFrame.xml"),
|
||
)
|
||
.unwrap();
|
||
let mdoc = benilla_ui::framexml::parse(&mtext).unwrap();
|
||
benilla_ui::loader::load(&s, &mdoc, &|_| None);
|
||
// The slot OnClick hides the hover tooltip on pickup; load GameTooltip.xml so GameTooltip
|
||
// exists (the runtime always loads it before the bag frame — ui_script::load_default_ui).
|
||
let ttext = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/GameTooltip.xml"),
|
||
)
|
||
.unwrap();
|
||
let tdoc = benilla_ui::framexml::parse(&ttext).unwrap();
|
||
benilla_ui::loader::load(&s, &tdoc, &|_| None);
|
||
|
||
// The app's feed: a backpack with Tough Jerky ×5 in slot 1.
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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());
|
||
|
||
// Hidden by default: 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()), "bag window starts hidden");
|
||
|
||
// Click the toggle → the window opens, slot 1 paints the jerky in the TOP-LEFT well. The toggle
|
||
// now 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).
|
||
// The bag frame is unchanged: 192×240 at BOTTOMRIGHT (0,70) ⇒ x[832,1024] y[70,310]; the real
|
||
// backpack chain (ContainerFrame.lua l.425-444) seats the top-left button (game slot 1) at
|
||
// BOTTOMRIGHT frame +(-138,153), a 37×37 icon ⇒ x[849,886] y[223,260].
|
||
s.mouse_button(999.0, 20.0, "LeftButton", true);
|
||
s.mouse_button(999.0, 20.0, "LeftButton", false);
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
let r = 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");
|
||
assert_eq!(
|
||
(r.left, r.bottom, r.right, r.top),
|
||
(849.0, 223.0, 886.0, 260.0)
|
||
);
|
||
// 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.
|
||
// The top-left well center is (867,241).
|
||
s.mouse_button(867.0, 241.0, "LeftButton", true);
|
||
s.mouse_button(867.0, 241.0, "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 cancels the pickup (client-side; nothing sent).
|
||
s.mouse_button(867.0, 241.0, "RightButton", true);
|
||
s.mouse_button(867.0, 241.0, "RightButton", false);
|
||
assert!(
|
||
s.cursor_item().is_none(),
|
||
"right-click cancelled the held cursor"
|
||
);
|
||
assert!(s.take_container_uses().is_empty(), "cancel sends nothing");
|
||
|
||
// RIGHT-click with an empty cursor uses slot 1 (UseContainerItem → the app's use/equip fork).
|
||
s.mouse_button(867.0, 241.0, "RightButton", true);
|
||
s.mouse_button(867.0, 241.0, "RightButton", false);
|
||
assert_eq!(s.take_container_uses(), vec![(0, 1)]);
|
||
|
||
// Toggle again → hidden.
|
||
s.mouse_button(999.0, 20.0, "LeftButton", true);
|
||
s.mouse_button(999.0, 20.0, "LeftButton", false);
|
||
s.resolve();
|
||
assert!(!jerky_visible(&s.extract()), "toggle closes the window");
|
||
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.
|
||
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" 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 [
|
||
"UIParent.xml",
|
||
"Cooldown.xml",
|
||
"ActionBar.xml",
|
||
"MultiBars.xml",
|
||
] {
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
|
||
}
|
||
|
||
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"
|
||
);
|
||
|
||
// A bonus page outranks the paged one entirely: with an offset up, the page is ignored.
|
||
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
|
||
s.set_bonus_bar_offset(1);
|
||
assert_eq!(
|
||
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
|
||
.unwrap(),
|
||
73,
|
||
"bonus offset 1 is action 73, whatever page the main bar is on"
|
||
);
|
||
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);
|
||
for file in ["Cooldown.xml", "ActionBar.xml"] {
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
|
||
}
|
||
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!(
|
||
s.take_action_uses(),
|
||
vec![73],
|
||
"a key pressed mid-slide already drives the bonus page"
|
||
);
|
||
|
||
// Half the slide: the replica is half-risen (top = 0.5 * 43 over the bar's bottom edge at
|
||
// y=0), the paint suppression still holds, still silent.
|
||
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, THE sound, and the main bar adopts the bonus page.
|
||
s.tick(0.08);
|
||
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(),
|
||
73,
|
||
"the main bar adopts the bonus page at landing"
|
||
);
|
||
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(),
|
||
97
|
||
);
|
||
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!(s.take_action_uses(), vec![97]);
|
||
s.tick(0.2);
|
||
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);
|
||
for file in ["Cooldown.xml", "ActionBar.xml"] {
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
|
||
}
|
||
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).
|
||
s.tick(0.03);
|
||
let top = s
|
||
.eval::<f64>("return BonusActionBarFrame:GetTop()")
|
||
.unwrap();
|
||
assert!(
|
||
(top - 12.9).abs() < 0.6,
|
||
"turnaround descends from 21.5, top = {top}"
|
||
);
|
||
s.tick(0.2);
|
||
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);
|
||
for file in ["Cooldown.xml", "ActionBar.xml"] {
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
|
||
}
|
||
// 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());
|
||
}
|