- The social window: friends, ignore and `/who`, in the reference's own two-level arrangement with its inner tabs and per-row right-click menus. The lists ride the real wire — guid-keyed with no names, resolved through the name cache, and capped at 50 friends / 25 ignores because those are the client's own caps — and the ignore list does its actual job: an ignored speaker's chat is dropped silently, a whisper additionally tells the sender, and a duel challenge is auto-declined. Guild ships as a disabled tab, which is what a guildless character sees. - The escape menu: ESC's last rung finally opens something. The game menu lands at the reference's own size and ladder, and leaving works — logout and quit with the server owning the clock (instant at an inn, refused in combat, or the 20-second countdown), narrated by the real camp/quit dialogs. While the menu holds the center nothing else opens, and the bag bar greys out under it. - Items on the action bar act like items. An equipped trinket was a dead button; the reference's use law is two stages — not equippable means use, equippable and already worn means use in place — over its exact search order (equipment first, then the bags, then the backpack, then the keyring; never the bank). What can go on the bar is now the reference's own two filters, byte-read: an item with an on-use spell or one you can equip, and no passive spells — and dropping a passive there speaks the real client's own line, "You can't put a passive ability in the action bar." - A quest-starter item opens its quest instead of "The item was not found." The question goes to the item's own guid, never through item-use — which the server refuses for every one of the 215 quest-starter items — and refusing the quest says what the client says, then closes the window. - For the toolbox: a pixel pick that reports everything under the cursor all the way back, a lens that says which render phase a draw landed in, and switches that separate "discarded" from "lost depth" from "never drawn" — built while cornering a flickering tent, whose fix ships in the next snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
503 lines
20 KiB
Rust
503 lines
20 KiB
Rust
//! The shipped `assets/ui/GameMenuFrame.xml` — the frame ESC opens (decision 0674).
|
||
//!
|
||
//! What these guard, in order: the ref geometry (195×246 and the eight-button ladder, because the
|
||
//! whole point of shipping five dead buttons is that the frame keeps its exact silhouette); the
|
||
//! disabled five; the three live buttons' wire intents and sounds; the ESC ladder's two new rungs
|
||
//! (open when nothing is left to eat, close before everything else); the micro button's `clicked`
|
||
//! toggle; the native-center rule that makes the menu take the screen (windows close on the way in,
|
||
//! and nothing opens while it is up); and the camp/quit countdown dialogs end to end.
|
||
|
||
use benilla_ui::script::{
|
||
ContainerSlot, ContainerState, LootRow, LootState, SessionRequest, SoundRequest, UiScript,
|
||
};
|
||
|
||
/// The engine the menu needs behind it: fonts, the panel manager + popup engine, and the menu.
|
||
/// `extra` adds the files a given test wants in the way (a bag, a loot window).
|
||
fn harness_with(extra: &[&str]) -> UiScript {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
let files: Vec<&str> = ["Fonts.xml", "UiPanels.xml"]
|
||
.into_iter()
|
||
.chain(extra.iter().copied())
|
||
.chain(std::iter::once("GameMenuFrame.xml"))
|
||
.collect();
|
||
for file in files {
|
||
let text = std::fs::read_to_string(
|
||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||
.join("assets/ui")
|
||
.join(file),
|
||
)
|
||
.unwrap();
|
||
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
||
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"{file}: loader errors: {:?}",
|
||
report.errors
|
||
);
|
||
}
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
s
|
||
}
|
||
|
||
fn harness() -> UiScript {
|
||
harness_with(&[])
|
||
}
|
||
|
||
/// The eight buttons, top to bottom — the ref's own order (GameMenuFrame.xml l.48-186).
|
||
const LADDER: [&str; 8] = [
|
||
"GameMenuButtonOptions",
|
||
"GameMenuButtonSoundOptions",
|
||
"GameMenuButtonUIOptions",
|
||
"GameMenuButtonKeybindings",
|
||
"GameMenuButtonMacros",
|
||
"GameMenuButtonLogout",
|
||
"GameMenuButtonQuit",
|
||
"GameMenuButtonContinue",
|
||
];
|
||
|
||
/// The frame's geometry, quoted from the reference: 195×246 centered, each button 144×21, the first
|
||
/// centered on the frame's TOP at −37, each next 1 px under the last, and Return to Game hung 16
|
||
/// under Exit Game — the single gap that makes the last entry read as separate. Shipping the five
|
||
/// unbacked buttons disabled (rather than cutting them) is exactly what keeps this true, so it is
|
||
/// the first thing to break if someone "tidies" the ladder.
|
||
#[test]
|
||
fn the_menu_has_the_reference_frame_and_button_ladder() {
|
||
let mut s = harness();
|
||
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
|
||
s.resolve();
|
||
|
||
let (w, h) = s
|
||
.eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()")
|
||
.unwrap();
|
||
assert_eq!((w, h), (195.0, 246.0), "the ref frame size");
|
||
|
||
let top = s.eval::<f64>("return GameMenuFrame:GetTop()").unwrap();
|
||
// Button i's own top, measured down from the frame's: the first is centered at −37 (so its top
|
||
// is 37 − 21/2 = 26.5 below), then a 22 px stride (21 tall + the 1 px gap), with the last
|
||
// pushed a further 15 (its −16 anchor instead of −1).
|
||
let mut expected = top - 26.5;
|
||
for (i, name) in LADDER.iter().enumerate() {
|
||
let (bw, bh, btop, bleft) = s
|
||
.eval::<(f64, f64, f64, f64)>(&format!(
|
||
"return {name}:GetWidth(), {name}:GetHeight(), {name}:GetTop(), {name}:GetLeft()"
|
||
))
|
||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||
assert_eq!(
|
||
(bw, bh),
|
||
(144.0, 21.0),
|
||
"{name} is a GameMenuButtonTemplate"
|
||
);
|
||
assert!(
|
||
(btop - expected).abs() < 0.001,
|
||
"{name} top: expected {expected}, got {btop}"
|
||
);
|
||
// Every button is centered in the frame (the ladder is one column).
|
||
assert!(
|
||
(bleft - (512.0 - 72.0)).abs() < 0.001,
|
||
"{name} is centered in the 1024-wide screen"
|
||
);
|
||
expected -= if i == 6 { 21.0 + 16.0 } else { 21.0 + 1.0 };
|
||
}
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The five with nothing behind them are DISABLED — the director's call: the ladder keeps the ref's
|
||
/// shape, and a button that does nothing looks like it. The three that work are enabled.
|
||
#[test]
|
||
fn the_five_unbacked_buttons_are_disabled_and_the_three_live_ones_are_not() {
|
||
let s = harness();
|
||
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
|
||
|
||
for name in &LADDER[..5] {
|
||
assert!(
|
||
!s.eval::<bool>(&format!("return {name}:IsEnabled()"))
|
||
.unwrap(),
|
||
"{name} has no panel behind it and must read that way"
|
||
);
|
||
}
|
||
for name in &LADDER[5..] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return {name}:IsEnabled()"))
|
||
.unwrap(),
|
||
"{name} is live"
|
||
);
|
||
}
|
||
// The labels are the reference GlobalStrings, read back through the globals (not literals).
|
||
assert_eq!(
|
||
s.eval::<String>("return GameMenuButtonContinue:GetText()")
|
||
.unwrap(),
|
||
"Return to Game"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return GameMenuButtonOptions:GetText()")
|
||
.unwrap(),
|
||
"Video Options"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The ESC ladder's two new rungs (`ToggleGameMenu`, UIParent.lua l.1485-1495): a press with
|
||
/// nothing left to eat OPENS the menu (igMainMenuOpen), and the next press closes it
|
||
/// (igMainMenuQuit — the reference's own choice of kit) without reaching any rung below.
|
||
#[test]
|
||
fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_it() {
|
||
let mut s = harness_with(&["MerchantFrame.xml", "Cooldown.xml", "BagFrame.xml"]);
|
||
s.set_money(0);
|
||
s.set_container(0, Some(backpack()));
|
||
|
||
// A press with a window open is eaten by CloseAllWindows — the menu stays down.
|
||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||
let _ = s.take_sounds();
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
|
||
"the press closed the bag"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
|
||
"and did NOT also open the menu — one eater per press"
|
||
);
|
||
|
||
// Nothing left: the next press opens it.
|
||
let _ = s.take_sounds();
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
|
||
assert!(s
|
||
.take_sounds()
|
||
.contains(&SoundRequest::KitName("igMainMenuOpen".into())));
|
||
|
||
// And the press after that closes it again.
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
|
||
assert!(s
|
||
.take_sounds()
|
||
.contains(&SoundRequest::KitName("igMainMenuQuit".into())));
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The micro button's form (`ToggleGameMenu(1)`, l.1466-1480) is a plain TOGGLE, not the ladder:
|
||
/// clicking it with a window open closes the window AND opens the menu in one go — the press-eating
|
||
/// rule is the ESC key's, not the button's.
|
||
#[test]
|
||
fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
|
||
let mut s = harness_with(&["MerchantFrame.xml", "Cooldown.xml", "BagFrame.xml"]);
|
||
s.set_money(0);
|
||
s.set_container(0, Some(backpack()));
|
||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
|
||
|
||
s.run("ToggleGameMenu(1)").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
|
||
"the click closed the bag"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
|
||
"…and opened the menu in the SAME click"
|
||
);
|
||
|
||
s.run("ToggleGameMenu(1)").unwrap();
|
||
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The native-center rule (`ShowUIPanel` l.697-702 + `CanOpenPanels`): opening the menu closes the
|
||
/// panel slots and the bags, and while it holds the center NOTHING else opens — which is what makes
|
||
/// "you can't open your bags with the ESC menu up" true rather than merely greyed.
|
||
#[test]
|
||
fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
|
||
let mut s = harness_with(&[
|
||
"MerchantFrame.xml",
|
||
"LootFrame.xml",
|
||
"Cooldown.xml",
|
||
"BagFrame.xml",
|
||
]);
|
||
s.set_money(0);
|
||
s.set_container(0, Some(backpack()));
|
||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||
s.set_loot(Some(LootState {
|
||
rows: vec![LootRow {
|
||
item_id: 0,
|
||
name: Some("Wool Cloth".into()),
|
||
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
|
||
quantity: 1,
|
||
quality: Some(1),
|
||
is_coin: false,
|
||
}],
|
||
}));
|
||
s.fire_event("LOOT_OPENED", vec![]);
|
||
assert!(s
|
||
.eval::<bool>("return GetLeftFrame():GetName() == \"BenillaLootFrame\"")
|
||
.unwrap());
|
||
|
||
s.run("ToggleGameMenu(1)").unwrap();
|
||
assert!(
|
||
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
|
||
"the panel slot vacated on the way in"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
|
||
"and the bags closed (CloseAllBags)"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return GetCenterFrame():GetName() == \"GameMenuFrame\"")
|
||
.unwrap(),
|
||
"the menu holds the CENTER slot, not the left one"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return CanOpenPanels() and true or false")
|
||
.unwrap(),
|
||
"a native-center frame is up: nothing may open"
|
||
);
|
||
|
||
// The refusal itself: a panel asked to show while the menu is up simply doesn't.
|
||
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return BenillaLootFrame:IsVisible()")
|
||
.unwrap(),
|
||
"ShowUIPanel refuses a left-area panel behind the menu"
|
||
);
|
||
|
||
// …and works again the moment the menu goes down.
|
||
s.run("HideUIPanel(GameMenuFrame)").unwrap();
|
||
s.run("ShowUIPanel(BenillaLootFrame)").unwrap();
|
||
assert!(s
|
||
.eval::<bool>("return BenillaLootFrame:IsVisible()")
|
||
.unwrap());
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The three live buttons: each plays its own reference kit and does its one thing. Logout and Exit
|
||
/// Game both queue on the session seam — the difference between them is entirely app-side (whether
|
||
/// the completed logout ends the process), which is why they look identical from here.
|
||
#[test]
|
||
fn the_live_buttons_queue_their_intents_and_play_their_kits() {
|
||
let mut s = harness();
|
||
|
||
// Return to Game — just closes, no intent.
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
let _ = s.take_sounds();
|
||
s.run("GameMenuButtonContinue:Click()").unwrap();
|
||
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
|
||
assert!(s
|
||
.take_sounds()
|
||
.contains(&SoundRequest::KitName("igMainMenuContinue".into())));
|
||
assert!(s.take_session_requests().is_empty());
|
||
|
||
// Logout — the request, and the menu goes away behind it.
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
let _ = s.take_sounds();
|
||
s.run("GameMenuButtonLogout:Click()").unwrap();
|
||
assert_eq!(s.take_session_requests(), vec![SessionRequest::Logout]);
|
||
assert!(s
|
||
.take_sounds()
|
||
.contains(&SoundRequest::KitName("igMainMenuLogout".into())));
|
||
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
|
||
|
||
// Exit Game — the same shape, its own kit.
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
let _ = s.take_sounds();
|
||
s.run("GameMenuButtonQuit:Click()").unwrap();
|
||
assert_eq!(s.take_session_requests(), vec![SessionRequest::Quit]);
|
||
assert!(s
|
||
.take_sounds()
|
||
.contains(&SoundRequest::KitName("igMainMenuQuit".into())));
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The camp countdown end to end (ref StaticPopup.lua l.564-582 + UIParent.lua l.304-315): the
|
||
/// server's non-instant answer becomes `PLAYER_CAMPING`, the dialog counts down from the server's
|
||
/// own 20 s, and closing it EARLY calls the logout off. The `%d %s` text is written by the popup
|
||
/// engine's countdown branch, not by the entry — a dialog that opened blank and stayed blank would
|
||
/// mean CAMP fell out of that which-list.
|
||
#[test]
|
||
fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
|
||
let mut s = harness();
|
||
s.fire_event("PLAYER_CAMPING", vec![]);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup_Visible(\"CAMP\") or \"\"")
|
||
.unwrap(),
|
||
"StaticPopup1",
|
||
"the camp dialog took an instance"
|
||
);
|
||
|
||
// One tick of the engine's countdown fills the text from CAMP_TIMER.
|
||
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.5)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||
.unwrap(),
|
||
"20 seconds until logout",
|
||
"the countdown text is the engine's, from the server's 20 s clock"
|
||
);
|
||
// Cancel is the only button (the ref's CAMP_NOW is commented out in 1.12).
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Button1:GetText()")
|
||
.unwrap(),
|
||
"Cancel"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
|
||
.unwrap(),
|
||
"no second button"
|
||
);
|
||
|
||
// Closing it early cancels the logout — twice over in the reference's own shape (the button's
|
||
// OnAccept, and the OnHide guard that catches every other way out); a doubled cancel is
|
||
// harmless on the wire, and losing it would strand the character mid-countdown.
|
||
s.run("StaticPopup1Button1:Click()").unwrap();
|
||
assert!(
|
||
s.take_session_requests()
|
||
.contains(&SessionRequest::CancelLogout),
|
||
"the early close called the logout off"
|
||
);
|
||
assert!(s
|
||
.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
|
||
.unwrap());
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// A countdown that RUNS OUT must not cancel itself: the engine zeroes `timeleft` before hiding, and
|
||
/// the entry's OnHide guard reads exactly that. (Get this wrong and every logout in the field is
|
||
/// cancelled by its own dialog at t=0 — the character never leaves.)
|
||
#[test]
|
||
fn a_countdown_that_expires_does_not_cancel_the_logout() {
|
||
let mut s = harness();
|
||
s.fire_event("PLAYER_CAMPING", vec![]);
|
||
s.run("StaticPopup_OnUpdate(StaticPopup1, 25)").unwrap();
|
||
assert!(
|
||
s.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
|
||
.unwrap(),
|
||
"the dialog closed itself at zero"
|
||
);
|
||
assert!(
|
||
!s.take_session_requests()
|
||
.contains(&SessionRequest::CancelLogout),
|
||
"…and did NOT call off the logout it was counting"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The quit dialog is the camp dialog under another name, plus the one thing a quit can do without
|
||
/// the server: "Exit now". And `LOGOUT_CANCEL` (the server's cancel ack) takes either one down.
|
||
#[test]
|
||
fn player_quiting_offers_exit_now_and_logout_cancel_closes_it() {
|
||
let mut s = harness();
|
||
s.fire_event("PLAYER_QUITING", vec![]);
|
||
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.5)").unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||
.unwrap(),
|
||
"20 seconds until exit"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Button1:GetText()")
|
||
.unwrap(),
|
||
"Exit now"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Button2:GetText()")
|
||
.unwrap(),
|
||
"Cancel"
|
||
);
|
||
s.run("StaticPopup1Button1:Click()").unwrap();
|
||
assert!(s
|
||
.take_session_requests()
|
||
.contains(&SessionRequest::ForceQuit));
|
||
|
||
// The server's own cancel ack hides whichever is up, without queueing anything further.
|
||
s.fire_event("PLAYER_CAMPING", vec![]);
|
||
assert!(s
|
||
.eval::<bool>("return StaticPopup_Visible(\"CAMP\") ~= nil")
|
||
.unwrap());
|
||
let _ = s.take_session_requests();
|
||
s.fire_event("LOGOUT_CANCEL", vec![]);
|
||
assert!(s
|
||
.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
|
||
.unwrap());
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The reference's own OnShow guard on both live buttons: a menu re-opened over a running countdown
|
||
/// shows them dead, because re-arming a logout that is already counting is meaningless.
|
||
///
|
||
/// Opened with the micro button's form on purpose — the ESC key can't get you here at all, which is
|
||
/// the next test.
|
||
#[test]
|
||
fn logout_and_exit_read_disabled_while_a_countdown_runs() {
|
||
let mut s = harness();
|
||
s.fire_event("PLAYER_CAMPING", vec![]);
|
||
s.run("ToggleGameMenu(1)").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return GameMenuButtonLogout:IsEnabled()")
|
||
.unwrap(),
|
||
"Logout is dead while the camp timer runs"
|
||
);
|
||
assert!(!s
|
||
.eval::<bool>("return GameMenuButtonQuit:IsEnabled()")
|
||
.unwrap());
|
||
|
||
// With the countdown gone, a re-opened menu has them back.
|
||
s.run("HideUIPanel(GameMenuFrame)").unwrap();
|
||
s.fire_event("LOGOUT_CANCEL", vec![]);
|
||
s.run("ToggleGameMenu(1)").unwrap();
|
||
assert!(s
|
||
.eval::<bool>("return GameMenuButtonLogout:IsEnabled()")
|
||
.unwrap());
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// ESC with a countdown up never reaches the menu: the popup rung is FIRST in the ladder
|
||
/// (`StaticPopup_EscapePressed`, l.1482) and CAMP is `hideOnEscape`, so the press dismisses the
|
||
/// dialog — which, per the entry's own OnHide, calls the logout off. Pressing ESC to "get out of"
|
||
/// a logout you didn't mean is therefore the whole gesture, and it must not also open the menu.
|
||
#[test]
|
||
fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
|
||
let mut s = harness();
|
||
s.fire_event("PLAYER_CAMPING", vec![]);
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
assert!(
|
||
s.eval::<bool>("return StaticPopup_Visible(\"CAMP\") == nil")
|
||
.unwrap(),
|
||
"the press dismissed the countdown"
|
||
);
|
||
assert!(
|
||
s.take_session_requests()
|
||
.contains(&SessionRequest::CancelLogout),
|
||
"…which called the logout off"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
|
||
"and the same press did NOT also open the menu — one eater per press"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// A one-item backpack (the escape/bag tests' fixture, duplicated so this file is self-contained).
|
||
fn backpack() -> ContainerState {
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
ContainerSlot {
|
||
bar_placeable: true,
|
||
durability: None,
|
||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||
count: 1,
|
||
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,
|
||
},
|
||
);
|
||
ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots,
|
||
}
|
||
}
|