Files
benilla-octo/crates/benilla-app/src/ui_script/game_menu_tests.rs
T
Sam SungandClaude Opus 5 0c247396b7 The ESC menu's Support rung opens the help window (#1807)
The ESC menu's Support rung was greyed with nothing behind it. The 1.15 era menu that the layer's ladder follows opens the help window from Support. In 1.12 that window is the stock `HelpFrame`, opened by the "?" micro button's `ToggleHelpFrame()` (`MainMenuBarMicroButtons.xml:217`).

Support is now live: it plays the menu click, closes the menu as the Macros rung does, and toggles the help window. Edit Mode stays greyed.

Test: `support_closes_the_menu_and_toggles_the_help_window` clicks the rung and checks the toggle and the closed menu; the live-entries test now lists Support.

Fixes #1800

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bb434a181f6a3815ec6fe3e5ace89c864fa1808c)

Absorbed into the octo fork. Ported by hand: upstream wires Support in GameMenuAdapters.xml, a Lua adapter layered over stock FrameXML that this fork does not carry — our menu declares its own rungs in GameMenuFrame.xml, where GameMenuButtonSupport was GameMenuButton_Pending (SetText + Disable). It now sets its text on load and, on click, plays igMainMenuOption, hides the menu as the Macros rung does and calls ToggleHelpFrame(). Our rungs carry no Benilla prefix, so upstream's test names are retargeted; our own narrowing_the_frame_keeps_the_button_column_centred test is kept. The 6 FloatingChatFrame failures in this module are pre-existing baseline (10 passed/6 failed before, 11/6 after).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 23:18:06 -06:00

826 lines
30 KiB
Rust

