- 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>
2092 lines
78 KiB
Rust
2092 lines
78 KiB
Rust
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());
|
||
}
|