Files
benilla-octo/crates/benilla-app/src/ui_script/bag_tests.rs
T
gromandClaude Opus 5 9dde1a80e5 The keyring grows in rows, the rank is art, and two fixtures I left behind
- **Keyring**: `GetKeyRingSize` has no level term at all — it scans for the highest filled slot
  and rounds up to a multiple of four (`ContainerFrame.lua:817-844`), expanding only when the key
  count has caught up with that slot. So one key is one row of four, four keys in four slots grow
  to eight rather than refusing a fifth, and full is all 32. The realm also **fixed** 1.12's silent
  refusal: it renamed the message to `NO_EMPTY_KEYRING_SLOTS_ERROR` and defined it
  ("Your keyring is full."), where stock printed a name that did not exist.
- **Bag tooltip**: the item-button `OnUpdate` accumulates toward `TOOLTIP_UPDATE_TIME` instead of
  counting down from it (`:694-704`), and reads the threshold before adding — so a landing template
  repaints an open plate two frames after it crosses a fifth of a second, not on the next frame.
- **Raid grid**: the rank is a `$parentRankIcon` Texture now (`Blizzard_RaidUI.xml:49`), set to
  `RAID_LEADER_ICON`/`RAID_ASSISTANT_ICON` (`:100`, `:155-161`). The `$parentRank` FontString that
  held "(L)"/"(A)" is still declared and never written.
- **Ready check**: the realm moved it into its own group UI (`Turtle_GroupUI.lua:259`) and
  `RaidFrameReadyCheckButton`'s OnClick calls `GroupFrame_InitiateReadyCheck` by name, so
  `load_social_ui` seats that file; without it the button clicks into a nil.

And two fixtures that were mine to fix and I missed: `quest_tests`' detail and reward panels still
carried `item_id: 0`, which `3f2982445` taught the counters to take-while out of existence. Same
correction as the `benilla-ui` fixture in that commit, with the reason written where it is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-31 14:15:58 -06:00

