Lua bindings answer the reference's shapes on misses, bad indices and tooltips: - Indexed getters push their full miss tuple (`GetCraftInfo`, `GetPetActionInfo`, the trainer requirements and others). Indices go through `ftol` and an unsigned compare, so a negative misses instead of raising. Verified slot readers keep their own raises (`IsInventoryItemLocked`, `0x4c8520`). - The eight GameTooltip setters return the builder's refresh flag, gated as `0x53267c` gates it, so a hovered cooldown tooltip refreshes. `LOCKED` follows the item object (`0x52bdb3`). - Quality goes through the InventoryType gate (-1), and a pending template shows the question-mark icon. - `GetLootSlotInfo` returns four values. `SetSpell`, `CreateMacro`, `JoinChannelByName`, `GetQuestReward` and the shapeshift and trade-skill filters raise or answer as the reference does. - The shape gate probes each getter's miss legs through an argument ladder. Fixes #145 Fixes #993 Fixes #994 Fixes #995 Fixes #996 Fixes #997 Fixes #998 Fixes #1016 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2127 lines
72 KiB
Rust
2127 lines
72 KiB
Rust
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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),
|
||
inventory_type: 0,
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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),
|
||
inventory_type: 0,
|
||
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()
|
||
},
|
||
);
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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),
|
||
inventory_type: 0,
|
||
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 {
|
||
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),
|
||
inventory_type: 0,
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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),
|
||
inventory_type: 0,
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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),
|
||
inventory_type: 0,
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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.
|
||
pub(super) fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||
for file in crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\StackSplitFrame.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(s, file);
|
||
}
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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),
|
||
inventory_type: 0,
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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.
|
||
crate::ui_script::load_stock_bindings(&mut s);
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for file in crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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 {
|
||
duration_ms: None,
|
||
already_bound: false,
|
||
bar_placeable: true,
|
||
durability: None,
|
||
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(),
|
||
});
|
||
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`: stock `GetKeyRingSize` (`ContainerFrame.lua:773-786`: 4, then 8 at
|
||
/// 40, 12 at 50, 16 above 60) reads `UnitLevel("player")`, so a fixture left at 0 gets 4 slots.
|
||
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));
|
||
}
|
||
|
||
/// 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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\UIErrorsFrame.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(s, file);
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
// Level 44: 8 slots (the 40..49 rung). The level decides; the feed carries the same 8.
|
||
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, 8, "only the level-unlocked keyring slots are drawn");
|
||
assert_eq!(
|
||
s.eval::<i64>(
|
||
"local _, _, id = string.find(GetContainerItemLink(KEYRING_CONTAINER, 1), 'item:(%d+)') \
|
||
return tonumber(id)"
|
||
)
|
||
.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());
|
||
}
|
||
|
||
/// A hovered key's tooltip comes through the stock keyring arm of `ContainerFrameItemButton_OnEnter`
|
||
/// (`ContainerFrame.lua:616-619`): `SetInventoryItem("player", KeyRingButtonIDToInvSlotID(id))`,
|
||
/// the key's inventory slot 82 and up, answered from the keyring container.
|
||
#[test]
|
||
fn a_hovered_key_shows_its_tooltip_through_its_inventory_slot() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
|
||
keyring_surface(&s);
|
||
s.set_money(0);
|
||
s.set_has_key(true);
|
||
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)]);
|
||
s.run("KeyRingButton:Click()").unwrap();
|
||
assert!(open(&s, -2), "fixture: the keyring is open");
|
||
// Which slot the tooltip is asked for: the frame's own field shadows the method table.
|
||
s.run(
|
||
r#"local method = GameTooltip.SetInventoryItem
|
||
GameTooltip.SetInventoryItem = function(self, unit, slot)
|
||
ASKED = unit .. ":" .. slot
|
||
return method(self, unit, slot)
|
||
end"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
let key = bag_slot_button(&s, -2, 1);
|
||
hover(&mut s, &key);
|
||
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
|
||
assert_eq!(
|
||
s.eval::<Option<String>>("return ASKED").unwrap().as_deref(),
|
||
Some("player:82"),
|
||
"the stock arm asks for key 1's inventory slot"
|
||
);
|
||
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
|
||
assert_eq!(
|
||
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
|
||
.unwrap(),
|
||
"The Scarlet Key"
|
||
);
|
||
unhover(&mut s);
|
||
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"
|
||
);
|
||
|
||
// Full: each slot needs a texture, since `PutKeyInKeyRing` reads a slot as free from
|
||
// `GetContainerItemInfo`'s first return, the icon (`ContainerFrame.lua:744-746`).
|
||
let mut full = keyring(4, true);
|
||
for slot in 2..=4 {
|
||
full.slots.insert(
|
||
slot,
|
||
ContainerSlot {
|
||
item_id: 7146,
|
||
count: 1,
|
||
texture: Some("Interface\\Icons\\INV_Misc_Key_07".into()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
s.set_container(-2, Some(full));
|
||
s.run("PickupContainerItem(0, 3)").unwrap();
|
||
// Stock `PutKeyInKeyRing` reports through `NO_EMPTY_KEYRING_SLOTS`, which 1.12 never defines
|
||
// (`ContainerFrame.lua:753`), so nothing prints: a 1.12 bug, kept.
|
||
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 reference's own dangling string name prints nothing — un-quirking it is a decision, \
|
||
not a default"
|
||
);
|
||
// Nor does it raise: `AddMessage` reads its text through `lua_isstring` (`0x6f3510`), whose
|
||
// failure edge jumps to the function's epilogue (`0x79562c`).
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
// `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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
|
||
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 crate::ui_script::test_ui::production_order(&[
|
||
BAG_UI,
|
||
&[
|
||
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
|
||
"Interface\\FrameXML\\MerchantFrame.xml",
|
||
"ScrollTemplates.xml", // our scroll kit
|
||
],
|
||
]) {
|
||
load_xml(&s, file);
|
||
}
|
||
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());
|
||
}
|