diff --git a/crates/benilla-app/assets/ui/GameMenuAdapters.xml b/crates/benilla-app/assets/ui/GameMenuAdapters.xml new file mode 100644 index 00000000..6962cddf --- /dev/null +++ b/crates/benilla-app/assets/ui/GameMenuAdapters.xml @@ -0,0 +1,132 @@ + + + + + + + + + + diff --git a/crates/benilla-app/assets/ui/GameMenuFrame.xml b/crates/benilla-app/assets/ui/GameMenuFrame.xml deleted file mode 100644 index 955fa369..00000000 --- a/crates/benilla-app/assets/ui/GameMenuFrame.xml +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -- ERA_WINDOW_SCALE (OptionsFrame.xml) stands in for the era's UIParent scale. A - -- second OnShow on this frame would replace this one. - this:SetScale(ERA_WINDOW_SCALE or 1) - if UpdateMicroButtons then UpdateMicroButtons() end - if Disable_BagButtons then Disable_BagButtons() end - - - if UpdateMicroButtons then UpdateMicroButtons() end - if Enable_BagButtons then Enable_BagButtons() end - - - - diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index dd9c8f4b..38fb1b0c 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -1256,7 +1256,7 @@ - + diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 6b27b073..8e1bad2e 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -1,7 +1,6 @@ # The core interface, in load order: the reference's own `FrameXML.toc` files, off the player's -# patch chain, and one bare filename of ours from `assets/ui`. Entry 0, Fonts.xml, loads alone -# into every boot VM, from Startup on; the rest loads on entering the world, then benilla's layer -# (`layer.toc`), then the third-party addons. +# patch chain. Entry 0, Fonts.xml, loads alone into every boot VM, from Startup on; the rest loads +# on entering the world, then benilla's layer (`layer.toc`), then the third-party addons. # The font objects thus exist before the login screen; the reference loads Fonts.xml with the # rest of FrameXML. # `inherits=`, `parent=` and a named `relativeTo=` resolve only against names declared above; a @@ -54,6 +53,9 @@ Interface\FrameXML\ComboFrame.xml Interface\FrameXML\UIPanelTemplates.lua Interface\FrameXML\UIPanelTemplates.xml +# Inherits `GameMenuButtonTemplate`. It sources UIParent.lua again, as at the reference's own row, +# which rebuilds UIParent.lua's tables; no row between here and UIParent.xml writes into them. +Interface\FrameXML\GameMenuFrame.xml # The reference's three options windows, loaded hidden so addons find their frames by name. # Deviation: the player opens the layer's own options window, because 1.12's settings screens are # much worse to use; the stock one holds `OptionsFrame`. @@ -148,7 +150,3 @@ Interface\FrameXML\HelpFrame.xml Interface\FrameXML\CinematicFrame.xml Interface\FrameXML\TabardFrame.xml Interface\FrameXML\TutorialFrame.xml -# Ours, in place of the stock GameMenuFrame.xml until the layer reshapes the stock menu (#553). -# Last: it wraps `ToggleGameMenu`, `IsOptionFrameOpen` and `UpdateMicroButtons` to count -# `BenillaOptionsFrame`. -GameMenuFrame.xml diff --git a/crates/benilla-app/assets/ui/layer.toc b/crates/benilla-app/assets/ui/layer.toc index 17843450..141bdb68 100644 --- a/crates/benilla-app/assets/ui/layer.toc +++ b/crates/benilla-app/assets/ui/layer.toc @@ -11,6 +11,8 @@ ScrollTemplates.xml KeyBindingsPage.xml # `BenillaOptionsFrame`; its rows read the option globals the stock options windows set. OptionsFrame.xml +# The ESC menu's era look on the stock `GameMenuFrame`, whose Options rung opens ours. +GameMenuAdapters.xml # Wraps the stock bag verbs; the keyring is a container (id -2). ContainerFrameAdapters.xml # The pet page's autocast widgets in the pet action button's proportions. diff --git a/crates/benilla-app/src/ui_script/escape_tests.rs b/crates/benilla-app/src/ui_script/escape_tests.rs index 4027a194..7dcf8e73 100644 --- a/crates/benilla-app/src/ui_script/escape_tests.rs +++ b/crates/benilla-app/src/ui_script/escape_tests.rs @@ -181,10 +181,11 @@ fn escape_closes_the_options_window_before_opening_the_menu() { load_xml(&s, r"Interface\FrameXML\StaticPopup.xml"); load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); + load_xml(&s, r"Interface\FrameXML\GameMenuFrame.xml"); load_xml(&s, "ScrollTemplates.xml"); // the Keybindings page's faux-scroll kit load_xml(&s, "KeyBindingsPage.xml"); load_xml(&s, "OptionsFrame.xml"); - load_xml(&s, "GameMenuFrame.xml"); + load_xml(&s, "GameMenuAdapters.xml"); s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap(); assert!(s @@ -469,7 +470,8 @@ fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() { load_xml(&s, r"Interface\FrameXML\BasicControls.xml"); load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua"); load_xml(&s, r"Interface\FrameXML\StaticPopup.xml"); - load_xml(&s, "GameMenuFrame.xml"); + load_xml(&s, r"Interface\FrameXML\GameMenuFrame.xml"); + load_xml(&s, "GameMenuAdapters.xml"); s.run( r#" diff --git a/crates/benilla-app/src/ui_script/frame_flag_gate.rs b/crates/benilla-app/src/ui_script/frame_flag_gate.rs index 5eec88f3..f5fcec0c 100644 --- a/crates/benilla-app/src/ui_script/frame_flag_gate.rs +++ b/crates/benilla-app/src/ui_script/frame_flag_gate.rs @@ -401,7 +401,7 @@ fn the_shipped_frames_carry_the_references_flags() { assert!( // The floor guards the pairing, not the census: the paired count falls as each of our // files declaring a reference-named frame retires. - compared > 5, + compared >= 1, "only {compared} frames compared — the pairing broke, and the sweep guards nothing" ); assert!( diff --git a/crates/benilla-app/src/ui_script/game_menu_addon_tests.rs b/crates/benilla-app/src/ui_script/game_menu_addon_tests.rs new file mode 100644 index 00000000..257b948a --- /dev/null +++ b/crates/benilla-app/src/ui_script/game_menu_addon_tests.rs @@ -0,0 +1,389 @@ +//! The stock ESC menu's rungs under the addons that reshape it (ShaguTweaks, MCP, pfUI), with the +//! layer on and without it: every stock rung stays in the chain, anchored `TOP`, so an addon's +//! insert lays out as on 1.12.1. Without the layer, the menu is the stock one. + +use std::path::Path; + +use benilla_ui::script::UiScript; + +use crate::local_state::test_env::ENV_LOCK; + +/// The production in-game load with the ESC menu open: the layer on unless `stock_ui`, and the +/// AddOns root filled by `addons`. Fails on any load error or raise. +fn open_menu_with(tag: &str, stock_ui: bool, addons: impl FnOnce(&Path)) -> UiScript { + let (mut s, failures) = super::layer_tests::production_load_with(tag, stock_ui, "", addons); + assert!(failures.is_empty(), "load failures: {failures:#?}"); + s.run("ShowUIPanel(GameMenuFrame)").unwrap(); + s.resolve(); + assert!(s.eval::("return GameMenuFrame:IsVisible()").unwrap()); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + s +} + +/// An addon of `files`, each a name and its text, in the AddOns root. +fn write_addon(root: &Path, name: &str, files: &[(&str, &str)]) { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + for (file, body) in files { + std::fs::write(dir.join(file), body).unwrap(); + } +} + +/// The corpus addon `name`, copied whole into the AddOns root. +fn copy_addon(corpus: &Path, root: &Path, name: &str) { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + for entry in std::fs::read_dir(corpus.join(name)).unwrap().flatten() { + if entry.file_type().unwrap().is_file() { + std::fs::copy(entry.path(), dir.join(entry.file_name())).unwrap(); + } + } +} + +/// A shown rung's `(top, bottom)` down from the frame's top, and whether it is centred in it. +fn rung(s: &UiScript, name: &str) -> (f64, f64, bool) { + let (top, bottom, left, right, ftop, fleft, fright) = s + .eval::<(f64, f64, f64, f64, f64, f64, f64)>(&format!( + "local f = GameMenuFrame return {name}:GetTop(), {name}:GetBottom(), \ + {name}:GetLeft(), {name}:GetRight(), f:GetTop(), f:GetLeft(), f:GetRight()" + )) + .unwrap_or_else(|e| panic!("{name}: {e}")); + let centred = ((left - fleft) - (fright - right)).abs() < 1e-3; + (ftop - top, ftop - bottom, centred) +} + +/// The shown rungs of the open menu, top to bottom: the children of `GameMenuFrame` that draw. +fn shown_rungs(s: &UiScript) -> Vec { + let mut names: Vec = s + .eval( + "local out = {} for _, c in ipairs({ GameMenuFrame:GetChildren() }) do \ + if c:IsVisible() then table.insert(out, c:GetName()) end end return out", + ) + .unwrap(); + names.sort_by(|a, b| rung(s, a).0.total_cmp(&rung(s, b).0)); + names +} + +/// No two shown rungs overlap, each is centred, and each is 21 tall. +fn assert_a_clean_ladder(s: &UiScript) -> Vec { + let names = shown_rungs(s); + for pair in names.windows(2) { + let (above, below) = (rung(s, &pair[0]), rung(s, &pair[1])); + assert!( + below.0 >= above.1 - 1e-3, + "{} (down {}..{}) overlaps {} (down {}..{})", + pair[1], + below.0, + below.1, + pair[0], + above.0, + above.1 + ); + } + for name in &names { + let (top, bottom, centred) = rung(s, name); + assert!( + (bottom - top - 21.0).abs() < 1e-3, + "{name} is {} tall", + bottom - top + ); + assert!(centred, "{name} is off centre in the menu"); + } + names +} + +/// ShaguTweaks' insert (`config.lua:313-324`), restated in its own calls: narrow and deepen the +/// menu, hang a new rung under Interface Options, then hang Key Bindings under the new rung. +const SHAGU_ADVANCED_OPTIONS: &str = r#" +local panel = CreateFrame("Frame", nil, UIParent) +panel:Hide() +GameMenuFrame:SetWidth(GameMenuFrame:GetWidth() - 10) +GameMenuFrame:SetHeight(GameMenuFrame:GetHeight() + 10) +local rung = CreateFrame("Button", "GameMenuButtonAdvancedOptions", GameMenuFrame, "GameMenuButtonTemplate") +rung:SetPoint("TOP", GameMenuButtonUIOptions, "BOTTOM", 0, -1) +rung:SetText("Advanced Options") +rung:SetScript("OnClick", function() HideUIPanel(GameMenuFrame); panel:Show() end) +GameMenuButtonKeybindings:ClearAllPoints() +GameMenuButtonKeybindings:SetPoint("TOP", rung, "BOTTOM", 0, -1) +"#; + +fn shagu(root: &Path) { + write_addon( + root, + "ZZShagu", + &[ + ("ZZShagu.toc", "## Interface: 11200\nconfig.lua\n"), + ("config.lua", SHAGU_ADVANCED_OPTIONS), + ], + ); +} + +/// ShaguTweaks anchors its Advanced Options under `GameMenuButtonUIOptions` and re-anchors +/// `GameMenuButtonKeybindings` under it (`GameMenuFrame.xml:78`, `:93`): it loads without a raise, +/// the button lands 1 under the Options rung, and every rung below moves down by its height and +/// the gap, 22, as the stock rungs do on the stock menu. +#[test] +fn shagutweaks_advanced_options_lands_under_the_options_rung_and_pushes_the_rest_down() { + let _data = benilla_formats::wow_data_or_skip!(); + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for stock_ui in [false, true] { + let tag = if stock_ui { "shagu-stock" } else { "shagu" }; + let base = open_menu_with(&format!("{tag}-base"), stock_ui, |_| {}); + let s = open_menu_with(tag, stock_ui, shagu); + let before = shown_rungs(&base); + let after = assert_a_clean_ladder(&s); + + let options = rung(&s, "GameMenuButtonOptions"); + let advanced = rung(&s, "GameMenuButtonAdvancedOptions"); + // Under the Options rung on ours, as the era menu has no Sound or UI Options rung. + let above = if stock_ui { + "GameMenuButtonUIOptions" + } else { + "GameMenuButtonOptions" + }; + assert!( + (advanced.0 - (rung(&s, above).1 + 1.0)).abs() < 1e-3, + "stock_ui {stock_ui}: Advanced Options at {advanced:?}, 1 under {above}" + ); + let at = after + .iter() + .position(|n| n == "GameMenuButtonAdvancedOptions") + .expect("the button is shown"); + assert_eq!(after[at - 1], above, "stock_ui {stock_ui}: {after:?}"); + let mut expected = before.clone(); + expected.insert(at, "GameMenuButtonAdvancedOptions".into()); + assert_eq!(after, expected, "stock_ui {stock_ui}: the ladder"); + for name in &after[at + 1..] { + let moved = (rung(&s, name).0 - options.0) + - (rung(&base, name).0 - rung(&base, "GameMenuButtonOptions").0); + assert!( + (moved - 22.0).abs() < 1e-3, + "stock_ui {stock_ui}: {name} moved {moved} down, not 22" + ); + } + } +} +/// MCP's AddOns button (`MCP.xml:108-127`) anchors `TOP` under Macros, sets Logout's `TOP` under +/// itself without `ClearAllPoints` and grows the menu by 25: Logout's one `TOP` anchor is replaced, +/// so Logout, Exit Game and Return to Game move down under it, as on the stock menu. +#[test] +fn mcps_addons_button_lands_under_macros_and_pushes_logout_down() { + let _data = benilla_formats::wow_data_or_skip!(); + let corpus = benilla_formats::addon_corpus_or_skip!(); + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for stock_ui in [false, true] { + let tag = if stock_ui { "mcp-stock" } else { "mcp" }; + let base = open_menu_with(&format!("{tag}-base"), stock_ui, |_| {}); + let s = open_menu_with(tag, stock_ui, |root| copy_addon(&corpus, root, "MCP")); + let after = assert_a_clean_ladder(&s); + + let macros = rung(&s, "GameMenuButtonMacros"); + let addons = rung(&s, "GameMenuButtonAddOns"); + let logout = rung(&s, "GameMenuButtonLogout"); + assert!( + (addons.0 - (macros.1 + 1.0)).abs() < 1e-3, + "stock_ui {stock_ui}: AddOns {addons:?} 1 under Macros {macros:?}" + ); + assert!( + (logout.0 - (addons.1 + 1.0)).abs() < 1e-3, + "stock_ui {stock_ui}: Logout {logout:?} 1 under AddOns {addons:?}" + ); + let at = after + .iter() + .position(|n| n == "GameMenuButtonAddOns") + .unwrap(); + assert_eq!(after[at - 1], "GameMenuButtonMacros"); + assert_eq!( + after[at + 1..], + [ + "GameMenuButtonLogout", + "GameMenuButtonQuit", + "GameMenuButtonContinue" + ] + ); + let height = |s: &UiScript| s.eval::("return GameMenuFrame:GetHeight()").unwrap(); + assert!( + (height(&s) - height(&base) - 25.0).abs() < 1e-3, + "stock_ui {stock_ui}: the menu grows by MCP's 25" + ); + let span = |s: &UiScript, a: &str, b: &str| rung(s, b).0 - rung(s, a).0; + for (a, b) in [ + ("GameMenuButtonLogout", "GameMenuButtonQuit"), + ("GameMenuButtonQuit", "GameMenuButtonContinue"), + ] { + assert!( + (span(&s, a, b) - span(&base, a, b)).abs() < 1e-3, + "stock_ui {stock_ui}: {b} still follows {a}" + ); + } + } +} + +/// pfUI's game-menu skin (`skins/blizzard/game_menu.lua`) in its shape: it narrows the menu by 30, +/// finds the unnamed MAIN_MENU title among the regions, adds a button at the top, shifts +/// `GameMenuButtonOptions`' own point down 22 through `GetPoint`, and skins every stock rung that +/// exists. The rungs stay centred in the narrower frame, as the stock ones do, with no raise. +const PFUI_SHAPED_SKIN: &str = r#" +GameMenuFrame:SetWidth(GameMenuFrame:GetWidth() - 30) +GameMenuFrame:SetHeight(GameMenuFrame:GetHeight() + 6) + +local title +for _, region in ipairs({ GameMenuFrame:GetRegions() }) do + if region:GetObjectType() == "FontString" and region:GetDrawLayer() == "ARTWORK" + and region:GetText() and string.find(region:GetText(), MAIN_MENU, 1) then + title = region + end +end +title:SetTextColor(1, 1, 1, 1) +title:ClearAllPoints() +title:SetPoint("TOP", GameMenuFrame, "TOP", 0, 16) + +local button = CreateFrame("Button", "GameMenuButtonPFUI", GameMenuFrame, "GameMenuButtonTemplate") +button:SetPoint("TOP", 0, -10) +button:SetText("pfUI Config") + +local point, relativeTo, relativePoint, xOffset, yOffset = GameMenuButtonOptions:GetPoint() +GameMenuButtonOptions:SetPoint(point, relativeTo, relativePoint, xOffset, yOffset - 22) + +ZZPFUI_SKINNED = 0 +for _, rung in pairs({ GameMenuButtonOptions, GameMenuButtonSoundOptions, GameMenuButtonUIOptions, + GameMenuButtonKeybindings, GameMenuButtonMacros, GameMenuButtonRatings, GameMenuButtonLogout, + GameMenuButtonQuit, GameMenuButtonContinue }) do + if rung then ZZPFUI_SKINNED = ZZPFUI_SKINNED + 1 end +end +"#; + +#[test] +fn a_pfui_shaped_skin_keeps_every_rung_centred_in_the_narrowed_menu() { + let _data = benilla_formats::wow_data_or_skip!(); + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + for stock_ui in [false, true] { + let tag = if stock_ui { "pfui-stock" } else { "pfui" }; + let s = open_menu_with(tag, stock_ui, |root| { + write_addon( + root, + "ZZpfUI", + &[ + ("ZZpfUI.toc", "## Interface: 11200\nskin.lua\n"), + ("skin.lua", PFUI_SHAPED_SKIN), + ], + ); + }); + let after = assert_a_clean_ladder(&s); + assert_eq!( + after[0], "GameMenuButtonPFUI", + "stock_ui {stock_ui}: {after:?}" + ); + assert_eq!( + after[1], "GameMenuButtonOptions", + "stock_ui {stock_ui}: {after:?}" + ); + assert_eq!( + s.eval::("return ZZPFUI_SKINNED").unwrap(), + 8, + "stock_ui {stock_ui}: every stock rung is there to skin" + ); + } +} + +/// Booted without the layer, as `WOW_STOCK_UI=1` does, the ESC menu is the stock one: its eight +/// rungs at the stock file's geometry (`GameMenuFrame.xml:5-185`), each opening its stock window. +#[test] +fn the_stock_ui_menu_is_the_stock_menu_and_every_rung_opens_its_stock_window() { + use benilla_ui::script::SessionRequest; + let _data = benilla_formats::wow_data_or_skip!(); + let _l = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = open_menu_with("stock-menu", true, |_| {}); + let rungs = assert_a_clean_ladder(&s); + assert_eq!( + rungs, + [ + "GameMenuButtonOptions", + "GameMenuButtonSoundOptions", + "GameMenuButtonUIOptions", + "GameMenuButtonKeybindings", + "GameMenuButtonMacros", + "GameMenuButtonLogout", + "GameMenuButtonQuit", + "GameMenuButtonContinue", + ] + ); + assert_eq!( + s.eval::<(f64, f64)>("return GameMenuFrame:GetWidth(), GameMenuFrame:GetHeight()") + .unwrap(), + (195.0, 246.0) + ); + // Options' centre 37 under the top, then 1-unit gaps, and 16 above Return to Game. + let tops: Vec = rungs.iter().map(|n| rung(&s, n).0).collect(); + let want = [26.5, 48.5, 70.5, 92.5, 114.5, 136.5, 158.5, 195.5]; + for ((name, got), want) in rungs.iter().zip(&tops).zip(want) { + assert!((got - want).abs() < 1e-3, "{name} at {got}, not {want}"); + } + let labels: Vec = s + .eval( + "local out = {} for _, n in ipairs({ 'Options', 'SoundOptions', 'UIOptions', \ + 'Keybindings', 'Macros', 'Logout', 'Quit', 'Continue' }) do \ + table.insert(out, getglobal('GameMenuButton' .. n):GetText()) end return out", + ) + .unwrap(); + let globals: Vec = s + .eval( + "return { VIDEOOPTIONS_MENU, SOUNDOPTIONS_MENU, UIOPTIONS_MENU, KEY_BINDINGS, MACROS, \ + LOGOUT, EXIT_GAME, RETURN_TO_GAME }", + ) + .unwrap(); + assert_eq!(labels, globals); + assert!(!s + .eval::("return BenillaGameMenuButtonEditMode ~= nil") + .unwrap()); + + for (rung, window) in [ + ("Options", "OptionsFrame"), + ("SoundOptions", "SoundOptionsFrame"), + ("UIOptions", "UIOptionsFrame"), + ("Keybindings", "KeyBindingFrame"), + ("Macros", "MacroFrame"), + ] { + s.run("HideUIPanel(GameMenuFrame) ShowUIPanel(GameMenuFrame)") + .unwrap(); + s.run(&format!("GameMenuButton{rung}:Click()")).unwrap(); + assert!( + s.eval::(&format!( + "return {window} ~= nil and {window}:IsVisible() and true or false" + )) + .unwrap(), + "{rung} opens {window}" + ); + s.run(&format!("HideUIPanel({window})")).unwrap(); + // The stock video window's own show and hide read video CVars and verbs benilla does not + // register yet (`OptionsFrame.lua:146`, `:260`); what the menu does is the click above. + let errors = s.take_errors(); + assert!( + errors + .iter() + .all(|e| rung == "Options" && e.contains("OptionsFrame.lua")), + "{rung}: {errors:?}" + ); + } + for (rung, request) in [ + ("Logout", SessionRequest::Logout), + ("Quit", SessionRequest::Quit), + ] { + s.run("ShowUIPanel(GameMenuFrame)").unwrap(); + let _ = s.take_session_requests(); + s.run(&format!("GameMenuButton{rung}:Click()")).unwrap(); + assert_eq!(s.take_session_requests(), vec![request], "{rung}"); + } + s.run("ShowUIPanel(GameMenuFrame) GameMenuButtonContinue:Click()") + .unwrap(); + assert!(!s.eval::("return GameMenuFrame:IsVisible()").unwrap()); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); +} diff --git a/crates/benilla-app/src/ui_script/game_menu_tests.rs b/crates/benilla-app/src/ui_script/game_menu_tests.rs index 02a3d4b8..48363b2a 100644 --- a/crates/benilla-app/src/ui_script/game_menu_tests.rs +++ b/crates/benilla-app/src/ui_script/game_menu_tests.rs @@ -1,4 +1,5 @@ -//! Tests for our `GameMenuFrame.xml`, the frame ESC opens. The bags in its way are the stock +//! Tests for the frame ESC opens: the stock `GameMenuFrame.xml` as the layer's +//! `GameMenuAdapters.xml` reshapes it. 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::{ @@ -7,8 +8,8 @@ use benilla_ui::script::{ 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. +/// The stock menu over what it needs (`GameMenuButtonTemplate` is `UIPanelTemplates.xml:403`), +/// with `extra` in its way, then the layer's reshaping, 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); @@ -24,10 +25,12 @@ fn harness_with(extra: &[&str]) -> UiScript { "Interface\\FrameXML\\BasicControls.xml", "Interface\\FrameXML\\LocaleProperties.lua", "Interface\\FrameXML\\StaticPopup.xml", + // Ahead of `extra`, as the core loads before the layer: it sources UIParent.lua again. + r"Interface\FrameXML\GameMenuFrame.xml", ] .into_iter() .chain(extra.iter().copied()) - .chain(std::iter::once("GameMenuFrame.xml")) + .chain(std::iter::once("GameMenuAdapters.xml")) .collect(); let mut loaded = std::collections::HashSet::new(); for file in files { @@ -58,8 +61,8 @@ fn bag_harness_with(before: &[&str], after: &[&str]) -> UiScript { /// 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", + "BenillaGameMenuButtonEditMode", + "BenillaGameMenuButtonSupport", "GameMenuButtonMacros", "GameMenuButtonLogout", "GameMenuButtonQuit", @@ -114,7 +117,10 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() { let s = harness(); s.run("ShowUIPanel(GameMenuFrame)").unwrap(); - for name in ["GameMenuButtonEditMode", "GameMenuButtonSupport"] { + for name in [ + "BenillaGameMenuButtonEditMode", + "BenillaGameMenuButtonSupport", + ] { assert!( !s.eval::(&format!("return {name}:IsEnabled() ~= 0")) .unwrap(), @@ -134,10 +140,10 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() { "{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". + // Edit Mode is the era's label 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::("return GameMenuButtonEditMode:GetText()") + s.eval::("return BenillaGameMenuButtonEditMode:GetText()") .unwrap(), "Edit Mode" ); diff --git a/crates/benilla-app/src/ui_script/keybindings_tests.rs b/crates/benilla-app/src/ui_script/keybindings_tests.rs index ab529494..df517194 100644 --- a/crates/benilla-app/src/ui_script/keybindings_tests.rs +++ b/crates/benilla-app/src/ui_script/keybindings_tests.rs @@ -35,10 +35,12 @@ pub(crate) fn harness() -> UiScript { "Interface\\FrameXML\\StaticPopup.xml", "Interface\\FrameXML\\GameTooltip.xml", "Interface\\FrameXML\\UIDropDownMenu.xml", + // Before our files, as the core loads before the layer: it sources UIParent.lua again. + r"Interface\FrameXML\GameMenuFrame.xml", "ScrollTemplates.xml", "KeyBindingsPage.xml", "OptionsFrame.xml", - "GameMenuFrame.xml", + "GameMenuAdapters.xml", ] { // Strict for ours: an unknown template only warns, so a skinless window would pass. if file == "KeyBindingsPage.xml" || file == "OptionsFrame.xml" { diff --git a/crates/benilla-app/src/ui_script/layer_tests.rs b/crates/benilla-app/src/ui_script/layer_tests.rs index a812cb70..b2b59ee7 100644 --- a/crates/benilla-app/src/ui_script/layer_tests.rs +++ b/crates/benilla-app/src/ui_script/layer_tests.rs @@ -28,17 +28,34 @@ f:SetScript("OnEvent", function() table.insert(ADDON_LOADED_LOG, arg1) end) /// A VM after the production in-game load, with one third-party addon, `ZZOrder`, in a hermetic /// AddOns root; the layer on unless `stock_ui`. Returns the VM and the load's failures. fn production_load(tag: &str, stock_ui: bool) -> (UiScript, Vec) { + let before = format!("{DEFINE_LOG}\n{ADDON_LOADED_LOG}"); + production_load_with(tag, stock_ui, &before, |root| { + let dir = root.join("ZZOrder"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("ZZOrder.toc"), "## Interface: 11200\nprobe.lua\n").unwrap(); + std::fs::write( + dir.join("probe.lua"), + "ZZORDER_SAW_LAYER = BenillaOptionsFrame ~= nil\n", + ) + .unwrap(); + }) +} + +/// The production in-game load, the layer on unless `stock_ui`, over a hermetic AddOns root that +/// `addons` fills; `before` runs ahead of the load. The caller holds [`ENV_LOCK`]. Returns the VM +/// and the load's failures. +pub(super) fn production_load_with( + tag: &str, + stock_ui: bool, + before: &str, + addons: impl FnOnce(&std::path::Path), +) -> (UiScript, Vec) { let tmp = std::env::temp_dir().join(format!("benilla-layer-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&tmp); let home = tmp.join("benilla-config"); - let dir = home.join("AddOns").join("ZZOrder"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("ZZOrder.toc"), "## Interface: 11200\nprobe.lua\n").unwrap(); - std::fs::write( - dir.join("probe.lua"), - "ZZORDER_SAW_LAYER = BenillaOptionsFrame ~= nil\n", - ) - .unwrap(); + let root = home.join("AddOns"); + std::fs::create_dir_all(&root).unwrap(); + addons(&root); let _c = EnvGuard::unset("WOW_CAPTURE"); let _h = EnvGuard::set("BENILLA_HOME", home.to_str().unwrap()); @@ -50,6 +67,8 @@ fn production_load(tag: &str, stock_ui: bool) -> (UiScript, Vec) { exists: true, name: Some("Probesix".into()), level: 60, + class: Some("Warrior".into()), + class_file: Some("WARRIOR".into()), ..Default::default() }), ); @@ -57,8 +76,7 @@ fn production_load(tag: &str, stock_ui: bool) -> (UiScript, Vec) { s.note_addon_info_reply(&[]); s.register_cvars(crate::cvars::registered_pairs()); let mut failures = super::load_font_registry(&s); - s.run(DEFINE_LOG).unwrap(); - s.run(ADDON_LOADED_LOG).unwrap(); + s.run(before).unwrap(); // The layer passed in, not set through `WOW_STOCK_UI`: every test in this process reads it. failures.extend(super::manifest::load_ingame_ui_with( &mut s, @@ -109,12 +127,10 @@ fn the_layer_loads_after_every_stock_file_and_before_the_addons() { assert!(failures.is_empty(), "load failures: {failures:#?}"); let log: Vec = s.eval("return DEFINE_LOG").unwrap(); - // The core's last stock row (TutorialFrame.lua), the core's own last row (GameMenuFrame.xml), - // the layer's first file (ScrollTemplates.xml) and last (FrameXMLFixes.xml is all - // redefinitions, so ScriptLogFrame.xml), then the addon. + // The core's last row (TutorialFrame.lua), the layer's first file (ScrollTemplates.xml) and + // last (FrameXMLFixes.xml is all redefinitions, so ScriptLogFrame.xml), then the addon. let order = [ "TutorialFrame_OnHide", - "GameMenuButton_Pending", "BenillaScrollBar_Step", "BenillaScriptLog_Toggle", "ZZORDER_SAW_LAYER", @@ -139,7 +155,7 @@ fn the_layer_loads_after_every_stock_file_and_before_the_addons() { || n.strip_prefix("arg") .is_some_and(|d| d.len() == 1 && d.as_bytes()[0].is_ascii_digit()) }; - let late: Vec<&String> = log[at[2]..] + let late: Vec<&String> = log[at[1]..] .iter() .filter(|n| stock.contains(n.as_str()) && !dispatch(n)) .collect(); @@ -235,6 +251,7 @@ fn the_stock_ui_switch_boots_the_core_without_the_layer() { "BenillaScrollBar_Step", "KeyBindings_OnHostKey", "BenillaOptionsFrame_SelectCategory", + "BenillaGameMenuButtonEditMode", "BENILLA_BAG_WAS_OPEN", "BenillaScriptLog_Toggle", ] { @@ -245,12 +262,11 @@ fn the_stock_ui_switch_boots_the_core_without_the_layer() { } assert!(!s.eval::("return BenillaOptionsFrame ~= nil").unwrap()); assert!(!s.eval::("return ZZORDER_SAW_LAYER").unwrap()); - // The host's calls into the layer, and the game menu's Options rung. + // The host's calls into the layer. for body in [ super::ERRORS_TOGGLE.to_string(), super::ERRORS_CLEAR.to_string(), super::host_key_capture("CTRL-J"), - "GameMenuButtonOptions:Click()".to_string(), ] { s.run(&body).unwrap_or_else(|e| panic!("{body}: {e}")); } diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index dea47e2d..f65c87f6 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -817,6 +817,8 @@ mod tooltip_template_tests; #[cfg(test)] mod escape_tests; +#[cfg(test)] +mod game_menu_addon_tests; #[cfg(test)] mod game_menu_tests; diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 5deca590..32663df5 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -34,6 +34,8 @@ fn harness_on(mut s: UiScript) -> UiScript { "Interface\\FrameXML\\StaticPopup.xml", "Interface\\FrameXML\\GameTooltip.xml", "Interface\\FrameXML\\UIDropDownMenu.xml", + // Before our files, as the core loads before the layer: it sources UIParent.lua again. + r"Interface\FrameXML\GameMenuFrame.xml", // The stock options kit, hidden: `UIOptionsFrame_Init` assigns the option globals our // rows capture at OnLoad, and these declare the tables the Graphics rows and census read. r"Interface\FrameXML\OptionsFrameTemplates.xml", @@ -42,7 +44,7 @@ fn harness_on(mut s: UiScript) -> UiScript { "ScrollTemplates.xml", // the page scroll and the Keybindings list "KeyBindingsPage.xml", // the Keybindings page's templates and script "OptionsFrame.xml", - "GameMenuFrame.xml", + "GameMenuAdapters.xml", ] { // Our window loads strict: a missing template there fails instead of only warning. if loaded(&s, file) { diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 1453d9d0..62f99b86 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -1322,10 +1322,9 @@ mod tests { ); } - /// The core's chain rows are the reference's own `FrameXML.toc` rows, every one, but the stock - /// `GameMenuFrame.xml`, which ours stands in for until the layer reshapes the stock menu. A - /// row the stock toc lacks runs a file the reference does not, or twice: `UIParent.xml:4` - /// sources `LocaleProperties.lua`, which has no row. + /// The core's chain rows are the reference's own `FrameXML.toc` rows, every one. A row the + /// stock toc lacks runs a file the reference does not, or twice: `UIParent.xml:4` sources + /// `LocaleProperties.lua`, which has no row. #[test] fn the_cores_chain_rows_are_the_stock_tocs_rows() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1337,7 +1336,6 @@ mod tests { .lines() .map(str::trim) .filter(|l| !l.is_empty() && !l.starts_with('#')) - .filter(|l| !l.eq_ignore_ascii_case("GameMenuFrame.xml")) .map(|l| format!("interface\\framexml\\{}", l.to_ascii_lowercase())) .collect(); let mut core: Vec = super::super::addons::Addon::core() diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index 0e423a60..f4397ea0 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -486,7 +486,7 @@ fn every_shipped_text_attribute_answers_against_the_real_global_strings() { }; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui"); - let mut keys = 0; + let mut swept = 0; for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() { let path = entry.path(); if path.extension().is_none_or(|e| e != "xml") { @@ -505,7 +505,6 @@ fn every_shipped_text_attribute_answers_against_the_real_global_strings() { "{file}: text=\"{value}\" is shaped like a GlobalStrings key but the real \ GlobalStrings.lua has no such string — it would render as its own key name" ); - keys += 1; } else { assert!( resolved.is_none(), @@ -513,10 +512,11 @@ fn every_shipped_text_attribute_answers_against_the_real_global_strings() { the loader would silently show that string's value instead of these words" ); } + swept += 1; } } // A floor, so the sweep cannot pass by matching nothing. - assert!(keys >= 1, "only {keys} key-shaped text= values swept"); + assert!(swept >= 1, "only {swept} text= values swept"); } /// No shipped script hands a GlobalStrings key to a text sink as its words: `SetText` cannot tell @@ -1096,7 +1096,7 @@ fn every_declared_parent_really_attaches() { let declared = shipped_frame_parents(); assert!( // A sanity floor for the scan, not a census. - declared.len() >= 2, + !declared.is_empty(), "only {} parent declarations found — the scan broke", declared.len() ); @@ -1445,7 +1445,7 @@ fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() { ); // `IsOptionFrameOpen` names the stock options windows, all hidden; it sees ours through the - // wrapper `GameMenuFrame.xml` installs. + // wrapper `GameMenuAdapters.xml` installs. assert!( s.eval::("return IsOptionFrameOpen() and true or false") .unwrap(), @@ -1503,7 +1503,7 @@ fn the_stock_sound_window_turns_a_box_off_and_stores_a_slider_as_lua_text() { /// The stock Video Options window loads hidden (`OptionsFrame.xml:5`) and owns the `OptionsFrame` /// name; ours is `BenillaOptionsFrame`, which the ESC menu opens and the stock "options window -/// open?" consumers see only through the `GameMenuFrame.xml` wrappers. +/// open?" consumers see only through the `GameMenuAdapters.xml` wrappers. #[test] fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() { let _data = benilla_formats::wow_data_or_skip!(); @@ -1989,8 +1989,7 @@ fn the_stock_options_windows_load_and_save_are_reachable_for_addons() { } /// benilla's own interface is one layer, and it does not grow: `layer.toc` names exactly these -/// files, all ours (none off the chain), and `assets/ui` holds nothing else but the two manifests -/// and the core's one file. A window is migrated, not authored: point `benilla.toc` at +/// files, all ours (none off the chain), and `assets/ui` holds nothing else but the two manifests. A window is migrated, not authored: point `benilla.toc` at /// `Interface\FrameXML\.xml`, delete ours, and build the engine verbs the stock file /// calls; what changes the stock UI goes in one of these files. #[test] @@ -1999,6 +1998,7 @@ fn the_layer_does_not_grow() { "ScrollTemplates.xml", "KeyBindingsPage.xml", "OptionsFrame.xml", + "GameMenuAdapters.xml", "ContainerFrameAdapters.xml", "SpellBookAdapters.xml", "ScriptLogFrame.xml", @@ -2020,44 +2020,28 @@ fn the_layer_does_not_grow() { found.sort(); let mut shipped: Vec = LAYER .iter() - .chain(&["benilla.toc", "layer.toc", "GameMenuFrame.xml"]) + .chain(&["benilla.toc", "layer.toc"]) .map(|s| s.to_string()) .collect(); shipped.sort(); assert_eq!( found, shipped, - "assets/ui holds the two manifests, the layer's files and the core's one file, nothing else" + "assets/ui holds the two manifests and the layer's files, nothing else" ); } -/// The core is the stock UI: `benilla.toc` names the reference's own files off the chain and one -/// file of ours, `GameMenuFrame.xml`, which stands in for the stock one until the layer reshapes -/// the stock menu, and whose row says so. +/// The core is the stock UI: `benilla.toc` names the reference's own files off the chain and no +/// file of ours. #[test] -fn the_core_toc_names_no_file_of_ours_but_the_game_menu() { +fn the_core_toc_names_no_file_of_ours() { let core = super::addons::Addon::core().toc.files; let ours: Vec<&String> = core .iter() .filter(|f| !super::reference_ui::is_chain_entry(f)) .collect(); - assert_eq!( - ours, - ["GameMenuFrame.xml"], - "benilla.toc names a file of ours: benilla's own interface is the layer (layer.toc), \ - which loads after every stock file" - ); - let toc = super::content::read(super::manifest::MANIFEST).expect("benilla.toc"); - let lines: Vec<&str> = toc.lines().collect(); - let at = lines - .iter() - .position(|l| l.trim() == "GameMenuFrame.xml") - .expect("the row"); assert!( - lines[..at] - .iter() - .rev() - .take_while(|l| l.starts_with('#')) - .any(|l| l.contains("#553")), - "GameMenuFrame.xml's row names the issue that retires it" + ours.is_empty(), + "benilla.toc names a file of ours, {ours:?}: benilla's own interface is the layer \ + (layer.toc), which loads after every stock file" ); } diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index b8773108..ac4c75c6 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -130,9 +130,16 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo s.run(MICRO_BUTTON_STAND_INS) .expect("the micro-button stand-ins"); } - if leaf.eq_ignore_ascii_case("UIParent.xml") && super::reference_ui::is_chain_entry(&path) { + // The stock GameMenuFrame.xml sources UIParent.lua again, which drops the stand-ins' wrappers. + if (leaf.eq_ignore_ascii_case("UIParent.xml") || leaf.eq_ignore_ascii_case("GameMenuFrame.xml")) + && super::reference_ui::is_chain_entry(&path) + { s.run(UIPARENT_STAND_INS).expect("the UIParent stand-ins"); } + if leaf.eq_ignore_ascii_case("GameMenuFrame.xml") && super::reference_ui::is_chain_entry(&path) + { + s.run(GAME_MENU_STAND_INS).expect("the game menu stand-ins"); + } if leaf.eq_ignore_ascii_case("UIOptionsFrame.xml") { s.run(UIOPTIONS_STAND_INS) .expect("the stock options window's stand-ins"); @@ -241,7 +248,7 @@ pub(super) const UIPARENT_STAND_INS: &str = r#" -- names are real, and all three options windows are the REFERENCE's own files, -- loaded hidden — including `OptionsFrame`, the video window, which used to be our own -- window's name. Ours is `BenillaOptionsFrame` now and is not in this list: it is not a name - -- the reference indexes, and the wrappers in `GameMenuFrame.xml` are what tell these two + -- the reference indexes, and the wrappers in `GameMenuAdapters.xml` are what tell these two -- functions about it. A KIT is a prefix of the manifest and may load none of the four, which -- is what these stand-ins are for. local function benilla_seat_options() @@ -263,6 +270,15 @@ pub(super) const UIPARENT_STAND_INS: &str = r#" end "#; +/// What a kit owes stock `GameMenuFrame.xml`: its OnShow and OnHide (`GameMenuFrame.xml:188-195`) +/// call the micro-button and bag-bar verbs unguarded, from files a kit may stop short of. Plain +/// global writes, so the real definitions replace them in any order. +pub(super) const GAME_MENU_STAND_INS: &str = r#" + UpdateMicroButtons = UpdateMicroButtons or function() end + Disable_BagButtons = Disable_BagButtons or function() end + Enable_BagButtons = Enable_BagButtons or function() end +"#; + /// What a kit owes stock `UIOptionsFrame.xml`: its `VARIABLES_LOADED` arm /// (`UIOptionsFrame.lua:193-227`) calls six functions from files a kit may not load, and a kit /// fires the event itself. Functions, so seating them at load is safe in any order. diff --git a/crates/benilla-app/tests/common/mod.rs b/crates/benilla-app/tests/common/mod.rs index 4cfa350b..bbb704ce 100644 --- a/crates/benilla-app/tests/common/mod.rs +++ b/crates/benilla-app/tests/common/mod.rs @@ -42,7 +42,10 @@ pub fn load_ui(script: &UiScript, entry: &str) { .run(MICRO_BUTTON_STAND_INS) .expect("the micro-button stand-ins"); } - if leaf.eq_ignore_ascii_case("UIParent.xml") && entry.contains('\\') { + // The stock GameMenuFrame.xml sources UIParent.lua again, which drops the stand-ins' wrappers. + if (leaf.eq_ignore_ascii_case("UIParent.xml") || leaf.eq_ignore_ascii_case("GameMenuFrame.xml")) + && entry.contains('\\') + { script .run(UIPARENT_STAND_INS) .expect("the UIParent stand-ins"); @@ -123,7 +126,7 @@ const UIPARENT_STAND_INS: &str = r#" -- names are real, and all three options windows are the REFERENCE's own files, -- loaded hidden — including `OptionsFrame`, the video window, which used to be our own -- window's name. Ours is `BenillaOptionsFrame` now and is not in this list: it is not a name - -- the reference indexes, and the wrappers in `GameMenuFrame.xml` are what tell these two + -- the reference indexes, and the wrappers in `GameMenuAdapters.xml` are what tell these two -- functions about it. A KIT is a prefix of the manifest and may load none of the four, which -- is what these stand-ins are for. local function benilla_seat_options()