2139 lines
74 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,
};
use super::test_ui::{
bag_slot_button, bag_window, centre_of, click, hover, load_ui as load_xml, unhover, BAG_UI,
};
/// Is bag `id` open? Asked of `IsBagOpen`: the twelve `ContainerFrame`s are recycled.
fn open(s: &UiScript, id: i64) -> bool {
super::test_ui::bag_open(s, id)
}
/// The name of the frame showing bag `id`, which the caller has just opened.
fn window(s: &UiScript, id: i64) -> String {
bag_window(s, id).unwrap_or_else(|| panic!("bag {id} is not open"))
}
/// The stock bag buttons are `MainMenuBarArtFrame`'s children, a level above the bar's art.
#[test]
fn bag_bar_icons_draw_above_the_action_bar_art() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// At this size the bag bar sits over the right end of the XP bar's notched art.
s.set_screen_size(1600.0, 900.0);
for file in BAG_UI {
load_xml(&s, file);
}
s.resolve();
let quads = s.extract();
// Occluded: a higher-z textured quad other than the slot's own ring covers the icon's centre.
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)"
);
}
/// Stock `ContainerFrame_OnShow` and `OnHide` play the sounds (`ContainerFrame.lua:140`, `:120`).
#[test]
fn backpack_toggle_plays_open_and_close_kits() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
s.set_money(0);
// `ToggleBag` opens nothing without slots (its `size > 0` guard).
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
assert!(
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
);
assert!(!open(&s, 0));
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(open(&s, 0));
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igBackPackOpen".into())],
"opening the backpack plays igBackPackOpen"
);
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(!open(&s, 0));
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igBackPackClose".into())],
"closing the backpack plays igBackPackClose"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Stock `ToggleBackpack` (`ContainerFrame.lua:67-82`): with bag 0 shut it opens bag 0 alone, even
/// beside another open bag; with bag 0 open it hides every container window.
#[test]
fn b_opens_the_backpack_alone_and_closes_every_bag() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.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(),
}),
);
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 0), "backpack opens");
assert!(
!open(&s, 2),
"the equipped bag stays shut — this is the backpack's own toggle, not open-all"
);
assert!(
!open(&s, 1) && !open(&s, 3) && !open(&s, 4),
"and the empty slots have no window to show either way"
);
s.run("ToggleBag(2)").unwrap();
assert!(open(&s, 2));
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(
!open(&s, 0) && !open(&s, 2),
"bag 0 open ⇒ the toggle hides every container window"
);
// Bag 0 shut with bag 2 open is still the open arm: the condition reads bag 0.
s.run("ToggleBag(2)").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(
open(&s, 0) && open(&s, 2),
"with only another bag open, B opens the backpack beside it"
);
s.run("CloseAllWindows()").unwrap();
assert!(
!open(&s, 0) && !open(&s, 2),
"CloseAllWindows hides every bag window"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Stock `OpenAllBags` (`ContainerFrame.lua:662-700`) toggles by a count: all open closes the lot,
/// anything less opens it, `forceOpen` always opens; the keyring is swept but never counted.
#[test]
fn shift_b_toggles_every_bag_at_once() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.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(),
}),
);
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 0) && open(&s, 2));
assert!(
!open(&s, 1) && !open(&s, 3) && !open(&s, 4),
"empty bag slots (no container) have no window to show"
);
assert!(
!open(&s, -2),
"the keyring is not one of your bags — open-all never opens it"
);
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(!open(&s, 0) && !open(&s, 2));
// One of two open is still the open arm: the count decides.
s.run("ToggleBag(0)").unwrap();
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(
open(&s, 0) && open(&s, 2),
"1 of 2 open is not 'all open': the lot opens"
);
// An open keyring is swept but not counted, so this reads two of two and closes.
s.run("ToggleKeyRing()").unwrap();
assert!(open(&s, -2));
s.run("OpenAllBags()").unwrap();
let _ = s.take_sounds();
assert!(
!open(&s, 0) && !open(&s, 2),
"the keyring does not count as an open bag"
);
assert!(!open(&s, -2), "…but it IS swept by the same pass");
// `forceOpen` skips the close arm.
s.run("OpenAllBags(1)").unwrap();
s.run("OpenAllBags(1)").unwrap();
let _ = s.take_sounds();
assert!(
open(&s, 0) && open(&s, 2),
"forceOpen means open, never toggle"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// An open bag lights its bar button and any close clears it: `ContainerFrame_OnShow` and `OnHide`
/// write the ring (`ContainerFrame.lua:124-131`, `:84-95`), so it tracks every path.
#[test]
fn bag_bar_buttons_light_while_their_bag_is_open() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.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()
};
s.run("OpenAllBags()").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"
);
// Exactly two rings emit; the backpack's covers its 37-wide button at the art frame's
// BOTTOMRIGHT (-6, 2): x 981..1018, y 2..39 at this size.
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)
);
s.run("CloseBag(2)").unwrap();
assert!(
!checked(&mut s, "CharacterBag1Slot"),
"closing bag 2 clears its ring"
);
assert!(
checked(&mut s, "MainMenuBarBackpackButton"),
"the backpack ring stays"
);
// A right-click on the bar slot reopens bag 2: `PaperDollItemSlotButton_OnLoad` registers both
// buttons (`PaperDollFrame.lua:86`) and `BagSlotButton_OnClick` reads neither.
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"
);
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 in the right half of the screen hangs its tooltip left
/// (`ContainerFrameItemButton_OnEnter`, `ContainerFrame.lua:602-612`).
#[test]
fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The stock tooltip sizes from its lines, so reading its rect needs a text measurer.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
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("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
assert_eq!(s.eval::<f64>("return GetScreenWidth()").unwrap(), 1024.0);
// The bag anchors bottom-right, so every slot is in the right half.
let btn = bag_slot_button(&s, 0, 1);
let ok: bool = s
.eval(&format!("return {btn}:GetRight() >= GetScreenWidth() / 2"))
.unwrap();
assert!(ok, "fixture: the slot must sit in the right half");
hover(&mut s, &btn);
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.
let ok: bool = s
.eval(&format!(
"return GameTooltip:GetRight() <= {btn}:GetLeft() \
and GameTooltip:GetRight() <= GetScreenWidth()"
))
.unwrap();
assert!(ok, "tooltip hangs LEFT of a right-half slot");
}
/// Stock `ContainerFrameItemButton_OnUpdate` re-runs `OnEnter` while it owns the tooltip
/// (`ContainerFrame.lua:645-660`); hiding the tooltip drops ownership.
#[test]
fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
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("MainMenuBarBackpackButton:Click()").unwrap();
s.resolve();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
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 next frame's `OnUpdate` repaints the open tooltip.
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()
},
);
// Not the next frame: the realm's `ContainerFrameItemButton_OnUpdate` **accumulates** and
// repaints only once `this.updateTooltip` passes `TOOLTIP_UPDATE_TIME`
// (`ContainerFrame.lua:694-704`), where stock 1.12 counted down from it and so repainted on
// the first update after a hover. A fifth of a second, which is what a player sees.
s.tick(0.016);
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
1,
"one frame in, the plate is still the fallback line"
);
// The threshold is read **before** the accumulate, so the repaint lands on the frame after
// the one that crosses it.
s.tick(0.2);
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
1,
"the frame that crosses the threshold still reads the old total"
);
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"
);
unhover(&mut s);
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 `SellPrice` times the stack (`0x52b650`, `0x52e376`) or the
/// `ITEM_UNSELLABLE` line, and arms the sell cursor (`ShowContainerSellCursor`, `0x4fa460`);
/// leaving resets it.
#[test]
fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{MerchantState, ScriptValue, UiCursorMode};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
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 {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
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()));
// `MERCHANT_SHOW` opens the bags (the window's OnShow calls `OpenBackpack`); a toggle here
// would take `ToggleBackpack`'s close-all arm.
s.fire_event("MERCHANT_SHOW", vec![ScriptValue::Str("Vendor".into())]);
s.take_sounds();
s.resolve();
let b1 = bag_slot_button(&s, 0, 1);
let b2 = bag_slot_button(&s, 0, 2);
hover(&mut s, &b1);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert!(s
.eval::<bool>(
"return GameTooltipMoneyFrame:IsShown() \
and GameTooltipMoneyFrameCopperButton:GetText() == '52'",
)
.unwrap());
assert_eq!(
s.ui_cursor(),
Some(UiCursorMode::Buy),
"the pouch cursor is armed over a sellable item"
);
unhover(&mut s);
assert_eq!(s.ui_cursor(), None, "ResetCursor on leave");
assert!(s
.eval::<bool>("return not GameTooltipMoneyFrame:IsShown()")
.unwrap());
hover(&mut s, &b2);
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 GameTooltipMoneyFrame:IsShown()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A readable bag item (a letter's permanent copy) shows the inspect cursor on hover
/// (`ContainerFrame.lua:638`, `this.readable`); a plain item does not.
#[test]
fn readable_letter_hover_shows_the_inspect_magnifier() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::UiCursorMode;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.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("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
let b1 = bag_slot_button(&s, 0, 1);
let b2 = bag_slot_button(&s, 0, 2);
hover(&mut s, &b1);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert_eq!(
s.ui_cursor(),
Some(UiCursorMode::Inspect),
"the magnifier over the letter"
);
unhover(&mut s);
assert_eq!(s.ui_cursor(), None, "ResetCursor on leave");
hover(&mut s, &b2);
assert_eq!(s.ui_cursor(), None, "no magnifier over the jerky");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Real input, so the template's `RegisterForDrag`/`OnDragStart`/`OnReceiveDrag` wiring runs.
#[test]
fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(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("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
let b1 = bag_slot_button(&s, 0, 1);
let b5 = bag_slot_button(&s, 0, 5);
let (x1, y1): (f64, f64) = s
.eval(&format!(
"return ({b1}:GetLeft() + {b1}:GetRight()) / 2, \
({b1}:GetTop() + {b1}:GetBottom()) / 2"
))
.unwrap();
let (x5, y5): (f64, f64) = s
.eval(&format!(
"return ({b5}:GetLeft() + {b5}:GetRight()) / 2, \
({b5}:GetTop() + {b5}:GetBottom()) / 2"
))
.unwrap();
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());
}
#[test]
fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
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!(!open(&s, 1), "hidden by default");
// `CharacterBag0Slot` is bag 1: the stock handlers compute
// `this:GetID() - CharacterBag0Slot:GetID() + 1`.
s.run("CharacterBag0Slot:Click()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 1), "the bag-bar click opened bag 1's window");
let w = window(&s, 1);
assert_eq!(
s.eval::<String>(&format!("return {w}Name: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 height follows stock `ContainerFrame_GenerateFrame`:
/// `top + ((rows - 1) * 41 - 9) + 10`, where `top` is 72 for a size % 4 == 2 bag, 86 for a single
/// full row and 94 otherwise, and a one-row bag drops the middle term.
#[test]
fn equipped_bag_window_snug_fits_its_row_count() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
// (bag, slots, height): 6 is 72+32+10, 8 is 94+32+10, 10 is 72+73+10, 20 is 94+155+10, and the
// one-row 4 and 2 are 86+10 and 72+10. Bag 1 stays at 6: the last assertion reads it.
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(),
}),
);
// The window is sized at generation, so each resize reopens it.
if open(&s, bag) {
s.run(&format!("ToggleBag({bag})")).unwrap();
}
s.run(&format!("ToggleBag({bag})")).unwrap();
let _ = s.take_sounds();
let frame = window(&s, bag);
let h = s
.eval::<f64>(&format!("return {frame}:GetHeight()"))
.unwrap();
assert!(
(h - expected).abs() < 0.5,
"bag {bag} ({size} slots): height {h}, expected {expected}"
);
s.run(&format!("ToggleBag({bag})")).unwrap();
let _ = s.take_sounds();
}
s.run("ToggleBag(1)").unwrap();
let _ = s.take_sounds();
let w1 = window(&s, 1);
let h6 = s.eval::<f64>(&format!("return {w1}: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());
}
/// Open a backpack holding a five-stack in slot 1; returns that slot button's centre.
fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
for file in BAG_UI {
load_xml(s, file);
}
load_xml(s, "ScrollTemplates.xml"); // our scroll kit
load_xml(s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(s, "Interface\\FrameXML\\StackSplitFrame.xml");
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: 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("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.resolve();
let btn = bag_slot_button(s, 0, 1);
centre_of(s, &btn)
}
/// The shift fork (`ContainerFrame.lua:567-578`) opens the split frame without a pickup.
#[test]
fn shift_click_on_a_stack_opens_the_split_frame() {
let _data = benilla_formats::wow_data_or_skip!();
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());
}
/// The split dialog's plate fills the 172×96 frame: stock `StackSplitFrame.xml` sizes it 256×32
/// with no anchors, and an anchor-less region gets an implicit `SetAllPoints` at creation, its size
/// unread; the TexCoords crop 172×96 out of the 256×128 art.
#[test]
fn the_split_frame_plate_fills_the_dialog() {
let _data = benilla_formats::wow_data_or_skip!();
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());
}
/// The split dialog raises over a same-stratum window shown before it, as an addon bag window is
/// (`frameStrata="HIGH"`, the dialog's own): stock `toplevel="true"` makes its `Show` raise it.
#[test]
fn the_split_frame_raises_over_a_same_stratum_window() {
let _data = benilla_formats::wow_data_or_skip!();
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:SetWidth(1024); w:SetHeight(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})"
);
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());
}
/// Digits reach the dialog's `OnChar` (`enableKeyboard`): the first replaces the seeded 1, later
/// ones append unless they would exceed the stack, and ENTER commits as Okay does.
#[test]
fn typing_a_number_into_the_split_spinner_sets_the_count() {
let _data = benilla_formats::wow_data_or_skip!();
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"
);
// The dialog consumes the digit, so it does not also fire 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 digit that would exceed the stack is ignored, not clamped: stock `StackSplitFrame_OnChar`
// writes `this.split` only when `split <= this.maxStack` (`StackSplitFrame.lua:72`).
assert!(s.char_input("7"));
assert_eq!(
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
3,
"37 against a 5-stack keeps the 3 — the ref ignores the digit, it does not clamp"
);
assert!(s.frame_key_input("BACKSPACE"), "the dialog took BACKSPACE");
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 1);
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 only picks the split up (`SplitContainerItem` is a pickup); the next placement queues the
/// move with the split count.
#[test]
fn split_okay_then_a_placement_queues_the_split_move() {
let _data = benilla_formats::wow_data_or_skip!();
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());
click(&mut s, "StackSplitRightButton", "LeftButton");
click(&mut s, "StackSplitRightButton", "LeftButton");
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 3);
click(&mut s, "StackSplitOkayButton", "LeftButton");
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"
);
let b5 = bag_slot_button(&s, 0, 5);
click(&mut s, &b5, "LeftButton");
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 an open split frame (`ContainerFrame.lua:581`), even on an unrelated slot.
#[test]
fn a_plain_click_hides_an_open_split_frame() {
let _data = benilla_formats::wow_data_or_skip!();
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());
let b9 = bag_slot_button(&s, 0, 9);
click(&mut s, &b9, "LeftButton");
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());
}
/// Stock `ContainerFrame_UpdateCooldown` reads `GetContainerItemCooldown` into
/// `CooldownFrame_SetTimer`; a `BAG_UPDATE_COOLDOWN` with the cooldown gone hides the sweep.
#[test]
fn bag_slot_cooldown_sweeps_through_the_xml() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.tick(100.0); // a nonzero clock epoch
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 left of the potion's 60 s: started at `GetTime` 52 (an 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)]);
// The next paint's `OnUpdateModel` scrubs the pane's sequence 0 to the elapsed fraction.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
let sweep = |s: &UiScript| super::test_ui::cooldown_play_any(s);
let play = sweep(&s).expect("the bag slot sweeps");
assert_eq!(play, (0, 800), "48 of 60 s elapsed: sequence 0 at 800 ms");
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());
}
/// Stock plain `SetText` tooltips (`MainMenuBarBagButtons.xml:91-99`, `.lua:86-96`) with the
/// binding key appended; `TOGGLEBAG` numbers the slots right to left, so the one beside the
/// backpack is `TOGGLEBAG4` (F11 by default).
#[test]
fn the_bar_bag_buttons_name_themselves_on_hover() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// `GetBindingKey` reads the registered command set, seeded as the app seeds it.
s.register_bindings(&crate::bindings::registry_commands());
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.resolve();
hover(&mut s, "MainMenuBarBackpackButton");
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert!(
line.starts_with("Backpack") && line.contains("(B)"),
"the backpack names itself and its TOGGLEBACKPACK key: {line:?}"
);
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"
);
hover(&mut s, "CharacterBag0Slot");
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Equip Container",
"an empty slot says what belongs in it"
);
// An equipped bag shows its own item tooltip (the `SetInventoryItem` arm); bar slot 1 is
// inventory slot 20.
let mut inv: benilla_ui::script::InventorySlots = Default::default();
inv[20] = Some(benilla_ui::script::InvSlotView {
cooldown: None,
cooldown_legs: Default::default(),
use_spell_id: 0,
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
repair_cost: 0,
flags: 0,
item_id: 4496,
icon: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
count: 1,
contents_count: Some(0), // an equipped bag, whose subclass row carries no count gate
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(),
raw: Default::default(),
});
s.set_inventory_slots(inv);
hover(&mut s, "CharacterBag0Slot");
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert!(
line.starts_with("Small Brown Pouch"),
"an equipped slot shows that bag, not the empty-slot fallback: {line:?}"
);
assert!(
line.contains("(F11)"),
"…and the reference appends this slot's own TOGGLEBAG4 key: {line:?}"
);
unhover(&mut s);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A keyring container (id -2) fed as the app feeds it, `size` being what `keyring_size(level)`
/// gives, with a key in slot 1 when `occupied`.
fn keyring(size: u32, occupied: bool) -> ContainerState {
let mut slots = std::collections::HashMap::new();
if occupied {
slots.insert(
1,
ContainerSlot {
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,
}
}
/// Seat a player at `level`.
///
/// Note for anyone reading this expecting it to size the keyring: it does not, and an earlier
/// comment here claiming stock `GetKeyRingSize` reads `UnitLevel` was simply wrong — `UnitLevel`
/// appears nowhere in `ContainerFrame.lua`. That function *scans* the keyring for its highest
/// filled slot and rounds up to a multiple of four (`:816-844`).
fn seat_player_at_level(s: &mut UiScript, level: u32) {
let mut player = benilla_ui::script::UnitState {
level,
..Default::default()
};
player.name = Some("Tester".into());
s.set_unit("player", Some(player));
}
/// The keyring's two halves, which pfUI's show/hide button plays off against each other.
///
/// `GetContainerNumSlots(KEYRING_CONTAINER)` answers **0** — nothing in stock FrameXML ever asks
/// it, because the keyring is sized by `GetKeyRingSize`'s scan everywhere, which is only worth
/// writing if the container API does not answer for it. Meanwhile every addressable slot still
/// answers `GetContainerItemInfo`, which is what that scan reads.
///
/// pfUI's bag module is exactly those two: `GetContainerNumSlots(-2)` slots normally, and
/// `GetKeyRingSize()` when showing (`modules/bags.lua:202-203`). Reporting a real count for the
/// first made both arms agree and the button do nothing.
#[test]
fn the_keyring_reports_no_container_slots_but_still_answers_per_slot() {
use benilla_ui::script::{ContainerSlot, ContainerState};
use std::collections::HashMap;
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// `GetKeyRingSize` is FrameXML, not an engine global, so it has to be loaded to be scanned —
// but only its own file: the full `BAG_UI` set drags in `FloatingChatFrame`, whose
// prerequisites this harness does not seat (`docs/ADDON_COMPAT.md`'s harness row).
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\ContainerFrame.lua");
// The full UI supplies this (it reads -2 in a loaded client); this fixture loads one file.
s.run("KEYRING_CONTAINER = -2").unwrap();
s.set_container(
-2,
Some(ContainerState {
name: Some("Keyring".into()),
// As the feed writes it.
num_slots: 0,
slots: HashMap::from([(
1,
ContainerSlot {
item_id: 5,
count: 1,
texture: Some(r"Interface\Icons\INV_Misc_Key_03".into()),
..Default::default()
},
)]),
}),
);
assert_eq!(
s.eval::<i64>("return GetContainerNumSlots(-2)").unwrap(),
0,
"the hide arm of pfUI's toggle is this being zero"
);
assert!(
s.eval::<bool>("return GetContainerItemInfo(-2, 1) ~= nil")
.unwrap(),
"and the slot still answers, which is what GetKeyRingSize scans"
);
// pfUI's own two lines, transcribed (`modules/bags.lua:202-203`), both ways round.
let bagsize = |s: &mut UiScript, showing: bool| {
s.eval::<i64>(&format!(
"local showKeyring = {} local bagsize = GetContainerNumSlots(-2) if showKeyring == true then bagsize = GetKeyRingSize() end return bagsize",
if showing { "true" } else { "nil" }
))
.unwrap()
};
assert_eq!(bagsize(&mut s, false), 0, "hidden draws no keyring slots");
assert!(
bagsize(&mut s, true) > 0,
"shown draws the scanned size, so the button changes something"
);
}
/// How many drawn quads carry a texture path containing `needle`.
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()
}
/// The bar and bag files the keyring spans, plus `UIErrorsFrame.xml`: `PutKeyInKeyRing` reports a
/// full ring through `UIErrorsFrame`.
fn keyring_surface(s: &UiScript) {
for file in BAG_UI {
load_xml(s, file);
}
load_xml(s, "ScrollTemplates.xml"); // our scroll kit
load_xml(s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(s, "Interface\\FrameXML\\UIErrorsFrame.xml");
}
/// The first key shows the keyring button, swaps the bar's two right-hand strips to the keyring
/// plate and slides the performance meter from -227 to -235 (`MainMenuBar_UpdateKeyRing`,
/// `MainMenuBar.lua:174-183`).
#[test]
fn the_first_key_puts_the_keyring_on_the_bar() {
let _data = benilla_formats::wow_data_or_skip!();
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(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: `HasKey`, then `BAG_UPDATE` to `MainMenuBarArtFrame`'s `OnEvent`, as at runtime.
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}"
);
}
// The stock bar puts `KeyRingButton`'s left edge 234 from the bar's right (backpack at -6,
// 37-wide buttons, -5 gaps), into the socket painted on the keyring plate.
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 (`MainMenuBar.lua:180`: -227 to -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
);
// The latch is one-way: `MainMenuBar_UpdateKeyRing` only ever shows the button, and
// `SHOW_KEYRING` is a saved variable (`MainMenuBar.lua:174-183`, `MainMenuBar.xml:323-336`).
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 leaves the button on the bar — the reference's one-way latch"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The keyring button opens a container titled "Keyring", stitched from the `-Keyring` plate, with
/// the level-gated slot count and its own sounds (`ContainerFrame.lua:116-138`).
#[test]
fn the_keyring_button_opens_a_keyring_window() {
let _data = benilla_formats::wow_data_or_skip!();
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);
// The level decides nothing here (see `seat_player_at_level`): `GetKeyRingSize` scans for the
// highest filled slot and rounds up to a multiple of four (`ContainerFrame.lua:817-844`).
seat_player_at_level(&mut s, 44);
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!(!open(&s, -2));
s.run("KeyRingButton:Click()").unwrap();
assert!(open(&s, -2), "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"
);
let w = window(&s, -2);
assert_eq!(
s.eval::<String>(&format!("return {w}Name: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"
);
let shown = s
.eval::<i64>(&format!(
"local n = 0 for i = 1, MAX_CONTAINER_ITEMS do \
if getglobal('{w}Item' .. i):IsShown() then n = n + 1 end end return n"
))
.unwrap();
assert_eq!(
shown, 4,
"one key in slot 1 rounds up to one row of four — not the fed `num_slots`, which stock \
FrameXML never asks the keyring for"
);
assert_eq!(
s.eval::<i64>("return BenillaGetContainerItemID(KEYRING_CONTAINER, 1)")
.unwrap(),
7146
);
s.run("KeyRingButton:Click()").unwrap();
assert!(!open(&s, -2));
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 keyring slot (stock
/// `PutKeyInKeyRing`); a full keyring refuses the drop.
#[test]
fn a_key_dropped_on_the_button_files_itself() {
let _data = benilla_formats::wow_data_or_skip!();
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 a key 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("KeyRingButton:Click()").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"
);
// A key in every slot of the row is not full: `GetKeyRingSize` only keeps the current size
// when `numItems < maxSlotNumberFilled`, so four keys in slots 1-4 round up to eight
// (`ContainerFrame.lua:835-839`) and `PutKeyInKeyRing` finds slot 5 free. Each slot needs a
// texture, since it reads a slot as free from `GetContainerItemInfo`'s first return, the icon.
let keyed = |slots: std::ops::RangeInclusive<u32>| {
let mut ring = keyring(4, true);
for slot in slots {
ring.slots.insert(
slot,
ContainerSlot {
item_id: 7146,
count: 1,
texture: Some("Interface\\Icons\\INV_Misc_Key_07".into()),
..Default::default()
},
);
}
ring
};
s.set_container(-2, Some(keyed(2..=4)));
s.run("PickupContainerItem(0, 3)").unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 3,
dst_bag: -2,
dst_slot: 5,
count: None,
}],
"a filled row grows by four rather than refusing"
);
// Truly full is all 32 — `GetKeyRingSize` clamps there (`:841`) — and the refusal now says so:
// stock `PutKeyInKeyRing` printed `NO_EMPTY_KEYRING_SLOTS`, a name 1.12 never defines, and the
// realm renamed it to `NO_EMPTY_KEYRING_SLOTS_ERROR` (`:797`) and defined it in
// `GlobalStrings.lua` as "Your keyring is full." So the 1.12 silent-refusal bug is fixed.
s.set_container(-2, Some(keyed(2..=32)));
s.run("PickupContainerItem(0, 3)").unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert!(
s.take_container_moves().is_empty(),
"a full keyring queues no move"
);
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t.contains("keyring is full")
)),
"the refusal reaches UIErrorsFrame now that the string exists"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Stock `ItemAnim_OnEvent` puts the pushed icon on the pane of the button owning that slot and
/// plays sequence 0 once; `ItemAnim_OnAnimFinished` hides it. The pane authored no size, so its
/// rect is the file's bounding box in layout units.
#[test]
fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// `ForcedBackpackItem.m2` (`benilla-extract m2seq`, `m2batch`): one sequence (id 0, 1000 ms,
// clamp) and a 0.02707 × 0.07962 box, which is 42.41 × 124.72 at 16:9, where a layout unit is
// 768·√(a²+1) = 1566.4 FrameXML units.
const CARD: &str = r"Interface\ItemAnimations\ForcedBackpackItem.mdx";
s.set_model_facts(
CARD,
ModelFileFacts {
sequences: vec![SequenceFacts {
anim_id: 0,
duration_ms: 1000,
looping: false,
}],
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
cameras: 0,
},
);
s.resolve();
let shown = |s: &UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
.unwrap()
};
// The shown card panes, as the renderer sees them: `(icon, play head)` per visible pane.
type Card = (Option<String>, Option<(u16, u32)>);
let cards = |s: &mut UiScript| -> Vec<Card> {
let heads = s.visible_model_panes();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::ModelPane {
handle,
model: Some(m),
icon,
..
} if m == CARD => Some((
icon,
heads
.iter()
.find(|p| p.handle == handle)
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms))),
)),
_ => None,
})
.collect()
};
for b in [
"MainMenuBarBackpackButton",
"CharacterBag1Slot",
"KeyRingButton",
] {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
assert!(cards(&mut s).is_empty());
// `arg1` is the inventory-slot id `ItemAnim_OnEvent` compares against the button's: bag 2 is
// `CharacterBag1Slot`, 21.
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(21),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
assert!(
shown(&s, "CharacterBag1Slot"),
"the card that took it plays"
);
assert!(
!shown(&s, "MainMenuBarBackpackButton") && !shown(&s, "KeyRingButton"),
"…and nobody else's"
);
s.resolve();
let live = cards(&mut s);
assert_eq!(
live,
vec![(
Some(r"Interface\Icons\INV_Misc_Bag_08".to_string()),
Some((0, 0))
)],
"one pane, the pushed icon on it, sequence 0 at 0"
);
// The implicit rect hangs off the button's BOTTOMRIGHT (-10, 0), as the stock template anchors.
let (w, h): (f32, f32) = s
.eval("return CharacterBag1SlotItemAnim:GetWidth(), CharacterBag1SlotItemAnim:GetHeight()")
.unwrap();
assert!(
(w - 42.41).abs() < 0.05 && (h - 124.72).abs() < 0.05,
"the card's rect is the file's box: {w} × {h}"
);
let (dx, dy): (f32, f32) = s
.eval(
"local a, b = CharacterBag1SlotItemAnim, CharacterBag1Slot \
return a:GetRight() - b:GetRight(), a:GetBottom() - b:GetBottom()",
)
.unwrap();
assert!(
(dx + 10.0).abs() < 0.01 && dy.abs() < 0.01,
"BOTTOMRIGHT (−10, 0) off the button: ({dx}, {dy})"
);
// The clock runs the file's 1000 ms clamp.
s.tick(0.5);
assert_eq!(cards(&mut s)[0].1, Some((0, 500)));
s.tick(0.55);
assert!(
!shown(&s, "CharacterBag1Slot"),
"one play, then gone (the stock OnAnimFinished hides it)"
);
assert!(cards(&mut s).is_empty());
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(21),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_09".into()),
],
);
s.resolve();
assert_eq!(
cards(&mut s),
vec![(
Some(r"Interface\Icons\INV_Misc_Bag_09".to_string()),
Some((0, 0))
)]
);
// The keyring (-2) is a destination of its own.
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 the global `ToggleBackpack`, as Bagnon's `Overrides.lua` does, receives
/// the backpack button's click and suppresses the stock toggle.
#[test]
fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
// Bagnon's 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();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return HOOK_RAN").unwrap(),
1,
"the click must reach the addon's replacement, not our own function"
);
assert!(
!open(&s, 0),
"the stock bag must not open when an addon has taken the verb over"
);
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 bag slots carry the stock names, `CharacterBag0Slot` to `CharacterBag3Slot` (0-based, so
/// `CharacterBag0Slot` is bag 1), and their icons the derived `$parentIconTexture`: Bagnon reads
/// `CharacterBag0Slot`'s `OnClick`, and `Bartender2/Alias.lua:352` takes the icon's name.
#[test]
fn the_bag_slots_carry_the_references_names_and_icon_names() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.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"
);
}
// The stock handlers translate with `this:GetID() - CharacterBag0Slot:GetID() + 1`; the ids
// are inventory slots, `GetInventorySlotInfo("Bag0Slot")` being 20.
assert_eq!(
s.eval::<i64>("return CharacterBag0Slot:GetID()").unwrap(),
20,
"CharacterBag0Slot's id is its INVENTORY slot — 8 corpus addons compute `GetID() - 19`"
);
assert_eq!(
s.eval::<i64>("return CharacterBag3Slot:GetID() - CharacterBag0Slot:GetID() + 1")
.unwrap(),
4,
"the reference's own translation: the fourth button is bag 4"
);
assert!(
s.eval::<bool>("return getglobal('CharacterBag0Slot'):GetScript('OnClick') ~= nil")
.unwrap(),
"Bagnon captures this OnClick to replace the bag behaviour"
);
}
/// `ContainerFrame_OnShow`/`OnHide` write the ring, and stock `BackpackButton_OnClick` re-derives
/// it after the CheckButton's own flip.
#[test]
fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
// Stock `ToggleBag` opens nothing for a size-0 container, so the backpack is fed.
s.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
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()
};
s.run("ToggleBag(1)").unwrap();
assert!(!open(&s, 0));
assert!(!checked(&mut s));
// A real click: the CheckButton's own flip fires only on real input.
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!(
open(&s, 0),
"the click opens the backpack (ToggleBackpack's open arm reads bag 0, not 'anything open')"
);
assert!(open(&s, 1), "…and leaves the bag that was already up alone");
assert!(
checked(&mut s),
"open ⇒ lit, written by the window's OnShow over the widget flip"
);
// Bag 0 is open now, so the second click is the close-all arm.
s.mouse_button(r[0], r[1], "LeftButton", true);
s.mouse_button(r[0], r[1], "LeftButton", false);
assert!(!open(&s, 0));
assert!(!open(&s, 1), "the close arm takes every container down");
assert!(
!checked(&mut s),
"shut ⇒ dark, written by the window's OnHide"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}