- seven more windows run off the player's own patch chain: the durability shield (`DurabilityFrame.xml`, with a Rust-side repair for the reference's own stale side-glyph inset the director caught on screen), `Localization.xml` + `GameTime.xml` (so `LocalizeFrames` finally runs from `VARIABLES_LOADED`, and the clock's 24-hour flag comes from where 1.12 sets it), the chat-link tooltip (`ItemRef.xml` — draggable now, and its shift-click arm fills the friend/ignore/who boxes as the reference's does), the mirror timers (`MirrorTimer.xml`, which exposed that our `STATICPOPUP_NUMDIALOGS` was 2 against the reference's 4, so a third breath/fatigue/feign bar could never be claimed), the zone splash with the fading kit, the item-text reader and the combo-point dots — each under the reference's published frame names, which is what an addon reaches for - `AddMessage` swallows a text it cannot use, silently, as the reference does: `0x795590` is a `lua_isstring` gate whose failure edge jumps straight to the epilogue — no line, no error, no record — and stock FrameXML takes that path: a full keyring calls `AddMessage(NO_EMPTY_KEYRING_SLOTS, …)` and no such GlobalString exists in 1.12, so it is a dead click on a real client where benilla put a script-error dialog on it; byte-verified by a four-worker cross-check in wow-re, and NOT generalised — a bad r/g/b/a still adds the line opaque white, only a bad text shows nothing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2256 lines
88 KiB
Rust
2256 lines
88 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`'s window open? `IsBagOpen` is the reference's own name for the scan over
|
||
/// `ContainerFrame1..12`, and it is what these tests ask instead of naming a frame — the windows
|
||
/// are RECYCLED (1751), so which `ContainerFrame` a bag lands in is not a property to assert on.
|
||
fn open(s: &UiScript, id: i64) -> bool {
|
||
super::test_ui::bag_open(s, id)
|
||
}
|
||
|
||
/// The frame currently showing bag `id`, by name — for the tests that need to reach INTO the
|
||
/// window (its slots, its title, its money row). Panics if that bag is not open, which is the
|
||
/// right failure: every caller has just opened it.
|
||
fn window(s: &UiScript, id: i64) -> String {
|
||
bag_window(s, id).unwrap_or_else(|| panic!("bag {id} is not open"))
|
||
}
|
||
|
||
/// The equipped-bag BAR icons must draw ABOVE the action-bar art, not under it. The bar buttons are
|
||
/// relocated onto `MainMenuBarArtFrame` but are top-level frames, so they default to a lower
|
||
/// frame level than the bar's own child-hierarchy art (the ExpBar dwarf notches + metal/well art) —
|
||
/// which would then paint over the centered icons, leaving the ring but no bag icon. The OnLoad
|
||
/// `BenillaActionBarArt_SeatAbove` seats them one level above the art (the action buttons' level).
|
||
/// This locks that: no bag-slot icon quad may be covered by a higher-z art texture at its center.
|
||
#[test]
|
||
fn bag_bar_icons_draw_above_the_action_bar_art() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
// The screen the client defaults to; the action bar centers here and the bag bar lands over its
|
||
// right end, where the dwarf-notch strip overlaps — the exact geometry that reproduced the bug.
|
||
s.set_screen_size(1600.0, 900.0);
|
||
// `BAG_UI` carries ActionBar.xml — both the anchor target (MainMenuBarArtFrame) and the
|
||
// occluder (the ExpBar dwarf art) — in its manifest position.
|
||
for file in BAG_UI {
|
||
load_xml(&s, file);
|
||
}
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
|
||
// A bag-slot icon is occluded when any HIGHER-z textured quad (other than the button's own ring)
|
||
// covers its center — i.e. the bar art draws on top of it.
|
||
let occluded = quads
|
||
.iter()
|
||
.filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("UI-PaperDoll-Slot-Bag")))
|
||
.filter(|icon| {
|
||
let r = icon.rect.expect("a resolved icon rect");
|
||
let (cx, cy) = ((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
|
||
quads.iter().any(|q| {
|
||
q.z > icon.z
|
||
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if !p.contains("UI-Quickslot2"))
|
||
&& q.rect.is_some_and(|qr| qr.left <= cx && cx <= qr.right && qr.bottom <= cy && cy <= qr.top)
|
||
})
|
||
})
|
||
.count();
|
||
assert_eq!(
|
||
occluded, 0,
|
||
"a bag-slot icon is painted over by the action-bar art (the seat-above-the-bar fix regressed)"
|
||
);
|
||
}
|
||
|
||
/// The backpack open/close kits (ContainerFrame.lua ContainerFrame_OnShow/OnHide, l.140 / l.120):
|
||
/// showing the window queues igBackPackOpen, hiding it queues igBackPackClose — and nothing queues
|
||
/// at load (the frame is authored hidden="true", so it never transitions on startup). Driven through
|
||
/// `BenillaBagToggle_OnClick` — the toggle body the bag button's click wrapper calls (the 'B'
|
||
/// binding runs the bare `ToggleBackpack()`, the same one hop deeper).
|
||
#[test]
|
||
fn backpack_toggle_plays_open_and_close_kits() {
|
||
let _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);
|
||
// The backpack has to have slots before `ToggleBag` will open it — its `size > 0` guard.
|
||
s.set_container(
|
||
0,
|
||
Some(ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots: std::collections::HashMap::new(),
|
||
}),
|
||
);
|
||
|
||
// Hidden at load: no sound queued, every window starts hidden.
|
||
assert!(
|
||
s.take_sounds().is_empty(),
|
||
"no sound at load (never transitions)"
|
||
);
|
||
assert!(!open(&s, 0));
|
||
|
||
// Toggle open → OnShow → igBackPackOpen.
|
||
s.run("MainMenuBarBackpackButton:Click()").unwrap();
|
||
assert!(open(&s, 0));
|
||
assert_eq!(
|
||
s.take_sounds(),
|
||
vec![SoundRequest::KitName("igBackPackOpen".into())],
|
||
"opening the backpack plays igBackPackOpen"
|
||
);
|
||
|
||
// Toggle closed → OnHide → igBackPackClose.
|
||
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());
|
||
}
|
||
|
||
/// **B / the backpack button open the BACKPACK ALONE** (ref ToggleBackpack, ContainerFrame.lua
|
||
/// l.67-82) — the director's report, and the fix in 1494. Until then both ran an all-bags toggle
|
||
/// and an equipped bag came up beside the backpack every time.
|
||
///
|
||
/// The reference's shape is deliberately asymmetric, and all three arms are pinned here:
|
||
/// * shut → `ToggleBag(0)`: bag 0 and nothing else, however many bags are equipped;
|
||
/// * bag 0 OPEN → hide every container window there is (the close arm is the all-bags one);
|
||
/// * bag 0 shut but ANOTHER bag open → the condition reads bag 0 specifically, so this is still
|
||
/// the open arm: the backpack joins the open bag rather than closing it.
|
||
///
|
||
/// ESC's CloseAllWindows must still sweep all of them.
|
||
#[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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
// Backpack (16) + one equipped bag in slot 2 (6). Bags 1/3/4 are left unset → 0 slots.
|
||
s.set_container(
|
||
0,
|
||
Some(ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots: std::collections::HashMap::new(),
|
||
}),
|
||
);
|
||
s.set_container(
|
||
2,
|
||
Some(ContainerState {
|
||
name: Some("Small Pouch".into()),
|
||
num_slots: 6,
|
||
slots: std::collections::HashMap::new(),
|
||
}),
|
||
);
|
||
|
||
// Toggle open: the BACKPACK, and only the backpack — bag 2 is equipped and stays shut.
|
||
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"
|
||
);
|
||
|
||
// The close arm IS the all-bags one: with bag 2 also open by some other path, one toggle
|
||
// takes the lot down.
|
||
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, bag 2 open: the ref's condition reads bag 0, so this is the OPEN arm — the
|
||
// backpack joins the bag already up instead of closing it.
|
||
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"
|
||
);
|
||
|
||
// ESC's CloseAllWindows sweeps every open bag, not just the backpack.
|
||
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());
|
||
}
|
||
|
||
/// **SHIFT-B / a shift-click on the backpack button open ALL of them** (ref OpenAllBags,
|
||
/// ContainerFrame.lua l.662-700) — the other half of the split 1494 restored. It is a TOGGLE, and
|
||
/// its arms are decided by a COUNT, not by "is anything open":
|
||
/// * anything less than everything open → open the lot (the backpack plus each equipped bag);
|
||
/// * everything already open → the counting pass IS the close;
|
||
/// * `forceOpen` → always the open arm (what a vendor window would pass).
|
||
///
|
||
/// The keyring is swept by the counting pass but never counted and never opened — the reference's
|
||
/// own `GetID() ~= KEYRING_CONTAINER` exclusion.
|
||
#[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, "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(),
|
||
}),
|
||
);
|
||
|
||
// From nothing open: the backpack AND the equipped bag; the empty slots have no window.
|
||
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"
|
||
);
|
||
|
||
// Everything open ⇒ the next one closes the lot.
|
||
s.run("OpenAllBags()").unwrap();
|
||
let _ = s.take_sounds();
|
||
assert!(!open(&s, 0) && !open(&s, 2));
|
||
|
||
// PARTIAL is the open arm, not the close arm — the count is what decides. Backpack alone up
|
||
// (1 of 2) ⇒ open-all still opens rather than closing what is there.
|
||
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 rides the sweep down without ever counting toward "all open" — so this
|
||
// still reads 2-of-2 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: from all-open, everything stays open.
|
||
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 (the CheckButton ring — ref ContainerFrame_OnShow/OnHide
|
||
/// SetChecked, l.124-131/84-95), and any close clears it: the windows are the source of truth,
|
||
/// so the ring tracks opens from every path (the all-toggle, a bar click, ESC's sweep).
|
||
#[test]
|
||
fn bag_bar_buttons_light_while_their_bag_is_open() {
|
||
let _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, "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()
|
||
};
|
||
|
||
// Open-all (SHIFT-B's knob since 1494 — the backpack button alone would light only its own
|
||
// ring now): the backpack button and the equipped bag's slot light; the empty slots don't.
|
||
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"
|
||
);
|
||
|
||
// ...and the rings actually EMIT (extract-level): exactly two CheckButtonHilight quads, the
|
||
// toggle's owner-sized on the 37px button at the art frame's BOTTOMRIGHT −6,2 (art right
|
||
// edge = 1024 at this screen ⇒ x[981,1018] y[2,39]).
|
||
s.resolve();
|
||
let rings: Vec<_> = s
|
||
.extract()
|
||
.into_iter()
|
||
.filter(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||
if p.contains("CheckButtonHilight"))
|
||
})
|
||
.collect();
|
||
assert_eq!(rings.len(), 2, "toggle + bag 2 rings emit, nothing else");
|
||
let toggle_ring = rings
|
||
.iter()
|
||
.find_map(|q| q.rect.filter(|r| r.left == 981.0))
|
||
.expect("the toggle's ring at the art frame's corner");
|
||
assert_eq!(
|
||
(
|
||
toggle_ring.left,
|
||
toggle_ring.bottom,
|
||
toggle_ring.right,
|
||
toggle_ring.top
|
||
),
|
||
(981.0, 2.0, 1018.0, 39.0)
|
||
);
|
||
|
||
// Closing ONE window (its close button / any Hide path) clears just its ring.
|
||
s.run("CloseBag(2)").unwrap();
|
||
assert!(
|
||
!checked(&mut s, "CharacterBag1Slot"),
|
||
"closing bag 2 clears its ring"
|
||
);
|
||
assert!(
|
||
checked(&mut s, "MainMenuBarBackpackButton"),
|
||
"the backpack ring stays"
|
||
);
|
||
|
||
// A bar-slot click reopens bag 2 and relights it (the click auto-toggle + the ref's
|
||
// re-derive tail agree here). Driven as a RIGHT-click through the real input path: the ref's
|
||
// BagSlotButtonTemplate inherits PaperDollItemSlotButtonTemplate, whose OnLoad registers
|
||
// ("LeftButtonUp", "RightButtonUp") — PaperDollFrame.lua:86 — and BagSlotButton_OnClick reads
|
||
// no button, so either one opens the bag. Ours registered LeftButtonUp only until 0908.
|
||
let (cx, cy): (f64, f64) = s
|
||
.eval(
|
||
"return (CharacterBag1Slot:GetLeft() + CharacterBag1Slot:GetRight()) / 2, \
|
||
(CharacterBag1Slot:GetTop() + CharacterBag1Slot:GetBottom()) / 2",
|
||
)
|
||
.unwrap();
|
||
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
|
||
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
|
||
assert!(
|
||
checked(&mut s, "CharacterBag1Slot"),
|
||
"a RIGHT-click on the bar slot reopens bag 2, exactly as a left one does"
|
||
);
|
||
|
||
// ESC's sweep closes everything → every ring dark.
|
||
s.run("CloseAllWindows()").unwrap();
|
||
assert!(
|
||
!checked(&mut s, "MainMenuBarBackpackButton") && !checked(&mut s, "CharacterBag1Slot"),
|
||
"close-all clears every ring"
|
||
);
|
||
let _ = s.take_sounds();
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// A slot on the RIGHT half of the screen hangs its tooltip LEFT — the ref's own screen-edge
|
||
/// answer (ContainerFrameItemButton_OnEnter, ContainerFrame.lua:602-612 side-pick), which is what
|
||
/// keeps a bag tooltip from running off the right edge (the bag lives at the bottom-right).
|
||
#[test]
|
||
fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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();
|
||
|
||
// The engine speaks 1.12: GetScreenWidth serves the host-set root width.
|
||
assert_eq!(s.eval::<f64>("return GetScreenWidth()").unwrap(), 1024.0);
|
||
// The bag window anchors bottom-right, so every slot button is in the right half. The bag
|
||
// numbers its buttons visually (reversed from container slots) — find the button showing
|
||
// container slot 1, where the fixture item lives.
|
||
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: the whole tooltip stays
|
||
// left of the slot, i.e. on-screen — never past the right edge.
|
||
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");
|
||
}
|
||
|
||
/// A tooltip opened while the item's template is still in flight repaints itself the moment the
|
||
/// stats land — no re-hover. The refresh loop is the ref's own (ContainerFrameItemButton_OnUpdate,
|
||
/// ContainerFrame.lua:645-660: re-run OnEnter every frame while `GameTooltip:IsOwned(this)`), and
|
||
/// hiding the tooltip drops ownership so the loop can never resurrect it.
|
||
#[test]
|
||
fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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 with the stats store empty: the fallback one-line tooltip, and the miss recorded.
|
||
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 app's arrival-driven push) → the very next frame's OnUpdate
|
||
// re-enter repaints the OPEN tooltip with the full stat head.
|
||
s.set_item_template(
|
||
25,
|
||
ItemTemplateView {
|
||
name: "Worn Shortsword".into(),
|
||
quality: 1,
|
||
inventory_type: 21,
|
||
class: 2,
|
||
subclass: 7,
|
||
damages: vec![(1.0, 3.0, 0)],
|
||
delay_ms: 1900,
|
||
..Default::default()
|
||
},
|
||
);
|
||
s.tick(0.016);
|
||
assert!(
|
||
s.eval::<i64>("return GameTooltip:NumLines()").unwrap() > 1,
|
||
"the stats landing repainted the open tooltip"
|
||
);
|
||
let has_damage: bool = s
|
||
.eval(
|
||
"for i = 1, GameTooltip:NumLines() do \
|
||
local fs = getglobal(\"GameTooltipTextLeft\" .. i) \
|
||
if fs and string.find(fs:GetText() or \"\", \"Damage\") then return true end \
|
||
end return false",
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
has_damage,
|
||
"the repaint carries the stat head's damage line"
|
||
);
|
||
|
||
// Leaving drops ownership: the loop must not resurrect the hidden tooltip.
|
||
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 the engine-truth sell-price money row (SellPrice × stack, wow-re
|
||
/// tooltip-money.md 0x52b650@0x52e376) — or the ITEM_UNSELLABLE "No sell price" line — and arms
|
||
/// the pouch cursor (ShowContainerSellCursor → Buy over a Point base, cursor-system.md §7);
|
||
/// leaving resets it.
|
||
#[test]
|
||
fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
|
||
use benilla_ui::script::{MerchantState, ScriptValue, UiCursorMode};
|
||
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for file in BAG_UI {
|
||
load_xml(&s, file);
|
||
}
|
||
load_xml(&s, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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 {
|
||
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 already opens the bags (the window's OnShow calls `OpenBackpack`, decision
|
||
// 0561), so this needs no toggle of its own — and must not have one: with bag 0 already up,
|
||
// `ToggleBackpack` takes its close-all arm and the window this test hovers in would go away.
|
||
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);
|
||
|
||
// The sellable stack: a money row (52c → the copper coin slot shows "52") + the pouch armed.
|
||
hover(&mut s, &b1);
|
||
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
|
||
assert!(s
|
||
.eval::<bool>(
|
||
"return GameTooltipMoneyCoin1:IsShown() \
|
||
and GameTooltipMoneyCoin1Num:GetText() == '52'",
|
||
)
|
||
.unwrap());
|
||
assert_eq!(
|
||
s.ui_cursor(),
|
||
Some(UiCursorMode::Buy),
|
||
"the pouch cursor is armed over a sellable item"
|
||
);
|
||
|
||
// Leaving resets the cursor and the money row dies with the tooltip.
|
||
unhover(&mut s);
|
||
assert_eq!(s.ui_cursor(), None, "ResetCursor on leave");
|
||
assert!(s
|
||
.eval::<bool>("return not GameTooltipMoneyCoin1:IsShown()")
|
||
.unwrap());
|
||
|
||
// The unsellable item: the ITEM_UNSELLABLE line, no coins.
|
||
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 GameTooltipMoneyCoin1:IsShown()")
|
||
.unwrap());
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// A readable bag item (a mail permanent copy — the instance carries item text) shows the
|
||
/// Inspect magnifier on hover (ref ContainerFrameItemButton_OnEnter, ContainerFrame.lua l.638:
|
||
/// `this.readable → ShowInspectCursor()`); a plain item leaves the base cursor; leaving resets.
|
||
#[test]
|
||
fn readable_letter_hover_shows_the_inspect_magnifier() {
|
||
use benilla_ui::script::UiCursorMode;
|
||
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for file in BAG_UI {
|
||
load_xml(&s, file);
|
||
}
|
||
load_xml(&s, "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());
|
||
}
|
||
|
||
/// The drag trio (decision 0216 §3): a real press-drag-release across two slot buttons routes
|
||
/// through the SAME `ContainerFrameItemButton_OnClick("LeftButton")` path a two-click pickup/place
|
||
/// does —
|
||
/// unlike every other bag test here, which calls the Lua click handler directly, this one drives
|
||
/// actual `mouse_button`/`mouse_move` so the `RegisterForDrag`/`OnDragStart`/`OnReceiveDrag` XML
|
||
/// wiring itself is under test, not just the handler body.
|
||
#[test]
|
||
fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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();
|
||
|
||
// Press on slot 1 (picks up), drag past the threshold onto slot 5, release there.
|
||
s.mouse_button(x1 as f32, y1 as f32, "LeftButton", true);
|
||
s.mouse_move(x5 as f32, y5 as f32);
|
||
let consumed = s.mouse_button(x5 as f32, y5 as f32, "LeftButton", false);
|
||
assert!(consumed, "the drag release lands on a mouse-enabled frame");
|
||
|
||
assert!(s.cursor_item().is_none(), "placed onto the empty slot 5");
|
||
assert_eq!(
|
||
s.take_container_moves(),
|
||
vec![ContainerMove {
|
||
src_bag: 0,
|
||
src_slot: 1,
|
||
dst_bag: 0,
|
||
dst_slot: 5,
|
||
count: None,
|
||
}]
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// A second bag window (decision 0216 slice 2): bag 1's snapshot feeds through the SAME
|
||
/// container/BenillaBagWindow_Update plumbing the backpack uses, opened via the bag-bar path
|
||
/// (`BenillaBagBarSlot_OnClick`, not the backpack toggle) and painting its own slot 1 icon.
|
||
#[test]
|
||
fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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 == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
|
||
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 SNUG-FITS its row count (BenillaBagWindow_FitBackground) instead of the
|
||
/// backpack's fixed 5-row/260 slab. The heights are the real client's, from
|
||
/// `ContainerFrame_GenerateFrame` in the shipped `Interface\FrameXML\ContainerFrame.lua`:
|
||
/// `height = topH + ((rows-1)*41 - 9) + 10`, with `topH` = 72 for a size%4==2 bag (its own plus-two
|
||
/// top band), 86 for a single full row, else 94. The `-9` is the reference's `firstRowPixelOffset`
|
||
/// and the `10` its fixed bottom rim; both are load-bearing — dropping the offset slides the rim a
|
||
/// row-fraction low and bleeds the next row's wells in above it. This locks that arithmetic AND the
|
||
/// core fix: a small bag is far shorter than the old fixed height.
|
||
#[test]
|
||
fn equipped_bag_window_snug_fits_its_row_count() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
// (bag id, slot count, expected window height). 6 → 2 rows plus-two-top (72+32+10); 8 → 2 rows
|
||
// full-top (94+32+10); 10 → 3 rows plus-two-top (72+73+10); 20 → 5 rows full-top (94+155+10).
|
||
// The last two exercise the no-middle fork: 4 → one full row (86+0+10); 2 → one plus-two row
|
||
// (72+0+10). Bag 1 stays at 6 so the h6 assertion below still reads the pouch.
|
||
for (bag, size, expected) in [
|
||
(1, 6, 114.0),
|
||
(2, 8, 136.0),
|
||
(3, 10, 155.0),
|
||
(4, 20, 259.0),
|
||
(2, 4, 96.0),
|
||
(3, 2, 82.0),
|
||
] {
|
||
s.set_container(
|
||
bag,
|
||
Some(ContainerState {
|
||
name: Some(format!("Bag {bag}")),
|
||
num_slots: size,
|
||
slots: std::collections::HashMap::new(),
|
||
}),
|
||
);
|
||
// The window is SIZED at generation, so re-open it after each resize: `ToggleBag` twice
|
||
// is the reference's own way of regenerating a frame for a bag whose size changed.
|
||
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();
|
||
}
|
||
// The core regression: a 6-slot bag is much shorter than the old fixed 260-tall slab.
|
||
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());
|
||
}
|
||
|
||
/// A fixture backpack with a 5-stack of Tough Jerky in slot 1, the bag opened — shared setup for
|
||
/// the shift-click/split tests below. Returns the slot-1 button's screen center.
|
||
fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
|
||
for file in BAG_UI {
|
||
load_xml(s, file);
|
||
}
|
||
load_xml(s, "MerchantFrame.xml");
|
||
load_xml(s, "StackSplit.xml");
|
||
s.set_money(0);
|
||
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
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 stack-split trigger — SHIFT + left-click on an unlocked stack of ≥2, the reference fork
|
||
/// verbatim (ContainerFrame.lua:567-577), driven through the `set_modifiers` mirror the cursor
|
||
/// arc landed. Nothing is picked up: the spinner opens against the still-seated stack.
|
||
#[test]
|
||
fn shift_click_on_a_stack_opens_the_split_frame() {
|
||
let _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());
|
||
}
|
||
|
||
/// B180 — the split dialog's parchment plate fills the whole 172×96 frame. The reference authors
|
||
/// the plate 256×32 with NO anchors, a vestigial size the real client never renders (the TexCoords
|
||
/// crop exactly 172×96 out of the 256×128 art — the frame's own size, a complete panel drawn 1:1
|
||
/// over it). 1308 first dodged this by dropping the size; 1310 then landed the byte-verified law
|
||
/// (an anchor-less region gets an implicit SetAllPoints at creation, size unread under the two
|
||
/// corners) and restored the ref's own text — this pins the render through the real mechanism.
|
||
#[test]
|
||
fn the_split_frame_plate_fills_the_dialog() {
|
||
let _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());
|
||
}
|
||
|
||
/// B180's Bagnon follow-on (director, 08-14): the dialog opened UNDER an addon bag window —
|
||
/// Bagnon's windows are `frameStrata="HIGH"`, the dialog's own stratum, and our re-expression had
|
||
/// dropped the reference's `toplevel="true"`, so the dialog's Show raised nothing and lost the
|
||
/// level tie to the later-shown window (its child buttons at level+1 poked through; the plate did
|
||
/// not). With the ref's attrs restored, Show runs the verified raise (toplevel.rs: compact, then
|
||
/// top-occupied-plus-one) and the whole dialog lands above. The synthetic window stands in for
|
||
/// Bagnon: same stratum, shown after load, overlapping the dialog.
|
||
#[test]
|
||
fn the_split_frame_raises_over_a_same_stratum_window() {
|
||
let _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:SetSize(1024, 768)
|
||
local bg = w:CreateTexture(nil, "BACKGROUND")
|
||
bg:SetTexture("Interface\\FakeBagnonBG")
|
||
bg:SetAllPoints()
|
||
w:Show()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
s.set_modifiers(true, false, false);
|
||
s.mouse_button(x, y, "LeftButton", true);
|
||
s.mouse_button(x, y, "LeftButton", false);
|
||
s.set_modifiers(false, false, false);
|
||
s.resolve();
|
||
|
||
assert!(
|
||
s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
|
||
"the spinner opened"
|
||
);
|
||
let (dialog, bagnon) = s
|
||
.eval::<(i64, i64)>("return StackSplitFrame:GetFrameLevel(), FakeBagnon:GetFrameLevel()")
|
||
.unwrap();
|
||
assert!(
|
||
dialog > bagnon,
|
||
"Show must raise the toplevel dialog over the same-stratum window \
|
||
(dialog level {dialog}, window level {bagnon})"
|
||
);
|
||
// The symptom itself: the plate paints AFTER the window's background in draw order.
|
||
let order: Vec<String> = s
|
||
.extract()
|
||
.iter()
|
||
.filter_map(|q| match &q.content {
|
||
QuadContent::Texture { path: Some(p), .. }
|
||
if p.contains("FakeBagnonBG") || p.contains("UI-MoneyFrame") =>
|
||
{
|
||
Some(p.clone())
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(order.len(), 2, "both textures on screen: {order:?}");
|
||
assert!(
|
||
order[0].contains("FakeBagnonBG"),
|
||
"the plate must draw over the window, not under it: {order:?}"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// Typed-digit entry in the split spinner (decision 1319) — the director's ask, and the deferral
|
||
/// this file's header carried since 0216. Pins the whole chain in one go: the dialog is in the
|
||
/// keyboard walk (`enableKeyboard`), a digit reaches its `OnChar`, the first digit REPLACES the
|
||
/// seeded 1 while later digits append, an over-max entry clamps instead of being rejected,
|
||
/// BACKSPACE drops a digit, and ENTER commits exactly as Okay does.
|
||
#[test]
|
||
fn typing_a_number_into_the_split_spinner_sets_the_count() {
|
||
let _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"
|
||
);
|
||
|
||
// A digit is consumed by the dialog — which is also what stops it firing action button 3.
|
||
assert!(s.char_input("3"), "the spinner consumed the digit");
|
||
assert_eq!(
|
||
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
|
||
3,
|
||
"the first digit REPLACES the seed rather than appending to it"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StackSplitText:GetText()").unwrap(),
|
||
"3",
|
||
"and the label follows"
|
||
);
|
||
|
||
// A second digit appends, then clamps to the 5-stack rather than being thrown away.
|
||
assert!(s.char_input("7"));
|
||
assert_eq!(
|
||
s.eval::<i64>("return StackSplitFrame.split").unwrap(),
|
||
5,
|
||
"37 against a 5-stack clamps to 5"
|
||
);
|
||
// BACKSPACE drops a digit off the clamped value.
|
||
assert!(s.frame_key_input("BACKSPACE"), "the dialog took BACKSPACE");
|
||
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 1);
|
||
|
||
// Type a real value and commit with ENTER — the Okay path, so the carry is picked up.
|
||
assert!(s.char_input("4"));
|
||
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 4);
|
||
assert!(s.key_input("ENTER"), "ENTER is consumed by the dialog");
|
||
assert!(
|
||
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
|
||
"ENTER commits and closes, like Okay"
|
||
);
|
||
let held = s.cursor_item().expect("ENTER committed the split");
|
||
assert_eq!(held.count, Some(4), "the typed count is what got picked up");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// Okay in the split spinner only picks the split carry up (ref/cursor.rs `SplitContainerItem` —
|
||
/// a pickup, not a self-contained move); a SUBSEQUENT placement is what actually queues the
|
||
/// `ContainerMove` with `count: Some(n)`, drained the same way any other container move is.
|
||
#[test]
|
||
fn split_okay_then_a_placement_queues_the_split_move() {
|
||
let _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());
|
||
|
||
// Bump the spinner from 1 to 3, then Okay — the carry lands on the cursor.
|
||
s.run("BenillaStackSplitRight_Click()").unwrap();
|
||
s.run("BenillaStackSplitRight_Click()").unwrap();
|
||
assert_eq!(s.eval::<i64>("return StackSplitFrame.split").unwrap(), 3);
|
||
s.run("BenillaStackSplitOkay_Click()").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
|
||
"Okay hides the spinner"
|
||
);
|
||
let held = s.cursor_item().expect("Okay picked up the split carry");
|
||
assert_eq!((held.bag, held.slot, held.count), (0, 1, Some(3)));
|
||
assert!(
|
||
s.take_container_moves().is_empty(),
|
||
"no move yet — only a pickup"
|
||
);
|
||
|
||
// Place the carry on slot 5 (empty) — NOW the move queues, carrying the split count.
|
||
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 any open split frame (ref ContainerFrame.lua:581) — even a click on an
|
||
/// unrelated, empty slot.
|
||
#[test]
|
||
fn a_plain_click_hides_an_open_split_frame() {
|
||
let _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());
|
||
}
|
||
|
||
/// Bag-slot item cooldowns through the shipped XML (decision 0263's deferral): a potion mid-
|
||
/// cooldown pushes its triple with the container snapshot; opening the bag runs the ref's
|
||
/// occupied-slot fork (`ContainerFrame_UpdateCooldown` → `GetContainerItemCooldown` →
|
||
/// `CooldownFrame_SetTimer`) and the slot grows a live sweep; a `BAG_UPDATE_COOLDOWN` refresh
|
||
/// with the cooldown gone hides it again.
|
||
#[test]
|
||
fn bag_slot_cooldown_sweeps_through_the_xml() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
s.tick(100.0); // a nonzero clock epoch, like the engine cooldown tests
|
||
|
||
let potion = |cooldown| ContainerSlot {
|
||
durability: None,
|
||
texture: Some("Interface\\Icons\\INV_Potion_49".into()),
|
||
count: 3,
|
||
quality: Some(1),
|
||
item_id: 118,
|
||
cooldown,
|
||
..Default::default()
|
||
};
|
||
let backpack = |cooldown| {
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(1, potion(cooldown));
|
||
ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots,
|
||
}
|
||
};
|
||
// 12 s remain of the potion category's 60 s: started at GetTime 52 (absolute-start triple).
|
||
s.set_container(0, Some(backpack(Some((52_000, 60_000, true)))));
|
||
s.run("OpenAllBags()").unwrap();
|
||
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(0)]);
|
||
s.resolve();
|
||
|
||
let sweep = |s: &UiScript| {
|
||
s.extract().iter().find_map(|q| match q.content {
|
||
QuadContent::Cooldown { fraction, .. } => Some(fraction),
|
||
_ => None,
|
||
})
|
||
};
|
||
let fraction = sweep(&s).expect("the bag slot sweeps");
|
||
assert!(
|
||
(fraction - 0.8).abs() < 1e-3,
|
||
"48 of 60 s elapsed: fraction {fraction}"
|
||
);
|
||
|
||
// The cooldown clears (a CLEAR_COOLDOWN, or it simply ran out before the re-push): the
|
||
// refresh event re-reads the now-cold triple and hides the widget.
|
||
s.set_container(0, Some(backpack(None)));
|
||
s.fire_event("BAG_UPDATE_COOLDOWN", vec![]);
|
||
assert_eq!(sweep(&s), None, "cold again after the refresh");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The bar's five bag buttons had NO hover at all — the director's "the bags are missing their
|
||
/// simple tooltips". These are the ref's plain `SetText` plates (MainMenuBarBagButtons.xml l.91-99
|
||
/// for the backpack, MainMenuBarBagButtons.lua l.86-96 for the four slots), NOT the two-line
|
||
/// newbie kind the micro buttons next to them use — so what's pinned here is the label, the
|
||
/// empty-slot fallback, and that they seat BESIDE the button rather than at the screen corner.
|
||
///
|
||
/// Those two bodies are the reference's OWN now (1751 window 3), which changes one thing this test
|
||
/// records: **the binding-key suffix appears on the four slots too.** The reference appends
|
||
/// `GetBindingKey("TOGGLEBAG"..(4 - (this:GetID() - CharacterBag0Slot:GetID())))` — its numbering
|
||
/// runs the bar right-to-left, so the slot next to the backpack is `TOGGLEBAG4`, our F11
|
||
/// (`bindings/commands.rs`, quoting ref Bindings.xml l.564-575). Our own handler never did this,
|
||
/// so the suffix is new here and is the reference's behaviour, not a regression. The backpack's
|
||
/// key is the reference's `TOGGLEBACKPACK` rather than the `OPENALLBAGS` ours read; both default
|
||
/// to B, so the string is unchanged and only the reason is.
|
||
#[test]
|
||
fn the_bar_bag_buttons_name_themselves_on_hover() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
// The suffix reads GetBindingKey live since 0997 — register the real command set the way
|
||
// the app's seed does, so the pin below is OPENALLBAGS's actual default.
|
||
s.register_bindings(&crate::bindings::registry_commands());
|
||
s.set_screen_size(1024.0, 768.0);
|
||
for file in BAG_UI {
|
||
load_xml(&s, file);
|
||
}
|
||
load_xml(&s, "MerchantFrame.xml");
|
||
s.resolve();
|
||
|
||
// The backpack: its label plus OPENALLBAGS's live key (default B; 0997's divergence note).
|
||
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:?}"
|
||
);
|
||
// Beside the button, not the default corner — the ref's ANCHOR_LEFT.
|
||
assert!(
|
||
s.eval::<bool>("return GameTooltip.default == nil").unwrap(),
|
||
"a bag button's plate is owner-anchored, never the default corner"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return GameTooltip:IsOwned(MainMenuBarBackpackButton)")
|
||
.unwrap(),
|
||
"…owned by the button it opened from"
|
||
);
|
||
|
||
// An empty bag slot falls back to the ref's EQUIP_CONTAINER rather than showing nothing.
|
||
hover(&mut s, "CharacterBag0Slot");
|
||
assert_eq!(
|
||
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
|
||
.unwrap(),
|
||
"Equip Container",
|
||
"an empty slot says what belongs in it"
|
||
);
|
||
|
||
// With a bag actually equipped there, the ref shows that BAG's own item tooltip instead — the
|
||
// SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot).
|
||
let mut inv: benilla_ui::script::InventorySlots = Default::default();
|
||
inv[20] = Some(benilla_ui::script::InvSlotView {
|
||
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());
|
||
}
|
||
|
||
/// One keyring container fed as the app feeds it: container −2 sized by the level ladder, with a
|
||
/// key in the first slot. `size` is the count `keyring_size(level)` would give.
|
||
fn keyring(size: u32, occupied: bool) -> ContainerState {
|
||
let mut slots = std::collections::HashMap::new();
|
||
if occupied {
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
// The Scarlet Key — the item the director's own character carries in keyring slot 1.
|
||
item_id: 7146,
|
||
count: 1,
|
||
quality: Some(1),
|
||
texture: Some("Interface\\Icons\\INV_Misc_Key_07".into()),
|
||
link: Some("|cffffffff|Hitem:7146:0:0:0|h[The Scarlet Key]|h|r".into()),
|
||
..Default::default()
|
||
},
|
||
);
|
||
}
|
||
ContainerState {
|
||
name: Some("Keyring".into()),
|
||
num_slots: size,
|
||
slots,
|
||
}
|
||
}
|
||
|
||
/// Seat a player at `level`. **The keyring's size is the REFERENCE's own level ladder now** —
|
||
/// `GetKeyRingSize` (ContainerFrame.lua l.773-786: 4, then 8 at 40, 12 at 50, 16 above 60) reads
|
||
/// `UnitLevel("player")`, where ours read the container's fed `num_slots` back (decision 0765).
|
||
/// Both derive from the same ladder the server enforces (`Player::GetMaxKeyringSize`), so this is
|
||
/// the swap becoming FAITHFUL rather than a behaviour change — but the level is now the input, and
|
||
/// a fixture that leaves it 0 gets a 4-slot ring.
|
||
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`. The engine exposes no
|
||
/// `GetTexture`, so "which art is this frame wearing" is asked of the resolved draw list — which is
|
||
/// the stronger question anyway (it answers what would actually be on screen).
|
||
fn drawn_with(s: &mut UiScript, needle: &str) -> usize {
|
||
s.resolve();
|
||
s.extract()
|
||
.iter()
|
||
.filter(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)))
|
||
.count()
|
||
}
|
||
|
||
/// Load the whole bar+bag surface the keyring spans: its button seats on the action bar (BAG_UI
|
||
/// carries ActionBar.xml) and its window is an ordinary `ContainerFrame` at `KEYRING_CONTAINER`
|
||
/// since decision 1751, so the reference's own file is load-bearing too. `ErrorsFrame.xml` is the
|
||
/// extra: `PutKeyInKeyRing` reports a full ring through `UIErrorsFrame`.
|
||
fn keyring_surface(s: &UiScript) {
|
||
for file in BAG_UI {
|
||
load_xml(s, file);
|
||
}
|
||
load_xml(s, "MerchantFrame.xml");
|
||
load_xml(s, "ErrorsFrame.xml");
|
||
}
|
||
|
||
/// **The gate** (decision 0765): no key ⇒ no keyring anywhere on the bar — the button is hidden and
|
||
/// the bar's two right-hand strips wear the ordinary dwarf plate. The first key flips all of it:
|
||
/// the button appears, both strips swap to the keyring plate with the reference's own TexCoords,
|
||
/// and the performance meter slides from −227 to −235 to clear the new socket
|
||
/// (ref MainMenuBar_UpdateKeyRing, MainMenuBar.lua l.174-183).
|
||
///
|
||
/// This is the director-reported symptom itself: a character holding a key saw no keyring at all.
|
||
#[test]
|
||
fn the_first_key_puts_the_keyring_on_the_bar() {
|
||
let _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);
|
||
|
||
// Keyless: the button is hidden and the plate is the dwarf one.
|
||
s.set_has_key(false);
|
||
s.run("MainMenuBar_UpdateKeyRing()").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
|
||
"no key ⇒ no keyring button"
|
||
);
|
||
assert_eq!(
|
||
drawn_with(&mut s, "UI-MainMenuBar-KeyRing"),
|
||
0,
|
||
"no key ⇒ every bar strip wears the ordinary dwarf plate"
|
||
);
|
||
|
||
// A key lands. The app pushes HasKey and the wire's BAG_UPDATE reaches the button's OnEvent —
|
||
// the exact runtime path, not a direct call.
|
||
s.set_has_key(true);
|
||
s.set_container(-2, Some(keyring(8, true)));
|
||
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
|
||
assert!(
|
||
s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
|
||
"the first key reveals the keyring button"
|
||
);
|
||
assert_eq!(
|
||
drawn_with(&mut s, "UI-MainMenuBar-KeyRing"),
|
||
2,
|
||
"exactly the two RIGHT-hand strips swap to the keyring plate"
|
||
);
|
||
for (strip, top, bottom) in [
|
||
("MainMenuBarTexture2", 0.6640625, 1.0),
|
||
("MainMenuBarTexture3", 0.1640625, 0.5),
|
||
] {
|
||
let (t, b) = s
|
||
.eval::<(f64, f64)>(&format!(
|
||
"local _, _, top, bottom = {strip}:GetTexCoord() return top, bottom"
|
||
))
|
||
.unwrap();
|
||
assert!(
|
||
(t - top).abs() < 1e-9 && (b - bottom).abs() < 1e-9,
|
||
"{strip} keeps the reference's own band — got {t}..{b}, want {top}..{bottom}"
|
||
);
|
||
}
|
||
// Geometry cross-check against the reference's own chain. The ref seats its backpack button at
|
||
// BOTTOMRIGHT (-6, 2) and steps 37px buttons left with -5 gaps, putting KeyRingButton's left
|
||
// edge at -234 from the bar's right; ours steps 36px buttons with -6 gaps and lands at -235.
|
||
// Within a pixel of the real bar — which is the check that matters, because the socket the
|
||
// button sits in is PAINTED INTO the keyring plate and cannot be nudged to meet it.
|
||
let bar_right = s.eval::<f64>("return MainMenuBar:GetRight()").unwrap();
|
||
let button_left = s.eval::<f64>("return KeyRingButton:GetLeft()").unwrap();
|
||
assert!(
|
||
(bar_right - button_left - 235.0).abs() < 1.5,
|
||
"the keyring button must land in the plate's painted socket — the ref's own -234, got {}",
|
||
bar_right - button_left
|
||
);
|
||
|
||
// The performance meter clears the new socket (ref l.180: -227 → -235).
|
||
let perf_right = s
|
||
.eval::<f64>("return MainMenuBarPerformanceBarFrame:GetRight()")
|
||
.unwrap();
|
||
let bar_right = s.eval::<f64>("return MainMenuBar:GetRight()").unwrap();
|
||
assert!(
|
||
(bar_right - perf_right - 235.0).abs() < 0.01,
|
||
"the meter slid to -235 with the keyring up (got {})",
|
||
bar_right - perf_right
|
||
);
|
||
|
||
// And it reverts: destroying the last key takes the keyring back off the bar (our divergence
|
||
// from the ref's one-way saved-variable latch — see MainMenuBar_UpdateKeyRing).
|
||
s.set_has_key(false);
|
||
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
|
||
assert!(
|
||
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
|
||
"losing the last key takes the button away again"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The window itself: clicking the button opens a container titled "Keyring", stitched from the
|
||
/// `-Keyring` plate, showing exactly the level-gated slot count — and it jingles rather than
|
||
/// rustling (KeyRingOpen/KeyRingClose, ref ContainerFrame.lua l.116-138).
|
||
#[test]
|
||
fn the_keyring_button_opens_a_keyring_window() {
|
||
let _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 level-44 character: 8 usable slots (the ladder's 40..49 rung). The LEVEL is what decides
|
||
// now — see `seat_player_at_level` — and the feed carries the same 8 so the two agree.
|
||
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"
|
||
);
|
||
// 8 usable slots: the window is GENERATED at the keyring's own size, so exactly 8 of the
|
||
// template's 36 item buttons are shown — the reference's own
|
||
// `for j = size + 1, MAX_CONTAINER_ITEMS do …:Hide() end` tail, no keyring-specific code.
|
||
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");
|
||
// The key is in the window (slot 1 of 8 is the LAST chain button — size - physIndex + 1).
|
||
assert_eq!(
|
||
s.eval::<i64>("return BenillaGetContainerItemID(KEYRING_CONTAINER, 1)")
|
||
.unwrap(),
|
||
7146
|
||
);
|
||
|
||
s.run("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 slot (ref PutKeyInKeyRing), and a
|
||
/// full keyring refuses out loud — where the reference prints nothing at all, having passed a
|
||
/// GlobalStrings name that was never defined.
|
||
#[test]
|
||
fn a_key_dropped_on_the_button_files_itself() {
|
||
let _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 the key on the cursor (picked out of the backpack).
|
||
s.set_container(-2, Some(keyring(4, true)));
|
||
s.set_container(
|
||
0,
|
||
Some(ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots: std::collections::HashMap::from([(
|
||
3,
|
||
ContainerSlot {
|
||
item_id: 7146,
|
||
count: 1,
|
||
..Default::default()
|
||
},
|
||
)]),
|
||
}),
|
||
);
|
||
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
|
||
s.run("PickupContainerItem(0, 3)").unwrap();
|
||
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
|
||
s.run("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"
|
||
);
|
||
|
||
// Now full: the click refuses with the error line instead of queueing anything.
|
||
// Every slot needs a TEXTURE, not just an item id: the reference's `PutKeyInKeyRing` decides
|
||
// "is this slot free" from `GetContainerItemInfo`'s first return, which is the icon path
|
||
// (ContainerFrame.lua l.744-746). A slot carrying an id and no texture reads EMPTY.
|
||
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();
|
||
// The refusal is the REFERENCE's own `PutKeyInKeyRing`, reporting through the name its own
|
||
// line asks for — `NO_EMPTY_KEYRING_SLOTS`, which 1.12 never defines. So the drop is refused
|
||
// and **nothing is printed**: a real 1.12 bug, and faithful here on purpose.
|
||
//
|
||
// This client un-quirked it once, by assigning the string the reference plainly meant. Window
|
||
// 1 of 1751 retired that (ContainerFrameAdapters.xml's header records it), but the assignment
|
||
// itself outlived the decision in `BagFrame.xml` for a window and a half. Window 3 deleted that
|
||
// file, so the stated posture and the behaviour agree again. Restoring the repair is one line
|
||
// in the adapters if it is ever wanted; it is not silent either way.
|
||
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"
|
||
);
|
||
// And it does not RAISE either, which is the half this test could not assert until the bytes
|
||
// came back. `AddMessage`'s text is fetched through `lua_isstring 0x6f3510` whose failure edge
|
||
// is a silent jump to the function's own epilogue — no line, no error (wow-5875-re
|
||
// `addmessage-text-gate-silent-skip.md`, 4-worker cross-check). Ours took an mlua `String` and
|
||
// raised, which put a script-error dialog on an ordinary player gesture; the fix is in
|
||
// `messageframe/mod.rs::message_text` and its own test carries the full law.
|
||
//
|
||
// So a full keyring is a DEAD CLICK on a real 1.12 client, and now here.
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
|
||
/// icon down into **that** container's bag-bar button and nobody else's, along the curves read out of
|
||
/// `ForcedBackpackItem.m2` — pop in, hang, fall, shrink away — and hides itself at the end.
|
||
///
|
||
/// This is the whole observable contract of `BenillaItemPushAnim_*`: the routing (which button), the
|
||
/// motion (starts a fall above the button, lands centred on it), the fade/scale shape (the file's
|
||
/// keys), and the CLAMP end (one play, then gone). It is also the regression net for the OnUpdate
|
||
/// gate — an anim frame left shown would keep ticking forever.
|
||
#[test]
|
||
fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.resolve();
|
||
|
||
let shown = |s: &UiScript, name: &str| {
|
||
s.eval::<bool>(&format!("return {name}BenillaItemPush:IsShown()"))
|
||
.unwrap()
|
||
};
|
||
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
|
||
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
|
||
s.eval::<(f32, f32)>(&format!(
|
||
"local a, b = {name}BenillaItemPush, {name} \
|
||
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
|
||
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
|
||
return ax - bx, ay - by"
|
||
))
|
||
.unwrap()
|
||
};
|
||
let size = |s: &UiScript, name: &str| {
|
||
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetWidth()"))
|
||
.unwrap()
|
||
};
|
||
let alpha = |s: &UiScript, name: &str| {
|
||
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetAlpha()"))
|
||
.unwrap()
|
||
};
|
||
// The card's centre relative to its button's BOTTOMRIGHT corner — the reference's OWN frame of
|
||
// reference for this widget (it anchors the `<Model>`'s BOTTOMRIGHT to the button's), and the
|
||
// only one the numbers are stable in: the card's placement depends on the anchor offset and
|
||
// the M2 bounding box, NOT on how big the button is. Measuring against the button's centre
|
||
// instead is what let 0887's placement look plausible.
|
||
let corner = |s: &UiScript, name: &str| -> (f32, f32) {
|
||
s.eval::<(f32, f32)>(&format!(
|
||
"local a, b = {name}BenillaItemPush, {name} \
|
||
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
|
||
return ax - b:GetRight(), ay - b:GetBottom()"
|
||
))
|
||
.unwrap()
|
||
};
|
||
|
||
// Nothing is animating until a push arrives.
|
||
for b in [
|
||
"MainMenuBarBackpackButton",
|
||
"CharacterBag1Slot",
|
||
"KeyRingButton",
|
||
] {
|
||
assert!(!shown(&s, b), "{b}'s card starts hidden");
|
||
}
|
||
|
||
// A push into equipped bag 2. `arg1` is the reference's own vocabulary since 1751 window 3 —
|
||
// the button's INVENTORY-slot id, which is what `ItemAnim_OnEvent` compares against — so bag 2
|
||
// is `CharacterBag1Slot`'s 21, not the container id 2 this used to send (`ui_loot.rs`
|
||
// `push_container`, the `0x491bb5` selector).
|
||
s.fire_event(
|
||
"ITEM_PUSH",
|
||
vec![
|
||
benilla_ui::script::ScriptValue::Int(21),
|
||
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
|
||
],
|
||
);
|
||
s.resolve();
|
||
assert!(shown(&s, "CharacterBag1Slot"), "bag 2's card plays");
|
||
for b in [
|
||
"MainMenuBarBackpackButton",
|
||
"CharacterBag0Slot",
|
||
"KeyRingButton",
|
||
] {
|
||
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
|
||
}
|
||
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
|
||
let drawn = |s: &UiScript, icon: &str| {
|
||
s.extract()
|
||
.iter()
|
||
.filter(
|
||
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == icon),
|
||
)
|
||
.count()
|
||
};
|
||
assert_eq!(
|
||
drawn(&s, "Interface\\Icons\\INV_Misc_Bag_08"),
|
||
1,
|
||
"the card wears the pushed item's icon (ITEM_PUSH's arg2)"
|
||
);
|
||
|
||
// Every figure below is the REFERENCE's, from wow-re's §5-cross-checked
|
||
// `modelframe-implicit-size-law.md` §3 — not a ratio we chose. The three it publishes
|
||
// (t = 0, 0.133, 1.0) are asserted to a tenth of a pixel; the rest assert the SHAPE of the
|
||
// motion, which is where 0887's authored placement actually went wrong.
|
||
//
|
||
// What changed from 0887, and why the old numbers looked right: it measured against the
|
||
// button's CENTRE and drove both axes off one shared 0..1 "drop" curve, which happens to land
|
||
// within ~1 px of the truth in y for a 36 px button. In x it was wrong in DIRECTION — the card
|
||
// drifts RIGHT as it falls, and the old code had it starting left and ending centred.
|
||
|
||
// t=0: invisible, full size, parked high above the button. The .m2's alpha key (0.000, 0.0),
|
||
// scale key (0.000, 1.0), translation (0.000, (0,0)).
|
||
let (cx, cy) = corner(&s, "CharacterBag1Slot");
|
||
assert!(
|
||
(cx + 30.14).abs() < 0.1 && (cy - 68.99).abs() < 0.1,
|
||
"starts at the ref's (-30.14, 68.99) from the button's bottom-right: got ({cx}, {cy})"
|
||
);
|
||
assert!(
|
||
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
|
||
"the quad is 0.0288 model units, not the 0.03 that 0887 rounded it to: got {}",
|
||
size(&s, "CharacterBag1Slot")
|
||
);
|
||
assert!(
|
||
alpha(&s, "CharacterBag1Slot") < 0.01,
|
||
"fades in from nothing"
|
||
);
|
||
|
||
// t=0.133: fully faded in, at the 1.2x swell.
|
||
s.tick(0.133);
|
||
s.resolve();
|
||
assert!(
|
||
(alpha(&s, "CharacterBag1Slot") - 1.0).abs() < 0.02,
|
||
"opaque by the alpha track's second key"
|
||
);
|
||
assert!(
|
||
(size(&s, "CharacterBag1Slot") - 44.24).abs() < 0.1,
|
||
"swollen to the ref's 44.24 px peak: got {}",
|
||
size(&s, "CharacterBag1Slot")
|
||
);
|
||
// The pop moves the centre by a twentieth of a pixel, and that tiny drift is the tell that the
|
||
// bone's PIVOT is not the quad's centre — scaling up pushes the centre away from the pivot,
|
||
// scaling down pulls it in, one mechanism at both ends. Folding the pivot into the centre (the
|
||
// obvious simplification) loses this and misses the landing point by a fifth of a pixel.
|
||
let (px, py) = corner(&s, "CharacterBag1Slot");
|
||
assert!(
|
||
(px + 30.18).abs() < 0.02 && (py - 68.98).abs() < 0.02,
|
||
"the 1.2x pop drifts the centre off the pivot by the ref's (-0.046, -0.015): got ({px}, {py})"
|
||
);
|
||
|
||
// t=0.267: back to the icon's own size, still opaque, still parked.
|
||
s.tick(0.134);
|
||
s.resolve();
|
||
assert!(
|
||
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
|
||
"settled back at the scale track's third key"
|
||
);
|
||
let (_, cy) = corner(&s, "CharacterBag1Slot");
|
||
assert!(
|
||
(cy - 68.99).abs() < 0.1,
|
||
"has not started falling: got {cy}"
|
||
);
|
||
|
||
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
|
||
// second is a hang. The other two tracks are already running down, though: the three curves
|
||
// keep different schedules, which is the thing a "hold everything then drop" reading would get
|
||
// wrong (scale/alpha are 0.687/0.682 here, on the 0.267→1.000 ramps).
|
||
s.tick(0.233);
|
||
s.resolve();
|
||
let (_, cy) = corner(&s, "CharacterBag1Slot");
|
||
assert!(
|
||
(cy - 69.0).abs() < 0.1,
|
||
"has not moved yet at the half second: got {cy}"
|
||
);
|
||
assert!(
|
||
(size(&s, "CharacterBag1Slot") - 25.31).abs() < 0.2,
|
||
"but is already dwindling: got {}",
|
||
size(&s, "CharacterBag1Slot")
|
||
);
|
||
assert!(
|
||
(alpha(&s, "CharacterBag1Slot") - 0.682).abs() < 0.02,
|
||
"and already fading: got {}",
|
||
alpha(&s, "CharacterBag1Slot")
|
||
);
|
||
|
||
// t=0.75: halfway through the fall — and moving RIGHT as it drops, which is the axis 0887 had
|
||
// backwards. The translation is per-axis (+0.0104, -0.0402 model units), not one shared curve.
|
||
s.tick(0.25);
|
||
s.resolve();
|
||
let (cx, cy) = corner(&s, "CharacterBag1Slot");
|
||
assert!(
|
||
(cx + 23.34).abs() < 0.2,
|
||
"drifted RIGHT by half the 13.3 px total: got {cx}"
|
||
);
|
||
assert!(
|
||
(cy - 43.33).abs() < 0.2,
|
||
"half of the 51.4 px fall travelled by t=0.75: got {cy}"
|
||
);
|
||
|
||
// Just short of the end: down on the button, shrunk to nothing, faded out.
|
||
s.tick(0.24);
|
||
s.resolve();
|
||
let (cx, cy) = corner(&s, "CharacterBag1Slot");
|
||
assert!(
|
||
(cx + 16.87).abs() < 0.2 && (cy - 18.68).abs() < 0.2,
|
||
"arrives at the ref's landing point: got ({cx}, {cy})"
|
||
);
|
||
// Which is also, within a couple of pixels, the button's own centre — the reference's card
|
||
// really does drop INTO the bag button, and that is the one thing 0887 got right for the wrong
|
||
// reason. The corner-relative assertion above is the exact one; this is the sanity check, and
|
||
// its tolerance is a button-size term, not slack: the landing point is fixed relative to the
|
||
// button's BOTTOMRIGHT, so the offset from its CENTRE moves by half of any size change. The
|
||
// bar's buttons are 37x37 now — `ItemButtonTemplate`'s own size, inherited through the
|
||
// reference's `PaperDollItemSlotButtonTemplate` — where the deleted BagFrame.xml authored 36.
|
||
let (dx, dy) = offset(&s, "CharacterBag1Slot");
|
||
assert!(
|
||
dx.abs() < 2.0 && dy.abs() < 2.0,
|
||
"lands on the button it went into: got ({dx}, {dy})"
|
||
);
|
||
assert!(
|
||
size(&s, "CharacterBag1Slot") < 2.0,
|
||
"collapsed to the scale track's 0.0144"
|
||
);
|
||
assert!(alpha(&s, "CharacterBag1Slot") < 0.05, "faded out");
|
||
|
||
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
|
||
s.tick(0.05);
|
||
assert!(
|
||
!shown(&s, "CharacterBag1Slot"),
|
||
"one play, then gone (and no OnUpdate left running)"
|
||
);
|
||
|
||
// The keyring is a real destination, not a rounding of the backpack.
|
||
s.fire_event(
|
||
"ITEM_PUSH",
|
||
vec![
|
||
benilla_ui::script::ScriptValue::Int(-2),
|
||
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Key_03".into()),
|
||
],
|
||
);
|
||
assert!(shown(&s, "KeyRingButton"), "the keyring card plays");
|
||
assert!(
|
||
!shown(&s, "MainMenuBarBackpackButton"),
|
||
"and the backpack's does not"
|
||
);
|
||
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// **An addon that replaces `ToggleBackpack` gets the bag button's click.**
|
||
///
|
||
/// This is how a 1.12 addon customises anything — replace a global, expect the client to call your
|
||
/// replacement. `Bagnon_Core/core/Overrides.lua` is the canonical shape:
|
||
///
|
||
/// ```lua
|
||
/// local bToggleBackpack = ToggleBackpack
|
||
/// ToggleBackpack = function() … end
|
||
/// ```
|
||
///
|
||
/// It was inert here. The 'B' binding and the bag button called `BenillaBagToggle_OnClick`
|
||
/// directly, so nothing ever looked up `ToggleBackpack`; Bagnon loaded, showed up in the AddOns
|
||
/// list, replaced the globals, and the player still got the stock bags. **The director saw that and
|
||
/// no instrument here could** — the corpus survey loads addons and fires events, but never clicks.
|
||
///
|
||
/// The class is much wider than one addon: every hook-a-global addon is silently inert wherever our
|
||
/// UI calls a benilla-named equivalent. This test pins the bag path; the rule is general.
|
||
#[test]
|
||
fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
|
||
let _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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
|
||
// Bagnon's exact idiom: capture the original, replace the global.
|
||
s.run(
|
||
r#"
|
||
HOOK_RAN = 0
|
||
local original = ToggleBackpack
|
||
ToggleBackpack = function() HOOK_RAN = HOOK_RAN + 1 end
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
// The button's own OnClick path — what the B binding and a plain backpack-button click run.
|
||
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"
|
||
);
|
||
// And the addon's override SUPPRESSED the stock behaviour, which is the point of hooking.
|
||
assert!(
|
||
!open(&s, 0),
|
||
"the stock bag must not open when an addon has taken the verb over"
|
||
);
|
||
|
||
// The reference's other two names exist and are callable by an addon that wants the original.
|
||
assert!(
|
||
s.eval::<bool>("return type(OpenAllBags) == 'function' and type(ToggleBag) == 'function'")
|
||
.unwrap(),
|
||
"OpenAllBags and ToggleBag are the ref's names and addons hook them too"
|
||
);
|
||
}
|
||
|
||
/// **The equipped-bag slots carry the REFERENCE's names, and so do their icons.**
|
||
///
|
||
/// Ours were `BenillaBagBarSlot1..4`; the reference's are `CharacterBag0Slot..3` — 0-based, so
|
||
/// `CharacterBag0Slot` IS bag 1. Eight corpus addons index those names and found nil, including
|
||
/// **Bagnon**, which does `getglobal("CharacterBag0Slot"):GetScript("OnClick")`.
|
||
///
|
||
/// The same class as the `ToggleBackpack` bug the director found: our own name where the reference
|
||
/// has one, so every addon reaching for it is silently inert.
|
||
///
|
||
/// A Lua alias would not have been enough. `Bartender2/Alias.lua:352` takes
|
||
/// `CharacterBag0SlotIconTexture` — a name DERIVED from the frame's own, which only a real rename
|
||
/// produces. The icon is `$parentIconTexture` now, matching the reference's
|
||
/// `PaperDollItemSlotButtonTemplate`.
|
||
#[test]
|
||
fn the_bag_slots_carry_the_references_names_and_icon_names() {
|
||
let _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, "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"
|
||
);
|
||
}
|
||
|
||
// 0-based: CharacterBag0Slot is bag 1, and the off-by-one is the whole trap. Asked the way the
|
||
// reference asks it, which is the only way there is now: the bar is stock
|
||
// `MainMenuBarBagButtons.xml` since 1751's third window, its buttons carry no `bagId` field,
|
||
// and every one of its handlers recomputes `this:GetID() - CharacterBag0Slot:GetID() + 1` at
|
||
// the call site. The ids themselves come from `GetInventorySlotInfo("Bag0Slot")` = 20 through
|
||
// the sourced `PaperDollItemSlotButton_OnLoad`, which is what makes the arithmetic work.
|
||
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"
|
||
);
|
||
|
||
// Bagnon's exact reach.
|
||
assert!(
|
||
s.eval::<bool>("return getglobal('CharacterBag0Slot'):GetScript('OnClick') ~= nil")
|
||
.unwrap(),
|
||
"Bagnon captures this OnClick to replace the bag behaviour"
|
||
);
|
||
}
|
||
|
||
/// The backpack button's checked law after the stated divergence (BenillaBagToggle_OnClick's
|
||
/// comment): checked belongs to the WINDOWS' OnShow/OnHide writes, and the button's XML click
|
||
/// wrapper only undoes the CheckButton widget's pre-handler flip.
|
||
///
|
||
/// Driven through REAL input (the widget flip fires only on real clicks) over the case 1494
|
||
/// changed: backpack shut with another bag's window open. That used to be the close-all arm —
|
||
/// nothing transitioned the backpack and only the undo kept the button from ending lit-while-shut.
|
||
/// It is now the OPEN arm, so the pin runs the whole cycle: the click opens bag 0 beside bag 1 and
|
||
/// its OnShow lights the button, the next click closes everything and the OnHide clears it.
|
||
#[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, "MerchantFrame.xml");
|
||
s.set_money(0);
|
||
// The reference's `ToggleBag` opens nothing for a container of size 0, so the backpack has to
|
||
// be fed — our own windows existed statically and opened regardless.
|
||
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()
|
||
};
|
||
// Bag 1's window alone is open; the backpack window and its button are dark.
|
||
s.run("ToggleBag(1)").unwrap();
|
||
assert!(!open(&s, 0));
|
||
assert!(!checked(&mut s));
|
||
|
||
// A REAL click on the backpack button (the widget flip fires only on real input): close-all.
|
||
s.resolve();
|
||
let r: Vec<f32> = s
|
||
.eval(
|
||
"local f = MainMenuBarBackpackButton \
|
||
return { f:GetLeft() + f:GetWidth() / 2, f:GetBottom() + f:GetHeight() / 2 }",
|
||
)
|
||
.unwrap();
|
||
s.mouse_move(r[0], r[1]);
|
||
s.mouse_button(r[0], r[1], "LeftButton", true);
|
||
s.mouse_button(r[0], r[1], "LeftButton", false);
|
||
assert!(
|
||
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"
|
||
);
|
||
|
||
// The second real click: bag 0 is open now, so this is the close-all arm — both windows go,
|
||
// and the OnHide write clears the ring.
|
||
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());
|
||
}
|