Files
benilla-octo/crates/benilla-app/src/ui_script/bag_tests.rs
T
SamandClaude Fable 5 04340a6893 addon art resolves: loose textures, TGA and the honest return
- an addon's own texture files resolve from its folder, TGA included, and SetTexture answers 1 or nil the way the client does — custom icons and skins draw
- runtime SetParent re-levels a frame by the client's strata law, and the raise survives hide-and-show round-trips — AtlasLoot layers right
- an addon that unregisters an event mid-fire no longer robs the next addon of the same event — Bagnon's per-character database fills again
- the Model widget's clock truncates milliseconds like the client's, and the no-text cast-bar law is confirmed at the bytes and narrowed to the cast bar alone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:21:55 +07:00

2092 lines
78 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 toggle body the bag button's click wrapper calls (the 'B'
/// binding runs the bare `ToggleBackpack()`, the same one hop deeper).
#[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, "MainMenuBarBackpackButton"),
"backpack ring lights"
);
assert!(checked(&mut s, "CharacterBag1Slot"), "bag 2's ring lights");
assert!(
!checked(&mut s, "CharacterBag0Slot"),
"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, "CharacterBag1Slot"),
"closing bag 2 clears its ring"
);
assert!(
checked(&mut s, "MainMenuBarBackpackButton"),
"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 (CharacterBag1Slot:GetLeft() + CharacterBag1Slot:GetRight()) / 2, \
(CharacterBag1Slot:GetTop() + CharacterBag1Slot: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, "CharacterBag1Slot"),
"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, "MainMenuBarBackpackButton") && !checked(&mut s, "CharacterBag1Slot"),
"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"
);
// CharacterBag0Slot == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
s.run("BenillaBagBarSlot_OnClick(CharacterBag0Slot)")
.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());
}
/// B180 — the split dialog's parchment plate fills the whole 172×96 frame. The reference authors
/// the plate 256×32 with NO anchors, a vestigial size the real client never renders (the TexCoords
/// crop exactly 172×96 out of the 256×128 art — the frame's own size, a complete panel drawn 1:1
/// over it). 1308 first dodged this by dropping the size; 1310 then landed the byte-verified law
/// (an anchor-less region gets an implicit SetAllPoints at creation, size unread under the two
/// corners) and restored the ref's own text — this pins the render through the real mechanism.
#[test]
fn the_split_frame_plate_fills_the_dialog() {
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);
s.resolve();
let (left, right, top, bottom) = s
.eval::<(f64, f64, f64, f64)>(
"return StackSplitFrame:GetLeft(), StackSplitFrame:GetRight(), \
StackSplitFrame:GetTop(), StackSplitFrame:GetBottom()",
)
.unwrap();
assert!(
(right - left - 172.0).abs() < 0.5 && (top - bottom - 96.0).abs() < 0.5,
"the dialog frame is 172×96, got {}×{}",
right - left,
top - bottom
);
let plate = s
.extract()
.into_iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-MoneyFrame"))
})
.expect("the plate is on screen");
let r = plate.rect.expect("the plate has a rect");
for (edge, got, want) in [
("left", r.left, left as f32),
("right", r.right, right as f32),
("top", r.top, top as f32),
("bottom", r.bottom, bottom as f32),
] {
assert!(
(got - want).abs() < 0.5,
"plate {edge} = {got}, frame {edge} = {want} — the plate must fill the dialog"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// B180's Bagnon follow-on (director, 08-14): the dialog opened UNDER an addon bag window —
/// Bagnon's windows are `frameStrata="HIGH"`, the dialog's own stratum, and our re-expression had
/// dropped the reference's `toplevel="true"`, so the dialog's Show raised nothing and lost the
/// level tie to the later-shown window (its child buttons at level+1 poked through; the plate did
/// not). With the ref's attrs restored, Show runs the verified raise (toplevel.rs: compact, then
/// top-occupied-plus-one) and the whole dialog lands above. The synthetic window stands in for
/// Bagnon: same stratum, shown after load, overlapping the dialog.
#[test]
fn the_split_frame_raises_over_a_same_stratum_window() {
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.run(
r#"
local w = CreateFrame("Frame", "FakeBagnon", UIParent)
w:SetFrameStrata("HIGH")
w:SetPoint("BOTTOMLEFT", 0, 0)
w:SetSize(1024, 768)
local bg = w:CreateTexture(nil, "BACKGROUND")
bg:SetTexture("Interface\\FakeBagnonBG")
bg:SetAllPoints()
w:Show()
"#,
)
.unwrap();
s.resolve();
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);
s.resolve();
assert!(
s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"the spinner opened"
);
let (dialog, bagnon) = s
.eval::<(i64, i64)>("return StackSplitFrame:GetFrameLevel(), FakeBagnon:GetFrameLevel()")
.unwrap();
assert!(
dialog > bagnon,
"Show must raise the toplevel dialog over the same-stratum window \
(dialog level {dialog}, window level {bagnon})"
);
// The symptom itself: the plate paints AFTER the window's background in draw order.
let order: Vec<String> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("FakeBagnonBG") || p.contains("UI-MoneyFrame") =>
{
Some(p.clone())
}
_ => None,
})
.collect();
assert_eq!(order.len(), 2, "both textures on screen: {order:?}");
assert!(
order[0].contains("FakeBagnonBG"),
"the plate must draw over the window, not under it: {order:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Typed-digit entry in the split spinner (decision 1319) — the director's ask, and the deferral
/// this file's header carried since 0216. Pins the whole chain in one go: the dialog is in the
/// keyboard walk (`enableKeyboard`), a digit reaches its `OnChar`, the first digit REPLACES the
/// seeded 1 while later digits append, an over-max entry clamps instead of being rejected,
/// BACKSPACE drops a digit, and ENTER commits exactly as Okay does.
#[test]
fn typing_a_number_into_the_split_spinner_sets_the_count() {
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);
s.resolve();
assert_eq!(
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
1,
"opens seeded at 1"
);
// A digit is consumed by the dialog — which is also what stops it firing action button 3.
assert!(s.char_input("3"), "the spinner consumed the digit");
assert_eq!(
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
3,
"the first digit REPLACES the seed rather than appending to it"
);
assert_eq!(
s.eval::<String>("return StackSplitText:GetText()").unwrap(),
"3",
"and the label follows"
);
// A second digit appends, then clamps to the 5-stack rather than being thrown away.
assert!(s.char_input("7"));
assert_eq!(
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
5,
"37 against a 5-stack clamps to 5"
);
// BACKSPACE drops a digit off the clamped value.
assert!(s.frame_key_input("BACKSPACE"), "the dialog took BACKSPACE");
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 1);
// Type a real value and commit with ENTER — the Okay path, so the carry is picked up.
assert!(s.char_input("4"));
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 4);
assert!(s.key_input("ENTER"), "ENTER is consumed by the dialog");
assert!(
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"ENTER commits and closes, like Okay"
);
let held = s.cursor_item().expect("ENTER committed the split");
assert_eq!(held.count, Some(4), "the typed count is what got picked up");
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("OpenAllBags()").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(MainMenuBarBackpackButton)")
.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(MainMenuBarBackpackButton)")
.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(CharacterBag0Slot)")
.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(CharacterBag0Slot)")
.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"
);
// ...and says so, on the errors frame — read where a player reads it, off the drawn quads.
// (`UIErrorsFrame` is a real `<MessageFrame>`; its lines are the widget's, not FontStrings.)
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "Your keyring is full."
)),
"the refusal posts to UIErrorsFrame — 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 [
"MainMenuBarBackpackButton",
"CharacterBag1Slot",
"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, "CharacterBag1Slot"), "bag 2's card plays");
for b in [
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"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)).
//
// The pixel figures below are the model's own ratios at the <Model> widget's REAL projection
// (1280 px per model unit at this template's modelScale of 1 — BagFrame.xml's header carries
// the derivation and why the 1200 that produced 36/48/12 was a back-fit). Same animation,
// 6.7% larger: a 38.4 px card, a 51.2 px fall, a 12.8 px drift.
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
(dy - 51.2).abs() < 0.5 && (dx + 12.8).abs() < 0.5,
"starts one fall (51.2px) above and one drift (12.8px) left: got ({dx}, {dy})"
);
assert!(
alpha(&s, "CharacterBag1Slot") < 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, "CharacterBag1Slot") - 1.0).abs() < 0.02,
"opaque by the alpha track's second key"
);
assert!(
(size(&s, "CharacterBag1Slot") - 38.4 * 1.2).abs() < 0.5,
"swollen to 1.2x the 38.4px card 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, "CharacterBag1Slot") - 38.4).abs() < 0.5,
"settled back to the 38.4px card at the scale track's third key"
);
let (_, dy) = offset(&s, "CharacterBag1Slot");
assert!((dy - 51.2).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, "CharacterBag1Slot");
assert!(
(dy - 51.2).abs() < 0.5,
"has not moved yet at the half second: got {dy}"
);
assert!(
(size(&s, "CharacterBag1Slot") - 38.4 * 0.686).abs() < 0.5,
"but is already dwindling: got {}",
size(&s, "CharacterBag1Slot")
);
assert!(
(alpha(&s, "CharacterBag1Slot") - 0.682).abs() < 0.02,
"and already fading: got {}",
alpha(&s, "CharacterBag1Slot")
);
// t=0.75: halfway down.
s.tick(0.25);
s.resolve();
let (_, dy) = offset(&s, "CharacterBag1Slot");
assert!(
(dy - 25.6).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, "CharacterBag1Slot");
assert!(
dx.abs() < 1.0 && dy.abs() < 1.5,
"lands centred on the button it went into: got ({dx}, {dy})"
);
assert!(
size(&s, "CharacterBag1Slot") < 2.0,
"collapsed to the scale track's 0.014"
);
assert!(alpha(&s, "CharacterBag1Slot") < 0.05, "faded out");
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
s.tick(0.05);
assert!(
!shown(&s, "CharacterBag1Slot"),
"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, "MainMenuBarBackpackButton"),
"and the backpack's does not"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **An addon that replaces `ToggleBackpack` gets the bag button's click.**
///
/// This is how a 1.12 addon customises anything — replace a global, expect the client to call your
/// replacement. `Bagnon_Core/core/Overrides.lua` is the canonical shape:
///
/// ```lua
/// local bToggleBackpack = ToggleBackpack
/// ToggleBackpack = function() … end
/// ```
///
/// It was inert here. The 'B' binding and the bag button called `BenillaBagToggle_OnClick`
/// directly, so nothing ever looked up `ToggleBackpack`; Bagnon loaded, showed up in the AddOns
/// list, replaced the globals, and the player still got the stock bags. **The director saw that and
/// no instrument here could** — the corpus survey loads addons and fires events, but never clicks.
///
/// The class is much wider than one addon: every hook-a-global addon is silently inert wherever our
/// UI calls a benilla-named equivalent. This test pins the bag path; the rule is general.
#[test]
fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
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);
// Bagnon's exact idiom: capture the original, replace the global.
s.run(
r#"
HOOK_RAN = 0
local original = ToggleBackpack
ToggleBackpack = function() HOOK_RAN = HOOK_RAN + 1 end
"#,
)
.unwrap();
// The button's own OnClick path — what the 'B' binding and the backpack button both run.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert_eq!(
s.eval::<i64>("return HOOK_RAN").unwrap(),
1,
"the click must reach the addon's replacement, not our own function"
);
// And the addon's override SUPPRESSED the stock behaviour, which is the point of hooking.
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the stock bag must not open when an addon has taken the verb over"
);
// The reference's other two names exist and are callable by an addon that wants the original.
assert!(
s.eval::<bool>("return type(OpenAllBags) == 'function' and type(ToggleBag) == 'function'")
.unwrap(),
"OpenAllBags and ToggleBag are the ref's names and addons hook them too"
);
}
/// **The equipped-bag slots carry the REFERENCE's names, and so do their icons.**
///
/// Ours were `BenillaBagBarSlot1..4`; the reference's are `CharacterBag0Slot..3` — 0-based, so
/// `CharacterBag0Slot` IS bag 1. Eight corpus addons index those names and found nil, including
/// **Bagnon**, which does `getglobal("CharacterBag0Slot"):GetScript("OnClick")`.
///
/// The same class as the `ToggleBackpack` bug the director found: our own name where the reference
/// has one, so every addon reaching for it is silently inert.
///
/// A Lua alias would not have been enough. `Bartender2/Alias.lua:352` takes
/// `CharacterBag0SlotIconTexture` — a name DERIVED from the frame's own, which only a real rename
/// produces. The icon is `$parentIconTexture` now, matching the reference's
/// `PaperDollItemSlotButtonTemplate`.
#[test]
fn the_bag_slots_carry_the_references_names_and_icon_names() {
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");
for i in 0..4 {
assert!(
s.eval::<bool>(&format!("return CharacterBag{i}Slot ~= nil"))
.unwrap(),
"CharacterBag{i}Slot must exist — 8 corpus addons index these"
);
assert!(
s.eval::<bool>(&format!(
"return getglobal('CharacterBag{i}SlotIconTexture') ~= nil"
))
.unwrap(),
"and its icon under the derived name Bartender2 aliases"
);
}
// 0-based: CharacterBag0Slot is bag 1. Pinned because the off-by-one is the whole trap, and
// two name-building call sites had to learn it.
assert_eq!(
s.eval::<i64>("return CharacterBag0Slot.bagId").unwrap(),
1,
"CharacterBag0Slot IS bag 1 — the reference names them 0-based"
);
// Bagnon's exact reach.
assert!(
s.eval::<bool>("return getglobal('CharacterBag0Slot'):GetScript('OnClick') ~= nil")
.unwrap(),
"Bagnon captures this OnClick to replace the bag behaviour"
);
}
/// The backpack button's checked law after the stated divergence (BenillaBagToggle_OnClick's
/// comment): checked belongs to the WINDOWS' OnShow/OnHide writes, and the button's XML click
/// wrapper only undoes the CheckButton widget's pre-handler flip. The killer case is the click
/// that never touches the backpack window — backpack shut, another bag's window open →
/// ToggleBackpack sees an open window and CLOSES ALL; the backpack never transitions, nothing
/// writes its button, and only the wrapper's undo keeps the widget flip from leaving it
/// lit-while-shut (the job the ref's native-window scan tail used to do).
#[test]
fn a_close_others_click_leaves_the_backpack_button_dark() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"BagFrame.xml",
] {
load_xml(&s, file);
}
s.set_money(0);
s.set_container(
1,
Some(benilla_ui::script::ContainerState {
name: Some("Pouch".into()),
num_slots: 6,
slots: std::collections::HashMap::new(),
}),
);
let checked = |s: &mut UiScript| {
s.eval::<bool>("return MainMenuBarBackpackButton:GetChecked() and true or false")
.unwrap()
};
// Bag 1's window alone is open; the backpack window and its button are dark.
s.run("ToggleBag(1)").unwrap();
assert!(!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert!(!checked(&mut s));
// A REAL click on the backpack button (the widget flip fires only on real input): close-all.
s.resolve();
let r: Vec<f32> = s
.eval(
"local f = MainMenuBarBackpackButton \
return { f:GetLeft() + f:GetWidth() / 2, f:GetBottom() + f:GetHeight() / 2 }",
)
.unwrap();
s.mouse_move(r[0], r[1]);
s.mouse_button(r[0], r[1], "LeftButton", true);
s.mouse_button(r[0], r[1], "LeftButton", false);
assert!(
!s.eval::<bool>("return BenillaBagFrame1:IsShown()").unwrap(),
"the click closes the other bag's window (ToggleBackpack's close-all arm)"
);
assert!(
!checked(&mut s),
"no backpack transition happened, so the button must stay dark — the widget flip undone"
);
// The second real click opens the backpack (plus the equipped bag): its OnShow writes the light.
s.mouse_button(r[0], r[1], "LeftButton", true);
s.mouse_button(r[0], r[1], "LeftButton", false);
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert!(
checked(&mut s),
"open ⇒ lit, written by the window's OnShow"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}