Files
benilla-octo/crates/benilla-app/src/ui_script/bag_tests.rs
T
SamandClaude Fable 5 44ff435304 the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

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

1706 lines
63 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::{
ContainerMove, ContainerSlot, ContainerState, ItemTemplateView, QuadContent, SoundRequest,
UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error and returning the
/// frame count (the panel/loot tests' loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) -> usize {
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
);
report.frames
}
/// The equipped-bag BAR icons must draw ABOVE the action-bar art, not under it. The bar buttons are
/// relocated onto `MainMenuBarArtFrame` but are top-level frames, so they default to a lower
/// frame level than the bar's own child-hierarchy art (the ExpBar dwarf notches + metal/well art) —
/// which would then paint over the centered icons, leaving the ring but no bag icon. The OnLoad
/// `BenillaActionBarArt_SeatAbove` seats them one level above the art (the action buttons' level).
/// This locks that: no bag-slot icon quad may be covered by a higher-z art texture at its center.
#[test]
fn bag_bar_icons_draw_above_the_action_bar_art() {
let mut s = UiScript::new().unwrap();
// The screen the client defaults to; the action bar centers here and the bag bar lands over its
// right end, where the dwarf-notch strip overlaps — the exact geometry that reproduced the bug.
s.set_screen_size(1600.0, 900.0);
// ActionBar.xml carries both the anchor target (MainMenuBarArtFrame) and the occluder (the
// ExpBar dwarf art); MerchantFrame.xml is BagFrame's documented purse-helper dep.
for file in [
"Fonts.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"BagFrame.xml",
] {
load_xml(&s, file);
}
s.resolve();
let quads = s.extract();
// A bag-slot icon is occluded when any HIGHER-z textured quad (other than the button's own ring)
// covers its center — i.e. the bar art draws on top of it.
let occluded = quads
.iter()
.filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-PaperDoll-Slot-Bag")))
.filter(|icon| {
let r = icon.rect.expect("a resolved icon rect");
let (cx, cy) = ((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
quads.iter().any(|q| {
q.z > icon.z
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if !p.contains("UI-Quickslot2"))
&& q.rect.is_some_and(|qr| qr.left <= cx && cx <= qr.right && qr.bottom <= cy && cy <= qr.top)
})
})
.count();
assert_eq!(
occluded, 0,
"a bag-slot icon is painted over by the action-bar art (the seat-above-the-bar fix regressed)"
);
}
/// The backpack open/close kits (ContainerFrame.lua ContainerFrame_OnShow/OnHide, l.140 / l.120):
/// showing the window queues igBackPackOpen, hiding it queues igBackPackClose — and nothing queues
/// at load (the frame is authored hidden="true", so it never transitions on startup). Driven through
/// `BenillaBagToggle_OnClick`, the exact path both the bag button and the 'B' binding use.
#[test]
fn backpack_toggle_plays_open_and_close_kits() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// BenillaMoney_Set (the bag's purse helper) lives in MerchantFrame.xml — the bag's documented
// isolation dep; Fonts.xml first so both files' `inherits=` FontStrings resolve.
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
// Hidden at load: no sound queued, the frame starts hidden.
assert!(
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
);
assert!(!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
// Toggle open → OnShow → igBackPackOpen.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igBackPackOpen".into())],
"opening the backpack plays igBackPackOpen"
);
// Toggle closed → OnHide → igBackPackClose.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igBackPackClose".into())],
"closing the backpack plays igBackPackClose"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The 'B' binding / backpack button opens or closes EVERY bag at once (ref ToggleBackpack), not
/// just the backpack: an equipped bag (here bag 2) opens alongside it, while empty bag slots (1,3,4
/// — no container, GetContainerNumSlots == 0) stay shut. A second toggle closes them all. And ESC's
/// CloseAllWindows must sweep all of them, not only the backpack.
#[test]
fn b_toggles_all_bags_open_and_closed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
// Backpack (16) + one equipped bag in slot 2 (6). Bags 1/3/4 are left unset → 0 slots.
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
s.set_container(
2,
Some(ContainerState {
name: Some("Small Pouch".into()),
num_slots: 6,
slots: std::collections::HashMap::new(),
}),
);
let shown =
|s: &mut UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
// Toggle open: backpack + bag 2 open; the empty slots do not.
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(shown(&mut s, "BenillaBagFrame"), "backpack opens");
assert!(
shown(&mut s, "BenillaBagFrame2"),
"the equipped bag opens too"
);
assert!(
!shown(&mut s, "BenillaBagFrame1")
&& !shown(&mut s, "BenillaBagFrame3")
&& !shown(&mut s, "BenillaBagFrame4"),
"empty bag slots (no container) have no window to show"
);
// Toggle again: every open bag closes.
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(
!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"),
"the second toggle closes them all"
);
// ESC's CloseAllWindows sweeps every open bag, not just the backpack.
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(
shown(&mut s, "BenillaBagFrame2"),
"reopened for the ESC check"
);
s.run("CloseAllWindows()").unwrap();
assert!(
!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"),
"CloseAllWindows hides every bag window"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// An open bag LIGHTS its bar button (the CheckButton ring — ref ContainerFrame_OnShow/OnHide
/// SetChecked, l.124-131/84-95), and any close clears it: the windows are the source of truth,
/// so the ring tracks opens from every path (the all-toggle, a bar click, ESC's sweep).
#[test]
fn bag_bar_buttons_light_while_their_bag_is_open() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
s.set_container(
2,
Some(ContainerState {
name: Some("Small Pouch".into()),
num_slots: 6,
slots: std::collections::HashMap::new(),
}),
);
let checked = |s: &mut UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}:GetChecked() and true or false"))
.unwrap()
};
// Open-all: the backpack button and the equipped bag's slot light; the empty slots don't.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(checked(&mut s, "BenillaBagToggle"), "backpack ring lights");
assert!(checked(&mut s, "BenillaBagBarSlot2"), "bag 2's ring lights");
assert!(
!checked(&mut s, "BenillaBagBarSlot1"),
"empty slot stays dark"
);
// ...and the rings actually EMIT (extract-level): exactly two CheckButtonHilight quads, the
// toggle's owner-sized on the 37px button at the art frame's BOTTOMRIGHT −6,2 (art right
// edge = 1024 at this screen ⇒ x[981,1018] y[2,39]).
s.resolve();
let rings: Vec<_> = s
.extract()
.into_iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("CheckButtonHilight"))
})
.collect();
assert_eq!(rings.len(), 2, "toggle + bag 2 rings emit, nothing else");
let toggle_ring = rings
.iter()
.find_map(|q| q.rect.filter(|r| r.left == 981.0))
.expect("the toggle's ring at the art frame's corner");
assert_eq!(
(
toggle_ring.left,
toggle_ring.bottom,
toggle_ring.right,
toggle_ring.top
),
(981.0, 2.0, 1018.0, 39.0)
);
// Closing ONE window (its close button / any Hide path) clears just its ring.
s.run("BenillaBagFrame2:Hide()").unwrap();
assert!(
!checked(&mut s, "BenillaBagBarSlot2"),
"closing bag 2 clears its ring"
);
assert!(
checked(&mut s, "BenillaBagToggle"),
"the backpack ring stays"
);
// A bar-slot click reopens bag 2 and relights it (the click auto-toggle + the ref's
// re-derive tail agree here). Driven as a RIGHT-click through the real input path: the ref's
// BagSlotButtonTemplate inherits PaperDollItemSlotButtonTemplate, whose OnLoad registers
// ("LeftButtonUp", "RightButtonUp") — PaperDollFrame.lua:86 — and BagSlotButton_OnClick reads
// no button, so either one opens the bag. Ours registered LeftButtonUp only until 0908.
let (cx, cy): (f64, f64) = s
.eval(
"return (BenillaBagBarSlot2:GetLeft() + BenillaBagBarSlot2:GetRight()) / 2, \
(BenillaBagBarSlot2:GetTop() + BenillaBagBarSlot2:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
assert!(
checked(&mut s, "BenillaBagBarSlot2"),
"a RIGHT-click on the bar slot reopens bag 2, exactly as a left one does"
);
// ESC's sweep closes everything → every ring dark.
s.run("CloseAllWindows()").unwrap();
assert!(
!checked(&mut s, "BenillaBagToggle") && !checked(&mut s, "BenillaBagBarSlot2"),
"close-all clears every ring"
);
let _ = s.take_sounds();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A slot on the RIGHT half of the screen hangs its tooltip LEFT — the ref's own screen-edge
/// answer (ContainerFrameItemButton_OnEnter, ContainerFrame.lua:602-612 side-pick), which is what
/// keeps a bag tooltip from running off the right edge (the bag lives at the bottom-right).
#[test]
fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_ThrowingKnife_02".into()),
count: 200,
quality: Some(1),
item_id: 2947,
link: Some("|cffffffff|Hitem:2947|h[Small Throwing Knife]|h|r".into()),
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.run("BenillaBagToggle_OnClick()").unwrap();
s.take_sounds();
s.resolve();
// The engine speaks 1.12: GetScreenWidth serves the host-set root width.
assert_eq!(s.eval::<f64>("return GetScreenWidth()").unwrap(), 1024.0);
// The bag window anchors bottom-right, so every slot button is in the right half. The bag
// numbers its buttons visually (reversed from container slots) — find the button showing
// container slot 1, where the fixture item lives.
s.run(
"for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i) \
if b and b.slot == 1 then BENILLA_TEST_BTN = b end end",
)
.unwrap();
let ok: bool = s
.eval("return BENILLA_TEST_BTN:GetRight() >= GetScreenWidth() / 2")
.unwrap();
assert!(ok, "fixture: the slot must sit in the right half");
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_BTN)").unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
s.resolve();
// ANCHOR_LEFT seats the tooltip's BOTTOMRIGHT on the slot's TOPLEFT: the whole tooltip stays
// left of the slot, i.e. on-screen — never past the right edge.
let ok: bool = s
.eval(
"return GameTooltip:GetRight() <= BENILLA_TEST_BTN:GetLeft() \
and GameTooltip:GetRight() <= GetScreenWidth()",
)
.unwrap();
assert!(ok, "tooltip hangs LEFT of a right-half slot");
}
/// A tooltip opened while the item's template is still in flight repaints itself the moment the
/// stats land — no re-hover. The refresh loop is the ref's own (ContainerFrameItemButton_OnUpdate,
/// ContainerFrame.lua:645-660: re-run OnEnter every frame while `GameTooltip:IsOwned(this)`), and
/// hiding the tooltip drops ownership so the loop can never resurrect it.
#[test]
fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
count: 1,
quality: Some(1),
item_id: 25,
link: Some("|cffffffff|Hitem:25|h[Worn Shortsword]|h|r".into()),
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.run("BenillaBagToggle_OnClick()").unwrap();
s.resolve();
s.run(
"for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i) \
if b and b.slot == 1 then BENILLA_TEST_BTN = b end end",
)
.unwrap();
// Hover with the stats store empty: the fallback one-line tooltip, and the miss recorded.
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_BTN)").unwrap();
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
1,
"in-flight template: the name-only fallback line"
);
assert_eq!(s.take_item_stat_asks(), vec![25], "the miss asks the app");
// The template lands (the app's arrival-driven push) → the very next frame's OnUpdate
// re-enter repaints the OPEN tooltip with the full stat head.
s.set_item_template(
25,
ItemTemplateView {
name: "Worn Shortsword".into(),
quality: 1,
inventory_type: 21,
class: 2,
subclass: 7,
damages: vec![(1.0, 3.0, 0)],
delay_ms: 1900,
..Default::default()
},
);
s.tick(0.016);
assert!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap() > 1,
"the stats landing repainted the open tooltip"
);
let has_damage: bool = s
.eval(
"for i = 1, GameTooltip:NumLines() do \
local fs = getglobal(\"GameTooltipTextLeft\" .. i) \
if fs and string.find(fs:GetText() or \"\", \"Damage\") then return true end \
end return false",
)
.unwrap();
assert!(
has_damage,
"the repaint carries the stat head's damage line"
);
// Leaving drops ownership: the loop must not resurrect the hidden tooltip.
s.run("BenillaBagSlot_OnLeave(BENILLA_TEST_BTN)").unwrap();
s.tick(0.016);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"hide drops ownership; OnUpdate never resurrects"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// At a vendor, a bag hover shows the engine-truth sell-price money row (SellPrice × stack, wow-re
/// tooltip-money.md 0x52b650@0x52e376) — or the ITEM_UNSELLABLE "No sell price" line — and arms
/// the pouch cursor (ShowContainerSellCursor → Buy over a Point base, cursor-system.md §7);
/// leaving resets it.
#[test]
fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
use benilla_ui::script::{MerchantState, ScriptValue, UiCursorMode};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Pelt_Wolf_01".into()),
count: 4,
quality: Some(1),
item_id: 2318,
link: Some("|cffffffff|Hitem:2318|h[Light Leather]|h|r".into()),
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
slots.insert(
2,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Key_03".into()),
count: 1,
quality: Some(1),
item_id: 9999,
link: Some("|cffffffff|Hitem:9999|h[Shadowforge Key]|h|r".into()),
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,
}),
);
// Sellable stack: 13c each × 4 = 52c. Unsellable: SellPrice 0.
s.set_item_template(
2318,
ItemTemplateView {
name: "Light Leather".into(),
quality: 1,
sell_price: 13,
..Default::default()
},
);
s.set_item_template(
9999,
ItemTemplateView {
name: "Shadowforge Key".into(),
quality: 1,
sell_price: 0,
..Default::default()
},
);
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![ScriptValue::Str("Vendor".into())]);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.take_sounds();
s.resolve();
s.run(
"BENILLA_TEST_B1, BENILLA_TEST_B2 = nil, nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_B1 = b end\n\
if b and b.slot == 2 then BENILLA_TEST_B2 = b end\n\
end",
)
.unwrap();
// The sellable stack: a money row (52c → the copper coin slot shows "52") + the pouch armed.
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_B1)").unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert!(s
.eval::<bool>(
"return GameTooltipMoneyCoin1:IsShown() \
and GameTooltipMoneyCoin1Num:GetText() == '52'",
)
.unwrap());
assert_eq!(
s.ui_cursor(),
Some(UiCursorMode::Buy),
"the pouch cursor is armed over a sellable item"
);
// Leaving resets the cursor and the money row dies with the tooltip.
s.run("BenillaBagSlot_OnLeave(BENILLA_TEST_B1)").unwrap();
assert_eq!(s.ui_cursor(), None, "ResetCursor on leave");
assert!(s
.eval::<bool>("return not GameTooltipMoneyCoin1:IsShown()")
.unwrap());
// The unsellable item: the ITEM_UNSELLABLE line, no coins.
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_B2)").unwrap();
let has_line: bool = s
.eval(
"for i = 1, GameTooltip:NumLines() do \
if (getglobal('GameTooltipTextLeft' .. i):GetText() or '') == 'No sell price' \
then return true end \
end return false",
)
.unwrap();
assert!(has_line, "SellPrice 0 shows the ITEM_UNSELLABLE line");
assert!(s
.eval::<bool>("return not GameTooltipMoneyCoin1:IsShown()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A readable bag item (a mail permanent copy — the instance carries item text) shows the
/// Inspect magnifier on hover (ref ContainerFrameItemButton_OnEnter, ContainerFrame.lua l.638:
/// `this.readable → ShowInspectCursor()`); a plain item leaves the base cursor; leaving resets.
#[test]
fn readable_letter_hover_shows_the_inspect_magnifier() {
use benilla_ui::script::UiCursorMode;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set — the bag window's money strip
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
texture: Some("Interface\\Icons\\INV_Misc_Note_01".into()),
count: 1,
quality: Some(1),
item_id: 8383,
link: Some("|cffffffff|Hitem:8383|h[Plain Letter]|h|r".into()),
readable: true,
..Default::default()
},
);
slots.insert(
2,
ContainerSlot {
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.take_sounds();
s.resolve();
s.run(
"BENILLA_TEST_B1, BENILLA_TEST_B2 = nil, nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_B1 = b end\n\
if b and b.slot == 2 then BENILLA_TEST_B2 = b end\n\
end",
)
.unwrap();
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_B1)").unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert_eq!(
s.ui_cursor(),
Some(UiCursorMode::Inspect),
"the magnifier over the letter"
);
s.run("BenillaBagSlot_OnLeave(BENILLA_TEST_B1)").unwrap();
assert_eq!(s.ui_cursor(), None, "ResetCursor on leave");
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_B2)").unwrap();
assert_eq!(s.ui_cursor(), None, "no magnifier over the jerky");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The drag trio (decision 0216 §3): a real press-drag-release across two slot buttons routes
/// through the SAME `BenillaBagSlot_OnClick("LeftButton")` path a two-click pickup/place does —
/// unlike every other bag test here, which calls the Lua click handler directly, this one drives
/// actual `mouse_button`/`mouse_move` so the `RegisterForDrag`/`OnDragStart`/`OnReceiveDrag` XML
/// wiring itself is under test, not just the handler body.
#[test]
fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml"); // BenillaBagSlot_OnClick's :Hide() dep
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(3),
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
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.run("BenillaBagToggle_OnClick()").unwrap();
s.take_sounds();
s.resolve();
s.run(
"BENILLA_TEST_B1, BENILLA_TEST_B5 = nil, nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_B1 = b end\n\
if b and b.slot == 5 then BENILLA_TEST_B5 = b end\n\
end",
)
.unwrap();
let (x1, y1): (f64, f64) = s
.eval(
"return (BENILLA_TEST_B1:GetLeft() + BENILLA_TEST_B1:GetRight()) / 2, \
(BENILLA_TEST_B1:GetTop() + BENILLA_TEST_B1:GetBottom()) / 2",
)
.unwrap();
let (x5, y5): (f64, f64) = s
.eval(
"return (BENILLA_TEST_B5:GetLeft() + BENILLA_TEST_B5:GetRight()) / 2, \
(BENILLA_TEST_B5:GetTop() + BENILLA_TEST_B5:GetBottom()) / 2",
)
.unwrap();
// Press on slot 1 (picks up), drag past the threshold onto slot 5, release there.
s.mouse_button(x1 as f32, y1 as f32, "LeftButton", true);
s.mouse_move(x5 as f32, y5 as f32);
let consumed = s.mouse_button(x5 as f32, y5 as f32, "LeftButton", false);
assert!(consumed, "the drag release lands on a mouse-enabled frame");
assert!(s.cursor_item().is_none(), "placed onto the empty slot 5");
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 5,
count: None,
}]
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A second bag window (decision 0216 slice 2): bag 1's snapshot feeds through the SAME
/// container/BenillaBagWindow_Update plumbing the backpack uses, opened via the bag-bar path
/// (`BenillaBagBarSlot_OnClick`, not the backpack toggle) and painting its own slot 1 icon.
#[test]
fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
count: 1,
quality: Some(2),
item_id: 200,
link: Some("|cffffffff|Hitem:200|h[Shiny Gem]|h|r".into()),
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
s.set_container(
1,
Some(ContainerState {
name: Some("Small Pouch".into()),
num_slots: 6,
slots,
}),
);
assert!(
!s.eval::<bool>("return BenillaBagFrame1:IsShown()").unwrap(),
"hidden by default"
);
// BenillaBagBarSlot1 == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
s.run("BenillaBagBarSlot_OnClick(BenillaBagBarSlot1)")
.unwrap();
let _ = s.take_sounds();
assert!(
s.eval::<bool>("return BenillaBagFrame1:IsShown()").unwrap(),
"the bag-bar click opened bag 1's window"
);
assert_eq!(
s.eval::<String>("return BenillaBagFrame1Name:GetText()")
.unwrap(),
"Small Pouch",
"the title reads the live GetBagName"
);
s.resolve();
let painted = s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Misc_Gem_01"))
});
assert!(painted, "bag 1's slot 1 icon is on screen");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// An equipped bag's window SNUG-FITS its row count (BenillaBagWindow_FitBackground) instead of the
/// backpack's fixed 5-row/260 slab. The heights are the real client's, from
/// `ContainerFrame_GenerateFrame` in the shipped `Interface\FrameXML\ContainerFrame.lua`:
/// `height = topH + ((rows-1)*41 - 9) + 10`, with `topH` = 72 for a size%4==2 bag (its own plus-two
/// top band), 86 for a single full row, else 94. The `-9` is the reference's `firstRowPixelOffset`
/// and the `10` its fixed bottom rim; both are load-bearing — dropping the offset slides the rim a
/// row-fraction low and bleeds the next row's wells in above it. This locks that arithmetic AND the
/// core fix: a small bag is far shorter than the old fixed height.
#[test]
fn equipped_bag_window_snug_fits_its_row_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
// (bag id, slot count, expected window height). 6 → 2 rows plus-two-top (72+32+10); 8 → 2 rows
// full-top (94+32+10); 10 → 3 rows plus-two-top (72+73+10); 20 → 5 rows full-top (94+155+10).
// The last two exercise the no-middle fork: 4 → one full row (86+0+10); 2 → one plus-two row
// (72+0+10). Bag 1 stays at 6 so the h6 assertion below still reads the pouch.
for (bag, size, expected) in [
(1, 6, 114.0),
(2, 8, 136.0),
(3, 10, 155.0),
(4, 20, 259.0),
(2, 4, 96.0),
(3, 2, 82.0),
] {
s.set_container(
bag,
Some(ContainerState {
name: Some(format!("Bag {bag}")),
num_slots: size,
slots: std::collections::HashMap::new(),
}),
);
let frame = format!("BenillaBagFrame{bag}");
s.run(&format!("BenillaBagWindow_Update({frame})")).unwrap();
let h = s
.eval::<f64>(&format!("return {frame}:GetHeight()"))
.unwrap();
assert!(
(h - expected).abs() < 0.5,
"bag {bag} ({size} slots): height {h}, expected {expected}"
);
}
// The core regression: a 6-slot bag is much shorter than the old fixed 260-tall slab.
let h6 = s
.eval::<f64>("return BenillaBagFrame1:GetHeight()")
.unwrap();
assert!(
h6 < 200.0,
"a 6-slot bag must not fill the old 260 slab, got {h6}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A fixture backpack with a 5-stack of Tough Jerky in slot 1, the bag opened — shared setup for
/// the shift-click/split tests below. Returns the slot-1 button's screen center.
fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
load_xml(s, "Fonts.xml");
load_xml(s, "UiPanels.xml");
load_xml(s, "MerchantFrame.xml");
load_xml(s, "GameTooltip.xml");
load_xml(s, "Cooldown.xml");
load_xml(s, "BagFrame.xml");
load_xml(s, "StackSplit.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
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.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
s.resolve();
s.run(
"BENILLA_TEST_BTN = nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_BTN = b end\n\
end",
)
.unwrap();
s.eval(
"return (BENILLA_TEST_BTN:GetLeft() + BENILLA_TEST_BTN:GetRight()) / 2, \
(BENILLA_TEST_BTN:GetTop() + BENILLA_TEST_BTN:GetBottom()) / 2",
)
.unwrap()
}
/// The stack-split trigger — SHIFT + left-click on an unlocked stack of ≥2, the reference fork
/// verbatim (ContainerFrame.lua:567-577), driven through the `set_modifiers` mirror the cursor
/// arc landed. Nothing is picked up: the spinner opens against the still-seated stack.
#[test]
fn shift_click_on_a_stack_opens_the_split_frame() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let (x, y) = open_backpack_with_a_five_stack(&mut s);
assert!(!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"shift-click opened the split frame"
);
assert!(
s.cursor_item().is_none(),
"the shift fork never picks the stack up"
);
assert_eq!(s.eval::<i64>("return StackSplitFrame.maxStack").unwrap(), 5);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Okay in the split spinner only picks the split carry up (ref/cursor.rs `SplitContainerItem` —
/// a pickup, not a self-contained move); a SUBSEQUENT placement is what actually queues the
/// `ContainerMove` with `count: Some(n)`, drained the same way any other container move is.
#[test]
fn split_okay_then_a_placement_queues_the_split_move() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let (x, y) = open_backpack_with_a_five_stack(&mut s);
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
// Bump the spinner from 1 to 3, then Okay — the carry lands on the cursor.
s.run("BenillaStackSplitRight_Click()").unwrap();
s.run("BenillaStackSplitRight_Click()").unwrap();
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 3);
s.run("BenillaStackSplitOkay_Click()").unwrap();
assert!(
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"Okay hides the spinner"
);
let held = s.cursor_item().expect("Okay picked up the split carry");
assert_eq!((held.bag, held.slot, held.count), (0, 1, Some(3)));
assert!(
s.take_container_moves().is_empty(),
"no move yet — only a pickup"
);
// Place the carry on slot 5 (empty) — NOW the move queues, carrying the split count.
s.run(
"BENILLA_TEST_B5 = nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 5 then BENILLA_TEST_B5 = b end\n\
end",
)
.unwrap();
s.run("BenillaBagSlot_OnClick(BENILLA_TEST_B5, \"LeftButton\")")
.unwrap();
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 5,
count: Some(3),
}]
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A plain click hides any open split frame (ref ContainerFrame.lua:581) — even a click on an
/// unrelated, empty slot.
#[test]
fn a_plain_click_hides_an_open_split_frame() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let (x, y) = open_backpack_with_a_five_stack(&mut s);
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
s.run(
"BENILLA_TEST_B9 = nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 9 then BENILLA_TEST_B9 = b end\n\
end",
)
.unwrap();
s.run("BenillaBagSlot_OnClick(BENILLA_TEST_B9, \"LeftButton\")")
.unwrap();
assert!(
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"the plain click on an unrelated slot hid the spinner"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Bag-slot item cooldowns through the shipped XML (decision 0263's deferral): a potion mid-
/// cooldown pushes its triple with the container snapshot; opening the bag runs the ref's
/// occupied-slot fork (`BenillaBagSlot_UpdateCooldown` → `GetContainerItemCooldown` →
/// `CooldownFrame_SetTimer`) and the slot grows a live sweep; a `BAG_UPDATE_COOLDOWN` refresh
/// with the cooldown gone hides it again.
#[test]
fn bag_slot_cooldown_sweeps_through_the_xml() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml"); // the shared CooldownFrame_SetTimer
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
s.tick(100.0); // a nonzero clock epoch, like the engine cooldown tests
let potion = |cooldown| ContainerSlot {
durability: None,
texture: Some("Interface\\Icons\\INV_Potion_49".into()),
count: 3,
quality: Some(1),
item_id: 118,
cooldown,
..Default::default()
};
let backpack = |cooldown| {
let mut slots = std::collections::HashMap::new();
slots.insert(1, potion(cooldown));
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
};
// 12 s remain of the potion category's 60 s: started at GetTime 52 (absolute-start triple).
s.set_container(0, Some(backpack(Some((52_000, 60_000, true)))));
s.run("BenillaOpenAllBags()").unwrap();
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(0)]);
s.resolve();
let sweep = |s: &UiScript| {
s.extract().iter().find_map(|q| match q.content {
QuadContent::Cooldown { fraction, .. } => Some(fraction),
_ => None,
})
};
let fraction = sweep(&s).expect("the bag slot sweeps");
assert!(
(fraction - 0.8).abs() < 1e-3,
"48 of 60 s elapsed: fraction {fraction}"
);
// The cooldown clears (a CLEAR_COOLDOWN, or it simply ran out before the re-push): the
// refresh event re-reads the now-cold triple and hides the widget.
s.set_container(0, Some(backpack(None)));
s.fire_event("BAG_UPDATE_COOLDOWN", vec![]);
assert_eq!(sweep(&s), None, "cold again after the refresh");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bar's five bag buttons had NO hover at all — the director's "the bags are missing their
/// simple tooltips". These are the ref's plain `SetText` plates (ref-MainMenuBarBagButtons.xml
/// l.91-99 for the backpack, ref-MainMenuBarBagButtons.lua l.86-96 for the four slots), NOT the
/// two-line newbie kind the micro buttons next to them use — so what's pinned here is the label,
/// the empty-slot fallback, and that they seat BESIDE the button rather than at the screen corner.
#[test]
fn the_bar_bag_buttons_name_themselves_on_hover() {
let mut s = UiScript::new().unwrap();
// The suffix reads GetBindingKey live since 0997 — register the real command set the way
// the app's seed does, so the pin below is OPENALLBAGS's actual default.
s.register_bindings(&crate::bindings::registry_commands());
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"BagFrame.xml",
] {
load_xml(&s, file);
}
s.resolve();
// The backpack: its label plus OPENALLBAGS's live key (default B; 0997's divergence note).
s.run("BenillaBagToggle_OnEnter(BenillaBagToggle)").unwrap();
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert!(
line.starts_with("Backpack") && line.contains("(B)"),
"the backpack names itself and its key: {line:?}"
);
// Beside the button, not the default corner — the ref's ANCHOR_LEFT.
assert!(
s.eval::<bool>("return GameTooltip.default == nil").unwrap(),
"a bag button's plate is owner-anchored, never the default corner"
);
assert!(
s.eval::<bool>("return GameTooltip:IsOwned(BenillaBagToggle)")
.unwrap(),
"…owned by the button it opened from"
);
// An empty bag slot falls back to the ref's EQUIP_CONTAINER rather than showing nothing.
s.run("BenillaBagBarSlot_OnEnter(BenillaBagBarSlot1)")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Equip Container",
"an empty slot says what belongs in it"
);
// With a bag actually equipped there, the ref shows that BAG's own item tooltip instead — the
// SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot).
let mut inv: benilla_ui::script::InventorySlots = Default::default();
inv[20] = Some(benilla_ui::script::InvSlotView {
bar_placeable: true,
durability: None,
flags: 0,
item_id: 4496,
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
quality: 1,
name: Some("Small Brown Pouch".into()),
link: Some("|cffffffff|Hitem:4496:0:0:0|h[Small Brown Pouch]|h|r".into()),
locked: false,
equip_slots: vec![20],
creator: None,
enchants: Vec::new(),
});
s.set_inventory_slots(inv);
s.run("BenillaBagBarSlot_OnEnter(BenillaBagBarSlot1)")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Small Brown Pouch",
"an equipped slot shows that bag, not the empty-slot fallback"
);
s.run("BenillaBagBarButton_OnLeave()").unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// One keyring container fed as the app feeds it: container −2 sized by the level ladder, with a
/// key in the first slot. `size` is the count `keyring_size(level)` would give.
fn keyring(size: u32, occupied: bool) -> ContainerState {
let mut slots = std::collections::HashMap::new();
if occupied {
slots.insert(
1,
ContainerSlot {
// The Scarlet Key — the item the director's own character carries in keyring slot 1.
item_id: 7146,
count: 1,
quality: Some(1),
texture: Some("Interface\\Icons\\INV_Misc_Key_07".into()),
link: Some("|cffffffff|Hitem:7146:0:0:0|h[The Scarlet Key]|h|r".into()),
..Default::default()
},
);
}
ContainerState {
name: Some("Keyring".into()),
num_slots: size,
slots,
}
}
/// How many drawn quads carry a texture path containing `needle`. The engine exposes no
/// `GetTexture`, so "which art is this frame wearing" is asked of the resolved draw list — which is
/// the stronger question anyway (it answers what would actually be on screen).
fn drawn_with(s: &mut UiScript, needle: &str) -> usize {
s.resolve();
s.extract()
.iter()
.filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)))
.count()
}
/// Load the whole bar+bag surface the keyring spans (its button seats on the action bar and its
/// window is a BagFrame container, so both files are load-bearing).
fn keyring_surface(s: &UiScript) {
for file in [
"Fonts.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"ErrorsFrame.xml",
"ActionBar.xml",
"BagFrame.xml",
] {
load_xml(s, file);
}
}
/// **The gate** (decision 0765): no key ⇒ no keyring anywhere on the bar — the button is hidden and
/// the bar's two right-hand strips wear the ordinary dwarf plate. The first key flips all of it:
/// the button appears, both strips swap to the keyring plate with the reference's own TexCoords,
/// and the performance meter slides from −227 to −235 to clear the new socket
/// (ref MainMenuBar_UpdateKeyRing, MainMenuBar.lua l.174-183).
///
/// This is the director-reported symptom itself: a character holding a key saw no keyring at all.
#[test]
fn the_first_key_puts_the_keyring_on_the_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
keyring_surface(&s);
s.set_money(0);
// Keyless: the button is hidden and the plate is the dwarf one.
s.set_has_key(false);
s.run("MainMenuBar_UpdateKeyRing()").unwrap();
assert!(
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"no key ⇒ no keyring button"
);
assert_eq!(
drawn_with(&mut s, "UI-MainMenuBar-KeyRing"),
0,
"no key ⇒ every bar strip wears the ordinary dwarf plate"
);
// A key lands. The app pushes HasKey and the wire's BAG_UPDATE reaches the button's OnEvent —
// the exact runtime path, not a direct call.
s.set_has_key(true);
s.set_container(-2, Some(keyring(8, true)));
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"the first key reveals the keyring button"
);
assert_eq!(
drawn_with(&mut s, "UI-MainMenuBar-KeyRing"),
2,
"exactly the two RIGHT-hand strips swap to the keyring plate"
);
for (strip, top, bottom) in [
("MainMenuBarTexture2", 0.6640625, 1.0),
("MainMenuBarTexture3", 0.1640625, 0.5),
] {
let (t, b) = s
.eval::<(f64, f64)>(&format!(
"local _, _, top, bottom = {strip}:GetTexCoord() return top, bottom"
))
.unwrap();
assert!(
(t - top).abs() < 1e-9 && (b - bottom).abs() < 1e-9,
"{strip} keeps the reference's own band — got {t}..{b}, want {top}..{bottom}"
);
}
// Geometry cross-check against the reference's own chain. The ref seats its backpack button at
// BOTTOMRIGHT (-6, 2) and steps 37px buttons left with -5 gaps, putting KeyRingButton's left
// edge at -234 from the bar's right; ours steps 36px buttons with -6 gaps and lands at -235.
// Within a pixel of the real bar — which is the check that matters, because the socket the
// button sits in is PAINTED INTO the keyring plate and cannot be nudged to meet it.
let bar_right = s.eval::<f64>("return MainMenuBar:GetRight()").unwrap();
let button_left = s.eval::<f64>("return KeyRingButton:GetLeft()").unwrap();
assert!(
(bar_right - button_left - 235.0).abs() < 1.5,
"the keyring button must land in the plate's painted socket — the ref's own -234, got {}",
bar_right - button_left
);
// The performance meter clears the new socket (ref l.180: -227 → -235).
let perf_right = s
.eval::<f64>("return MainMenuBarPerformanceBarFrame:GetRight()")
.unwrap();
let bar_right = s.eval::<f64>("return MainMenuBar:GetRight()").unwrap();
assert!(
(bar_right - perf_right - 235.0).abs() < 0.01,
"the meter slid to -235 with the keyring up (got {})",
bar_right - perf_right
);
// And it reverts: destroying the last key takes the keyring back off the bar (our divergence
// from the ref's one-way saved-variable latch — see MainMenuBar_UpdateKeyRing).
s.set_has_key(false);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"losing the last key takes the button away again"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The window itself: clicking the button opens a container titled "Keyring", stitched from the
/// `-Keyring` plate, showing exactly the level-gated slot count — and it jingles rather than
/// rustling (KeyRingOpen/KeyRingClose, ref ContainerFrame.lua l.116-138).
#[test]
fn the_keyring_button_opens_a_keyring_window() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
keyring_surface(&s);
s.set_money(0);
s.set_has_key(true);
// A level-44 character: 8 usable slots (the ladder's 40..49 rung).
s.set_container(-2, Some(keyring(8, true)));
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
let _ = s.take_sounds();
assert!(!s
.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
.unwrap());
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert!(
s.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
.unwrap(),
"the button opens the keyring"
);
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("KeyRingOpen".into())],
"the keyring has its own open kit, not the backpack's"
);
assert_eq!(
s.eval::<String>("return BenillaKeyRingFrameName:GetText()")
.unwrap(),
"Keyring",
"titled from the KEYRING string, not a bag item's name"
);
assert!(
drawn_with(&mut s, "UI-Bag-Components-Keyring") > 0,
"stitched from the keyring plate, not the ordinary bag sheet"
);
// 8 usable slots: buttons 1..8 shown, the template's remaining 12 hidden — the ordinary
// physIndex-past-size branch, no keyring-specific code.
let shown = s
.eval::<i64>(
"local n = 0 for i = 1, 20 do \
if getglobal('BenillaKeyRingFrameSlot' .. i):IsShown() then n = n + 1 end end return n",
)
.unwrap();
assert_eq!(shown, 8, "only the level-unlocked keyring slots are drawn");
// The key is in the window (slot 1 of 8 is the LAST chain button — size - physIndex + 1).
assert_eq!(
s.eval::<i64>("return BenillaGetContainerItemID(KEYRING_CONTAINER, 1)")
.unwrap(),
7146
);
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaKeyRingFrame:IsShown()")
.unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("KeyRingClose".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Dropping a held key on the button files it in the first free slot (ref PutKeyInKeyRing), and a
/// full keyring refuses out loud — where the reference prints nothing at all, having passed a
/// GlobalStrings name that was never defined.
#[test]
fn a_key_dropped_on_the_button_files_itself() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
keyring_surface(&s);
s.set_money(0);
s.set_has_key(true);
// A 4-slot keyring with slot 1 taken, and the key on the cursor (picked out of the backpack).
s.set_container(-2, Some(keyring(4, true)));
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::from([(
3,
ContainerSlot {
item_id: 7146,
count: 1,
..Default::default()
},
)]),
}),
);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 3,
dst_bag: -2,
dst_slot: 2,
count: None,
}],
"filed into the FIRST FREE keyring slot (1 is taken), not the backpack"
);
// Now full: the click refuses with the error line instead of queueing anything.
let mut full = keyring(4, true);
for slot in 2..=4 {
full.slots.insert(
slot,
ContainerSlot {
item_id: 7146,
count: 1,
..Default::default()
},
);
}
s.set_container(-2, Some(full));
s.run("PickupContainerItem(0, 3)").unwrap();
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
assert!(
s.take_container_moves().is_empty(),
"a full keyring queues no move"
);
assert_eq!(
s.eval::<String>("return UIErrorsFrameLine1:GetText()")
.unwrap(),
"Your keyring is full.",
"and says so — the reference's own dangling string name prints nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
/// icon down into **that** container's bag-bar button and nobody else's, along the curves read out of
/// `ForcedBackpackItem.m2` — pop in, hang, fall, shrink away — and hides itself at the end.
///
/// This is the whole observable contract of `BenillaItemPushAnim_*`: the routing (which button), the
/// motion (starts a fall above the button, lands centred on it), the fade/scale shape (the file's
/// keys), and the CLAMP end (one play, then gone). It is also the regression net for the OnUpdate
/// gate — an anim frame left shown would keep ticking forever.
#[test]
fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"BagFrame.xml",
] {
load_xml(&s, file);
}
s.resolve();
let shown = |s: &UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
.unwrap()
};
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}ItemAnim, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
return ax - bx, ay - by"
))
.unwrap()
};
let size = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetWidth()"))
.unwrap()
};
let alpha = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetAlpha()"))
.unwrap()
};
// Nothing is animating until a push arrives.
for b in ["BenillaBagToggle", "BenillaBagBarSlot2", "KeyRingButton"] {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
// A push into equipped bag 2.
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(2),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
s.resolve();
assert!(shown(&s, "BenillaBagBarSlot2"), "bag 2's card plays");
for b in ["BenillaBagToggle", "BenillaBagBarSlot1", "KeyRingButton"] {
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
}
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
let drawn = |s: &UiScript, icon: &str| {
s.extract()
.iter()
.filter(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == icon),
)
.count()
};
assert_eq!(
drawn(&s, "Interface\\Icons\\INV_Misc_Bag_08"),
1,
"the card wears the pushed item's icon (ITEM_PUSH's arg2)"
);
// t=0: invisible, full size, held a whole fall above the button (and a drift to its left) —
// the .m2's alpha key (0.000, 0.0), scale key (0.000, 1.0), translation (0.000, (0,0)).
let (dx, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
(dy - 48.0).abs() < 0.5 && (dx + 12.0).abs() < 0.5,
"starts one fall (48px) above and one drift (12px) left of the button: got ({dx}, {dy})"
);
assert!(
alpha(&s, "BenillaBagBarSlot2") < 0.01,
"fades in from nothing"
);
// t=0.133: fully faded in, at the 1.2x swell — the pop the director sees above the bar.
s.tick(0.133);
s.resolve();
assert!(
(alpha(&s, "BenillaBagBarSlot2") - 1.0).abs() < 0.02,
"opaque by the alpha track's second key"
);
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0 * 1.2).abs() < 0.5,
"swollen to 1.2x the 36px icon at the scale track's peak"
);
// t=0.267: back to the icon's own size, still opaque, still parked.
s.tick(0.134);
s.resolve();
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0).abs() < 0.5,
"settled back to the 36px icon at the scale track's third key"
);
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
assert!((dy - 48.0).abs() < 0.5, "has not started falling: got {dy}");
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
// second is a hang above the bar. The other two tracks are already running down, though: the
// three curves keep different schedules, which is the thing a "hold everything then drop"
// reading would get wrong (scale/alpha ≈ 0.686/0.682 here, the 0.267→1.000 ramps at t=0.5).
s.tick(0.233);
s.resolve();
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
(dy - 48.0).abs() < 0.5,
"has not moved yet at the half second: got {dy}"
);
assert!(
(size(&s, "BenillaBagBarSlot2") - 36.0 * 0.686).abs() < 0.5,
"but is already dwindling: got {}",
size(&s, "BenillaBagBarSlot2")
);
assert!(
(alpha(&s, "BenillaBagBarSlot2") - 0.682).abs() < 0.02,
"and already fading: got {}",
alpha(&s, "BenillaBagBarSlot2")
);
// t=0.75: halfway down.
s.tick(0.25);
s.resolve();
let (_, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
(dy - 24.0).abs() < 0.5,
"half the fall travelled by t=0.75: got {dy}"
);
// Just short of the end: landed on the button, shrunk to nothing, faded out.
s.tick(0.24);
s.resolve();
let (dx, dy) = offset(&s, "BenillaBagBarSlot2");
assert!(
dx.abs() < 1.0 && dy.abs() < 1.5,
"lands centred on the button it went into: got ({dx}, {dy})"
);
assert!(
size(&s, "BenillaBagBarSlot2") < 2.0,
"collapsed to the scale track's 0.014"
);
assert!(alpha(&s, "BenillaBagBarSlot2") < 0.05, "faded out");
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
s.tick(0.05);
assert!(
!shown(&s, "BenillaBagBarSlot2"),
"one play, then gone (and no OnUpdate left running)"
);
// The keyring is a real destination, not a rounding of the backpack.
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(-2),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Key_03".into()),
],
);
assert!(shown(&s, "KeyRingButton"), "the keyring card plays");
assert!(
!shown(&s, "BenillaBagToggle"),
"and the backpack's does not"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}