//! Tests for our `GameMenuFrame.xml`, the frame ESC opens. The bags in its way are the stock
//! `ContainerFrame1..12`, recycled, so tests ask [`bag_open`] rather than naming a frame.
use benilla_ui::script::{
ContainerSlot, ContainerState, LootRow, LootState, SessionRequest, SoundRequest, UiScript,
};
use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI};
/// The menu over what it needs (`GameMenuButtonTemplate` is `UIPanelTemplates.xml:403`), with
/// `extra` in its way, deduped so no file loads twice.
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = [
"Interface\\FrameXML\\Fonts.xml",
// Before the menu: its `parent="UIParent"` resolves at load.
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
]
.into_iter()
.chain(extra.iter().copied())
.chain(std::iter::once("GameMenuFrame.xml"))
.collect();
let mut loaded = std::collections::HashSet::new();
for file in files {
if loaded.insert(file) {
load_xml(&s, file);
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
fn harness() -> UiScript {
harness_with(&[])
}
/// [`harness_with`] over the stock bag stack, with `before` ahead of it and `after` behind.
fn bag_harness_with(before: &[&str], after: &[&str]) -> UiScript {
let files: Vec<&str> = before
.iter()
.copied()
.chain(BAG_UI.iter().copied())
.chain(after.iter().copied())
.collect();
harness_with(&files)
}
/// Top to bottom. Deviation: the 1.15 era client's ESC ladder, because its settings screens
/// replace 1.12's; without the AddOns rung, as the character-select screen is the only addon UI.
const LADDER: [&str; 7] = [
"GameMenuButtonOptions",
"GameMenuButtonEditMode",
"GameMenuButtonSupport",
"GameMenuButtonMacros",
"GameMenuButtonLogout",
"GameMenuButtonQuit",
"GameMenuButtonContinue",
];
/// The era layout (MainMenuFrameTemplates: padding 32 top and 28 elsewhere, 20-unit section
/// gaps) over seven 144x21 rungs: 200x267, gaps after Options, after Macros and before Return.
#[test]
fn the_menu_has_the_era_frame_and_button_ladder() {
let _data = benilla_formats::wow_data_or_skip!();
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), (200.0, 267.0), "the era frame size");
let top = s.eval::<f64>("return GameMenuFrame:GetTop()").unwrap();
let left = s.eval::<f64>("return GameMenuFrame:GetLeft()").unwrap();
const TOPS: [f64; 7] = [32.0, 73.0, 94.0, 115.0, 156.0, 177.0, 218.0];
for (name, down) in LADDER.iter().zip(TOPS) {
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 - (top - down)).abs() < 0.001,
"{name} top: expected {} down {down}, got {}",
top - down,
btop
);
assert!(
(bleft - (left + 28.0)).abs() < 0.001,
"{name} sits at the era 28-unit left padding"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A skin that narrows the frame must leave the column centred.
///
/// pfUI's Game Menu skin takes 30 off the width and then re-applies the chain root's own point
/// verbatim — `GameMenuButtonOptions:GetPoint()` straight back into `SetPoint` with only the y
/// nudged (`skins/blizzard/game_menu.lua`). That is a no-op for a centred anchor and a bug for
/// an absolute one: `TOPLEFT` + 28 was the centring offset for a 200-wide frame baked in as a
/// left edge, so at 170 the column sat 15 too far right and hung 2 units past the frame's own
/// right edge. Live, that is the whole of what "the esc menu looks unprofessional" was.
#[test]
fn narrowing_the_frame_keeps_the_button_column_centred() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
s.resolve();
// pfUI's own arithmetic, not a round number picked here.
s.run("GameMenuFrame:SetWidth(GameMenuFrame:GetWidth() - 30)")
.unwrap();
s.resolve();
let (fl, fr) = s
.eval::<(f64, f64)>("return GameMenuFrame:GetLeft(), GameMenuFrame:GetRight()")
.unwrap();
for name in LADDER {
let (bl, br) = s
.eval::<(f64, f64)>(&format!("return {name}:GetLeft(), {name}:GetRight()"))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert!(
(bl - fl) - (fr - br) < 0.001 && (fr - br) - (bl - fl) < 0.001,
"{name} is off-centre at width 170: {} left of the frame, {} right",
bl - fl,
fr - br
);
assert!(
br <= fr,
"{name} hangs {} units past the frame's right edge",
br - fr
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Support closes the menu and toggles the stock help window, as the "?" micro button does
/// (`MainMenuBarMicroButtons.xml:217`).
#[test]
fn support_closes_the_menu_and_toggles_the_help_window() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
s.run("HELP_TOGGLES = 0; function ToggleHelpFrame() HELP_TOGGLES = HELP_TOGGLES + 1 end")
.unwrap();
s.run("ShowUIPanel(GameMenuFrame); GameMenuButtonSupport:Click()")
.unwrap();
assert_eq!(s.eval::<i64>("return HELP_TOGGLES").unwrap(), 1);
assert!(!s
.eval::<bool>("return GameMenuFrame:IsVisible() == 1")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn the_unbacked_entry_is_disabled_and_the_rest_are_live() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
assert!(
!s.eval::<bool>("return GameMenuButtonEditMode:IsEnabled() ~= 0")
.unwrap(),
"Edit Mode has no panel behind it and must read that way"
);
for name in [
"GameMenuButtonOptions",
"GameMenuButtonSupport",
"GameMenuButtonMacros",
"GameMenuButtonLogout",
"GameMenuButtonQuit",
"GameMenuButtonContinue",
] {
assert!(
s.eval::<bool>(&format!("return {name}:IsEnabled() ~= 0"))
.unwrap(),
"{name} is live"
);
}
// Edit Mode reads the era's `HUD_EDIT_MODE_MENU` and Continue 1.12's `RETURN_TO_GAME`; Options
// is a literal, as 1.12's `OPTIONS_MENU` is "Options Menu".
assert_eq!(
s.eval::<String>("return GameMenuButtonEditMode:GetText()")
.unwrap(),
"Edit Mode"
);
assert_eq!(
s.eval::<String>("return GameMenuButtonContinue:GetText()")
.unwrap(),
"Return to Game"
);
assert_eq!(
s.eval::<String>("return GameMenuButtonOptions:GetText()")
.unwrap(),
"Options"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `ToggleGameMenu`'s ESC arm (`UIParent.lua:1482-1496`).
#[test]
fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(
&[],
&[
"ScrollTemplates.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
// With a window open, `CloseAllWindows` eats the press.
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
assert!(!bag_open(&s, 0), "the press closed the bag");
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"and did NOT also open the menu — one eater per press"
);
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())));
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());
}
/// `ToggleGameMenu(1)`, the micro button's form (`UIParent.lua:1466-1480`), is a plain toggle.
#[test]
fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(
&[],
&[
"ScrollTemplates.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0));
s.run("ToggleGameMenu(1)").unwrap();
assert!(!bag_open(&s, 0), "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 menu is a native-center panel: showing it closes the panels and bags
/// (`UIParent.lua:698-704`), and while it holds the center `CanOpenPanels` refuses the rest.
#[test]
fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(
&[],
&[
"ScrollTemplates.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
// The stock loot window, as `test_ui::LOOT_UI`.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
rows: vec![Some(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,
link: None,
random_property_id: 0,
})],
}));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s
.eval::<bool>("return GetLeftFrame():GetName() == \"LootFrame\"")
.unwrap());
s.run("ToggleGameMenu(1)").unwrap();
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"the panel slot vacated on the way in"
);
assert!(!bag_open(&s, 0), "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"
);
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(
!s.eval::<bool>("return LootFrame:IsVisible()").unwrap(),
"ShowUIPanel refuses a left-area panel behind the menu"
);
s.run("HideUIPanel(GameMenuFrame)").unwrap();
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Logout and Exit Game queue alike here: whether a finished logout ends the process is app-side.
#[test]
fn the_live_buttons_queue_their_intents_and_play_their_kits() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
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());
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());
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());
}
/// `PLAYER_CAMPING` shows CAMP (`UIParent.lua:304-307`, `StaticPopup.lua:564-583`), whose `%d %s`
/// text the popup engine's countdown branch writes, not the entry (`StaticPopup.lua:1727-1758`).
#[test]
fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
let _data = benilla_formats::wow_data_or_skip!();
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 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: 1.12 comments out `CAMP_NOW` (`StaticPopup.lua:567`).
assert_eq!(
s.eval::<String>("return StaticPopup1Button1:GetText()")
.unwrap(),
"Cancel"
);
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
"no second button"
);
// An early close cancels from both OnAccept and OnHide, as in the reference; harmless twice.
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());
}
/// The engine zeroes `timeleft` before hiding (`StaticPopup.lua:1716-1722`), and the entry's OnHide
/// cancels only while `timeleft > 0`.
#[test]
fn a_countdown_that_expires_does_not_cancel_the_logout() {
let _data = benilla_formats::wow_data_or_skip!();
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());
}
/// QUIT adds "Exit now" (`ForceQuit`); `LOGOUT_CANCEL`, the server's cancel ack, hides CAMP and
/// QUIT alike (`UIParent.lua:312-316`).
#[test]
fn player_quiting_offers_exit_now_and_logout_cancel_closes_it() {
let _data = benilla_formats::wow_data_or_skip!();
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));
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 stock OnShow guard (`GameMenuFrame.xml:134-140`) disables both while CAMP or QUIT counts.
/// Opened with the micro button, because ESC during a countdown dismisses the dialog instead.
#[test]
fn logout_and_exit_read_disabled_while_a_countdown_runs() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_CAMPING", vec![]);
s.run("ToggleGameMenu(1)").unwrap();
assert!(
!s.eval::<bool>("return GameMenuButtonLogout:IsEnabled() ~= 0")
.unwrap(),
"Logout is dead while the camp timer runs"
);
assert!(!s
.eval::<bool>("return GameMenuButtonQuit:IsEnabled() ~= 0")
.unwrap());
s.run("HideUIPanel(GameMenuFrame)").unwrap();
s.fire_event("LOGOUT_CANCEL", vec![]);
s.run("ToggleGameMenu(1)").unwrap();
assert!(s
.eval::<bool>("return GameMenuButtonLogout:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `StaticPopup_EscapePressed` is the first rung (`UIParent.lua:1482`) and CAMP is `hideOnEscape`,
/// so ESC dismisses the dialog, whose OnHide cancels the logout.
#[test]
fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
let _data = benilla_formats::wow_data_or_skip!();
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());
}
/// The map is a stock `area = "full"` panel (`UIParent.lua:30`), so the menu blocks it, and on
/// close it must vacate the full-screen slot, or the stale slot refuses every later panel.
#[test]
fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness_with(&[
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's zone pickers initialize at OnLoad
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The map calls `UpdateMicroButtons` unguarded (`WorldMapFrame.xml:599`, `:606`).
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
]);
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(s
.eval::<bool>("return GetFullScreenFrame():GetName() == \"WorldMapFrame\"")
.unwrap());
s.run("ToggleWorldMap()").unwrap();
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(
s.eval::<bool>("return GetFullScreenFrame() == nil")
.unwrap(),
"the map vacated the full-screen slot"
);
// The M binding and the micro button both call `ToggleWorldMap`.
s.run("ToggleGameMenu(1)").unwrap();
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
s.run("ToggleWorldMap()").unwrap();
assert!(
!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap(),
"the map must not open behind the game menu"
);
assert!(s
.eval::<bool>("return GetFullScreenFrame() == nil")
.unwrap());
s.run("ToggleGameMenu(1)").unwrap();
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// With the map holding the full screen nothing else opens (`UIParent.lua:668-675`), and ESC
/// closes the map without also opening the menu.
#[test]
fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness_with(&[
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The action bar the micro buttons sit on: the map calls `UpdateMicroButtons` unguarded.
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
// The stock loot window, as `test_ui::LOOT_UI`.
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
]);
s.run("ToggleWorldMap()").unwrap();
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(
!s.eval::<bool>("return LootFrame:IsVisible()").unwrap(),
"a left-area panel must not open behind the full-screen map"
);
s.run("ToggleGameMenu()").unwrap();
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and did not also open the menu"
);
assert!(s
.eval::<bool>("return GetFullScreenFrame() == nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Checked at the quad level, as `IsEnabled()` cannot tell greyed from gone: a disabled button
/// draws no state texture without a DisabledTexture (`SetState`, `0x779790`), and each bag icon is
/// a layer texture that `Disable_BagButtons` desaturates (`MainMenuBarBagButtons.lua:102-113`).
#[test]
fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
let _data = benilla_formats::wow_data_or_skip!();
// The action bar, whose `MainMenuBarArtFrame` parents the bag bar, ahead of the bag stack.
let mut s = bag_harness_with(
&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
],
&[
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.resolve();
let art = |s: &UiScript, owner: &str| -> Vec<(String, bool)> {
s.extract()
.into_iter()
.filter(|eq| s.quad_owner_name(eq.target).as_deref() == Some(owner))
.filter_map(|eq| match &eq.content {
benilla_ui::script::QuadContent::Texture {
path: Some(p),
desaturated,
..
} => Some((p.clone(), *desaturated)),
_ => None,
})
.collect()
};
for owner in [
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"CharacterBag1Slot",
"CharacterBag2Slot",
"CharacterBag3Slot",
] {
assert!(
!art(&s, owner).is_empty(),
"{owner} draws art before the menu opens"
);
}
s.run("ToggleGameMenu(1)").unwrap();
s.resolve();
for owner in [
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"CharacterBag1Slot",
"CharacterBag2Slot",
"CharacterBag3Slot",
] {
let drawn = art(&s, owner);
assert!(
!drawn.is_empty(),
"{owner} must still DRAW under the open menu — greyed is not gone"
);
}
// The backpack icon keeps its art and carries `SetDesaturation`'s greyscale flag.
let toggle = art(&s, "MainMenuBarBackpackButton");
assert!(
toggle
.iter()
.any(|(p, _)| p == "Interface\\Buttons\\Button-Backpack-Up"),
"the backpack image is still on the bar: {toggle:?}"
);
assert!(
toggle
.iter()
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && *grey),
"…and it is greyed, not full-bright: {toggle:?}"
);
s.run("ToggleGameMenu(1)").unwrap();
s.resolve();
let toggle = art(&s, "MainMenuBarBackpackButton");
assert!(
toggle
.iter()
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && !*grey),
"closing the menu restores full colour: {toggle:?}"
);
assert!(
s.eval::<bool>("return MainMenuBarBackpackButton:IsEnabled() ~= 0")
.unwrap(),
"…and the button works again"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A one-item backpack.
fn backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
cooldown_legs: Default::default(),
use_spell_id: 0,
charges: 0,
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 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,
enchants: Vec::new(),
},
);
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
/// The menu wears the options window's `ERA_WINDOW_SCALE` on show, as the era client draws the two
/// at one density; loaded with OptionsFrame.xml, which defines it.
#[test]
fn the_menu_rides_the_shared_era_window_scale() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with(&[
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
]);
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let k = s.eval::<f64>("return GameMenuFrame:GetScale()").unwrap();
let want = s.eval::<f64>("return ERA_WINDOW_SCALE").unwrap();
assert!((k - want).abs() < 1e-6, "menu scale {k} != knob {want}");
assert!((want - 0.78).abs() < 1e-6, "the knob itself moved: {want}");
}