`CreateFrame(): Couldn't find inherited node "DialogBorderOpaqueTemplate"` is where OpenMapClassic stopped — on its first file, inside Ace3, before a line of its own code ran. It is not that addon's problem: `BackdropTemplate` is named **unguarded** by Ace3 itself (`AceGUIContainer-Frame.lua:179` and `:206`, `AceGUIContainer-DropDownGroup.lua:128`), so every Ace3 addon on this install hits it. `assets/ui` does not grow, so this file carries its reason on that wall's own list: there is no stock 1.12 file to migrate from, because 1.12 has neither template. They are the same kind of surface as `script/compat/`'s verbs and live in XML only because `inherits=` is resolved by the loader and cannot be answered from Lua. **`BackdropTemplate` is correctly empty.** 9.0 moved the backdrop off the C side and invented `BackdropTemplateMixin` to put it back; 1.12 still has `SetBackdrop`/`GetBackdrop`/ `SetBackdropColor`/`SetBackdropBorderColor` as real frame methods, so a frame inheriting the template already answers all four. The mixin table exists for the `BackdropTemplateMixin and "BackdropTemplate" or nil` idiom and for `Mixin()`, its rows forwarding to the stock methods. **No getter rows**: a forwarding `GetBackdropColor` would call itself once `Mixin()` had written it onto the frame, and the stock one already answers. The dialog border is one `<Backdrop>` over this client's own dialog art — the shape every stock 1.12 dialog draws, and the same block `GameMenuFrame.xml` uses — rather than a transcription of Era's nine `SharedUIPanelTemplates.xml` textures. The opaque variant differs only in background alpha, which is the one thing the two names disagree about. `enableMouse` stays off: it is a decoration over a frame that handles its own input, and a border that ate clicks would make an Ace options window unusable. Movement: openmap `AceConfigDialog:573` → `:586`, now wanting `SetNormalFontObject`. Full chain green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2017 lines
80 KiB
Rust
2017 lines
80 KiB
Rust
//! Whole-tree guards over the shipped interface: `assets/ui` and the manifest it loads.
|
||
|
||
/// Every shipped `assets/ui/*.xml` parses (XML forbids `--` inside a comment). Parse-only: a file
|
||
/// loaded out of manifest order reports templates its predecessors define.
|
||
#[test]
|
||
fn every_shipped_ui_xml_parses() {
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut checked = 0;
|
||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||
let path = entry.path();
|
||
if path.extension().is_some_and(|e| e == "xml") {
|
||
let text = std::fs::read_to_string(&path).expect("read");
|
||
if let Err(e) = benilla_ui::framexml::parse(&text) {
|
||
panic!("{}: {e}", path.display());
|
||
}
|
||
checked += 1;
|
||
}
|
||
}
|
||
assert!(
|
||
// A floor, not a census: a moved directory must not pass by sweeping nothing.
|
||
checked >= 6,
|
||
"only {checked} xml files swept — sweep broke"
|
||
);
|
||
}
|
||
|
||
/// The whole manifest loads in its real order with zero loader errors, which the app only logs.
|
||
#[test]
|
||
fn the_whole_shipped_manifest_loads_without_errors() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// Loading the UI makes no sound: a dropdown's OnLoad runs `UIDropDownMenu_Initialize`, which calls
|
||
/// its init at once (`UIDropDownMenu.lua:50`), and a unit popup's init reaches `UnitPopup_ShowMenu`
|
||
/// and its closing `PlaySound("igMainMenuOpen")` unless `UnitPopup_HideButtons` leaves only CANCEL
|
||
/// for a unit that does not exist (`UnitPopup.lua:115`).
|
||
#[test]
|
||
fn loading_the_shipped_ui_queues_no_sounds() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
s.resolve();
|
||
assert_eq!(
|
||
s.take_sounds(),
|
||
vec![],
|
||
"loading the UI played a sound — a load-time handler is ringing; see \
|
||
UnitPopup_HideButtons / UIDropDownMenu_Initialize"
|
||
);
|
||
}
|
||
|
||
/// The stock pet bar with the hunter fixture (Claw autocasting), settled past its slide.
|
||
fn pet_bar_vm() -> benilla_ui::script::UiScript {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
super::pet_bar_tests::load_pet_bar(&s);
|
||
super::pet_bar_tests::declare_token_strings(&s);
|
||
s.set_pet_actions(true, true, true, super::pet_bar_tests::hunter_slots());
|
||
s.fire_event("PET_BAR_UPDATE", vec![]);
|
||
for _ in 0..3 {
|
||
s.tick(0.05);
|
||
}
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "pet bar errors: {:?}", s.errors());
|
||
s
|
||
}
|
||
|
||
/// The autocast brackets land on each button's corners: the art fills the middle 33/64 of
|
||
/// `UI-AutoCastableOverlay`, which puts the stock pet button's on its corners (58 × 33/64 = 29.9
|
||
/// on 30). Deviation: the spell book takes that ratio (`SpellBookAdapters.xml`), because its stock
|
||
/// brackets (60 × 33/64 = 30.9 on 37) sit three units inside each edge.
|
||
#[test]
|
||
fn the_autocast_brackets_reach_each_buttons_corners() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
use benilla_ui::framexml::{Element, TopLevel};
|
||
|
||
/// The bracket art's share of `UI-AutoCastableOverlay.blp`, texels 15..47 of 64, measured with
|
||
/// `benilla-extract blp`.
|
||
const ART: f32 = 33.0 / 64.0;
|
||
|
||
fn dim(el: &Element, tag: &str, axis: &str) -> Option<f32> {
|
||
el.children
|
||
.iter()
|
||
.find(|c| c.tag.eq_ignore_ascii_case(tag))
|
||
.and_then(|n| n.children.first())
|
||
.and_then(|d| {
|
||
d.attrs()
|
||
.iter()
|
||
.find(|(k, _)| k.eq_ignore_ascii_case(axis))
|
||
.map(|(_, v)| v.clone())
|
||
})
|
||
.and_then(|v| v.parse().ok())
|
||
}
|
||
|
||
/// Walk a template, carrying the nearest enclosing button size down to the overlay.
|
||
fn walk(el: &Element, button: Option<f32>, out: &mut Vec<(String, f32, f32)>) {
|
||
let button = dim(el, "Size", "x")
|
||
.filter(|_| el.tag.ends_with("Button"))
|
||
.or(button);
|
||
let name = el
|
||
.attrs()
|
||
.iter()
|
||
.find(|(k, _)| k.eq_ignore_ascii_case("name"))
|
||
.map(|(_, v)| v.clone())
|
||
.unwrap_or_default();
|
||
if name.ends_with("AutoCastable") {
|
||
if let (Some(size), Some(btn)) = (dim(el, "Size", "x"), button) {
|
||
out.push((name.clone(), size * ART, btn));
|
||
}
|
||
}
|
||
for child in &el.children {
|
||
walk(child, button, out);
|
||
}
|
||
}
|
||
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut found = Vec::new();
|
||
for entry in std::fs::read_dir(&dir).expect("assets/ui") {
|
||
let path = entry.expect("entry").path();
|
||
if path.extension().is_none_or(|e| e != "xml") {
|
||
continue;
|
||
}
|
||
let src = std::fs::read_to_string(&path).expect("read");
|
||
for item in benilla_ui::framexml::parse(&src).expect("parse").items {
|
||
match item {
|
||
TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
|
||
walk(&el, None, &mut found)
|
||
}
|
||
TopLevel::Include(_) | TopLevel::Script(_) => {}
|
||
}
|
||
}
|
||
}
|
||
// The spell book's stock overlay is resized by a script, so it is measured off a VM.
|
||
let s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
|
||
let (overlay, button): (f32, f32) = s
|
||
.eval("return SpellButton1AutoCastable:GetWidth(), SpellButton1:GetWidth()")
|
||
.unwrap();
|
||
found.push(("SpellButton1AutoCastable".into(), overlay * ART, button));
|
||
// The pet button's is the stock template, measured the same way.
|
||
let s = pet_bar_vm();
|
||
let (overlay, button): (f32, f32) = s
|
||
.eval("return PetActionButton1AutoCastable:GetWidth(), PetActionButton1:GetWidth()")
|
||
.unwrap();
|
||
found.push(("PetActionButton1AutoCastable".into(), overlay * ART, button));
|
||
assert_eq!(found.len(), 2, "expected two autocast overlays: {found:?}");
|
||
for (name, brackets, button) in &found {
|
||
assert!(
|
||
(brackets / button - 0.997).abs() < 0.01,
|
||
"{name}: a {brackets:.1}-unit bracket square on a {button}-unit button is {:.3}x — the \
|
||
pet button's is 0.997x, which is what puts brackets IN the corners",
|
||
brackets / button
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The autocast shine's rim runs on its viewport edge (1.024×), as the stock pet button's does
|
||
/// (`setAllPoints` on 30×30, `scale="1.2"`). Deviation: the spell book re-seats the stock model at
|
||
/// 1.48 on 37 units (`SpellBookAdapters.xml`), because its stock `scale="1.22"` in 36 units is a
|
||
/// 0.87× rim that floats clear of the edge and washes the icon.
|
||
#[test]
|
||
fn the_shine_panes_ask_for_the_rims_we_meant() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut found: Vec<(String, f32, f32)> = Vec::new();
|
||
let mut s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
|
||
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
|
||
s.tick(0.05);
|
||
s.resolve();
|
||
let (scale, view): (f32, f32) = s
|
||
.eval("return SpellButton1AutoCast:GetModelScale(), SpellButton1AutoCast:GetWidth()")
|
||
.unwrap();
|
||
found.push(("SpellButton1AutoCast".into(), 0.02 * 1280.0 * scale, view));
|
||
let s = pet_bar_vm();
|
||
let (scale, view): (f32, f32) = s
|
||
.eval(
|
||
"return PetActionButton1AutoCast:GetModelScale(), PetActionButton1AutoCast:GetWidth()",
|
||
)
|
||
.unwrap();
|
||
found.push((
|
||
"PetActionButton1AutoCast".into(),
|
||
0.02 * 1280.0 * scale,
|
||
view,
|
||
));
|
||
for (name, rim, view) in &found {
|
||
assert!(
|
||
(rim / view - 1.024).abs() < 1e-3,
|
||
"{name}: a {rim}-unit rim in a {view}-unit viewport is {:.3}x, not the pet button's \
|
||
1.024x — a rim that does not reach its own viewport edge is never clipped, and reads \
|
||
as a wash rather than a rim",
|
||
rim / view
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Every texture path the shipped UI names resolves in the archives, through the renderer's own
|
||
/// `sprite_candidates` and its `.blp`/`.tga` fallback: a miss draws as a plain white quad. The
|
||
/// shape half runs without client data.
|
||
#[test]
|
||
fn every_shipped_texture_path_resolves_in_the_client_archives() {
|
||
use benilla_ui::framexml::{Element, TopLevel};
|
||
|
||
// `file=` on an element is an archive path; `<Script file=>` and `<Include file=>` are
|
||
// top-level items, not elements.
|
||
fn walk(el: &Element, file: &str, out: &mut Vec<(String, String, String)>) {
|
||
// `<Model file=>` names a model, never drawn as a texture.
|
||
if el.tag.eq_ignore_ascii_case("Model") {
|
||
return;
|
||
}
|
||
for (key, value) in el.attrs() {
|
||
let archive_path = ["file", "bgfile", "edgefile"]
|
||
.contains(&key.to_ascii_lowercase().as_str())
|
||
&& !value.is_empty();
|
||
if archive_path {
|
||
out.push((file.to_string(), el.tag.clone(), value.clone()));
|
||
}
|
||
}
|
||
for child in &el.children {
|
||
walk(child, file, out);
|
||
}
|
||
}
|
||
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut refs = Vec::new();
|
||
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") {
|
||
continue;
|
||
}
|
||
let text = std::fs::read_to_string(&path).expect("read");
|
||
let file = path.file_name().unwrap_or_default().to_string_lossy();
|
||
let doc = benilla_ui::framexml::parse(&text).expect("parses");
|
||
for item in &doc.items {
|
||
match item {
|
||
TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
|
||
walk(el, &file, &mut refs);
|
||
}
|
||
TopLevel::Include(_) | TopLevel::Script(_) => {}
|
||
}
|
||
}
|
||
}
|
||
// A floor, so the sweep cannot pass by matching nothing.
|
||
assert!(refs.len() >= 50, "only {} texture paths swept", refs.len());
|
||
|
||
// The shape half: a doubled separator is Lua escaping written into XML.
|
||
for (file, tag, path) in &refs {
|
||
assert!(
|
||
!path.contains("\\\\"),
|
||
"{file}: <{tag} file=\"{path}\"> has DOUBLED separators — XML attributes are not Lua \
|
||
strings, so the backslashes stay doubled, the archive lookup misses, and the widget \
|
||
draws as a white quad"
|
||
);
|
||
}
|
||
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let chain = benilla_formats::open_chain(&data).expect("open chain");
|
||
let missing: Vec<String> = refs
|
||
.iter()
|
||
.filter(|(_, _, path)| {
|
||
!benilla_assets::sprite_candidates(path)
|
||
.iter()
|
||
.any(|c| chain.contains(c))
|
||
})
|
||
.map(|(file, tag, path)| format!("{file}: <{tag} file=\"{path}\">"))
|
||
.collect();
|
||
assert!(
|
||
missing.is_empty(),
|
||
"texture paths that resolve to nothing (each draws as a white quad): {missing:#?}"
|
||
);
|
||
}
|
||
|
||
/// Every archive path a shipped Lua chunk names survives its own escaping: Lua silently drops the
|
||
/// backslash of an unknown escape, so `"Interface\P…"` arrives as `InterfaceP…` and the texture
|
||
/// never appears. The mirror of the XML check above, where a doubled separator is the bug.
|
||
#[test]
|
||
fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
|
||
use benilla_ui::framexml::{Element, ScriptRef, TopLevel};
|
||
|
||
// Archive roots a path literal starts with; other strings carry `\n` or `\124`, not paths.
|
||
const ROOTS: [&str; 7] = [
|
||
"interface",
|
||
"textures",
|
||
"world",
|
||
"sound",
|
||
"character",
|
||
"item",
|
||
"spells",
|
||
];
|
||
|
||
/// Every quoted literal in one Lua chunk as raw source text, escapes uninterpreted; comments
|
||
/// and long strings are skipped.
|
||
fn literals(chunk: &str) -> Vec<(String, bool)> {
|
||
let src: Vec<char> = chunk.chars().collect();
|
||
let at = |i: usize, s: &str| src[i..].starts_with(&s.chars().collect::<Vec<_>>()[..]);
|
||
let mut out = Vec::new();
|
||
let mut i = 0;
|
||
while i < src.len() {
|
||
if at(i, "--") {
|
||
i = if at(i + 2, "[[") {
|
||
src[i..]
|
||
.windows(2)
|
||
.position(|w| w == [']', ']'])
|
||
.map_or(src.len(), |k| i + k + 2)
|
||
} else {
|
||
src[i..]
|
||
.iter()
|
||
.position(|&c| c == '\n')
|
||
.map_or(src.len(), |k| i + k + 1)
|
||
};
|
||
continue;
|
||
}
|
||
if at(i, "[[") {
|
||
i = src[i + 2..]
|
||
.windows(2)
|
||
.position(|w| w == [']', ']'])
|
||
.map_or(src.len(), |k| i + 2 + k + 2);
|
||
continue;
|
||
}
|
||
let quote = src[i];
|
||
if quote != '"' && quote != '\'' {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
let (mut raw, mut j, mut closed) = (String::new(), i + 1, false);
|
||
while j < src.len() {
|
||
let c = src[j];
|
||
if c == '\\' && j + 1 < src.len() {
|
||
raw.push(c);
|
||
raw.push(src[j + 1]);
|
||
j += 2;
|
||
continue;
|
||
}
|
||
// A newline before the closing quote: not a literal (often an apostrophe).
|
||
if c == '\n' {
|
||
break;
|
||
}
|
||
if c == quote {
|
||
closed = true;
|
||
break;
|
||
}
|
||
raw.push(c);
|
||
j += 1;
|
||
}
|
||
if closed {
|
||
// Joined by `..` on either side, or a `%` template: a fragment, not a whole path.
|
||
let before = src[..i].iter().rposition(|c| !c.is_whitespace());
|
||
let after = src[j + 1..].iter().position(|c| !c.is_whitespace());
|
||
let joined = before.is_some_and(|k| k >= 1 && src[k] == '.' && src[k - 1] == '.')
|
||
|| after.is_some_and(|k| {
|
||
src.get(j + 1 + k) == Some(&'.') && src.get(j + 2 + k) == Some(&'.')
|
||
});
|
||
// A literal ending in a separator (a directory) or a dash (a family prefix,
|
||
// `"…\\MageFire-" .. rank`) is joined elsewhere; no texture name ends in either.
|
||
let fragment = raw.ends_with('\\') || raw.ends_with('-');
|
||
let whole = !joined && !fragment && !raw.contains('%');
|
||
out.push((raw, whole));
|
||
i = j + 1;
|
||
} else {
|
||
i += 1;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
// Every Lua chunk (top-level `<Script>` blocks and handler bodies), through the parser: an
|
||
// attribute value is also double-quoted, and a text scan cannot tell the two apart.
|
||
fn bodies(el: &Element, out: &mut Vec<String>) {
|
||
if !el.body.trim().is_empty() {
|
||
out.push(el.body.clone());
|
||
}
|
||
for child in &el.children {
|
||
bodies(child, out);
|
||
}
|
||
}
|
||
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut paths: Vec<(String, String, bool)> = Vec::new();
|
||
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") {
|
||
continue;
|
||
}
|
||
let text = std::fs::read_to_string(&path).expect("read");
|
||
let file = path.file_name().unwrap_or_default().to_string_lossy();
|
||
let mut chunks = Vec::new();
|
||
for item in &benilla_ui::framexml::parse(&text).expect("parses").items {
|
||
match item {
|
||
TopLevel::Script(ScriptRef::Inline { body, .. }) => chunks.push(body.clone()),
|
||
TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
|
||
bodies(el, &mut chunks);
|
||
}
|
||
TopLevel::Script(ScriptRef::File(_)) | TopLevel::Include(_) => {}
|
||
}
|
||
}
|
||
for (raw, whole) in chunks.iter().flat_map(|c| literals(c)) {
|
||
let lower = raw.to_ascii_lowercase();
|
||
if ROOTS.iter().any(|r| lower.starts_with(r)) && raw.contains('\\') {
|
||
paths.push((file.to_string(), raw, whole));
|
||
}
|
||
}
|
||
}
|
||
// A floor, so the sweep cannot pass by matching nothing.
|
||
assert!(
|
||
paths.len() >= 3,
|
||
"only {} archive paths swept out of the shipped Lua",
|
||
paths.len()
|
||
);
|
||
|
||
// The shape half: with every `\\` pair collapsed, no backslash may remain.
|
||
for (file, raw, _) in &paths {
|
||
assert!(
|
||
!raw.replace("\\\\", "").contains('\\'),
|
||
"{file}: the Lua literal \"{raw}\" has SINGLE separators — Lua drops the backslash \
|
||
from every one of them, so the path arrives with its folders run together, the \
|
||
archive lookup misses, and the texture silently never appears. Double them."
|
||
);
|
||
}
|
||
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let chain = benilla_formats::open_chain(&data).expect("open chain");
|
||
let missing: Vec<String> = paths
|
||
.iter()
|
||
// Only whole paths under the two texture roots resolve through `sprite_candidates`.
|
||
.filter(|(_, raw, whole)| {
|
||
let lower = raw.to_ascii_lowercase();
|
||
*whole && (lower.starts_with("interface") || lower.starts_with("textures"))
|
||
})
|
||
.filter(|(_, raw, _)| {
|
||
let real = raw.replace("\\\\", "\\");
|
||
!benilla_assets::sprite_candidates(&real)
|
||
.iter()
|
||
.any(|c| chain.contains(c))
|
||
})
|
||
.map(|(file, raw, _)| format!("{file}: \"{raw}\""))
|
||
.collect();
|
||
assert!(
|
||
missing.is_empty(),
|
||
"archive paths a shipped Lua chunk names that resolve to nothing: {missing:#?}"
|
||
);
|
||
}
|
||
|
||
/// Every shipped `text=` answers against the real `GlobalStrings.lua`: `text=` is a lookup
|
||
/// (`FrameScript_GetText`, `0x703bf0`) that shows the raw attribute only on a miss (`0x778c07`).
|
||
/// A key-shaped value must resolve, or it shows its own key; a literal must not collide with a
|
||
/// global, or the lookup swaps in that string.
|
||
#[test]
|
||
fn every_shipped_text_attribute_answers_against_the_real_global_strings() {
|
||
let data = benilla_formats::wow_data_or_skip!();
|
||
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
|
||
let src = chain
|
||
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
|
||
.expect("GlobalStrings.lua in the chain");
|
||
let s = benilla_ui::script::UiScript::new().expect("VM");
|
||
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
|
||
|
||
// The key shape, deliberately broader than the loader's runtime predicate (which ignores a
|
||
// miss with no underscore, a label rather than a key): this sweep is over our own files.
|
||
let key_shaped = |v: &str| {
|
||
v.len() >= 2
|
||
&& v.chars().any(|c| c.is_ascii_uppercase())
|
||
&& v.chars()
|
||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||
};
|
||
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut keys = 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") {
|
||
continue;
|
||
}
|
||
let text = std::fs::read_to_string(&path).expect("read");
|
||
let file = path.file_name().unwrap_or_default().to_string_lossy();
|
||
for value in text
|
||
.match_indices("text=\"")
|
||
.filter_map(|(i, m)| text[i + m.len()..].split('"').next())
|
||
{
|
||
let resolved = s.lua().globals().get::<String>(value).ok();
|
||
if key_shaped(value) {
|
||
assert!(
|
||
resolved.is_some_and(|t| !t.is_empty()),
|
||
"{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(),
|
||
"{file}: the literal text=\"{value}\" collides with a real GlobalStrings key — \
|
||
the loader would silently show that string's value instead of these words"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// A floor, so the sweep cannot pass by matching nothing.
|
||
assert!(keys >= 1, "only {keys} key-shaped text= values swept");
|
||
}
|
||
|
||
/// No shipped script hands a GlobalStrings key to a text sink as its words: `SetText` cannot tell
|
||
/// a key from a word, so `SetText("DEAD")` shows the key where the word is its value, "Dead"
|
||
/// (`GlobalStrings.lua:898`). The `text=` half is the sweep above.
|
||
#[test]
|
||
fn no_shipped_script_sets_a_global_string_key_as_display_text() {
|
||
/// The key shape, deliberately **broader** than `loader::is_global_string_key`: that one gates
|
||
/// a runtime warning and so ignores a miss with no underscore (`text="OK"` is a label an addon
|
||
/// author typed). Here every all-caps word counts, because this sweep is over our OWN shipped
|
||
/// files, where `text="DELETE"` must resolve and a literal that collides with a real key is a
|
||
/// bug either way. Restated rather than shared, for that reason and because it is private.
|
||
fn key_shaped(s: &str) -> bool {
|
||
s.len() >= 2
|
||
&& s.chars().any(|c| c.is_ascii_uppercase())
|
||
&& s.chars()
|
||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
|
||
}
|
||
|
||
// The string literal after each text sink's open paren on one line.
|
||
fn sink_literals(line: &str) -> Vec<&str> {
|
||
let mut out = Vec::new();
|
||
// No `:SetFormattedText(`: it is not a 1.12 verb.
|
||
for sink in [":SetText(", ":SetButtonText("] {
|
||
let mut rest = line;
|
||
while let Some(at) = rest.find(sink) {
|
||
rest = &rest[at + sink.len()..];
|
||
let arg = rest.trim_start();
|
||
if let Some(body) = arg.strip_prefix('"') {
|
||
if let Some(end) = body.find('"') {
|
||
out.push(&body[..end]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut offenders: Vec<String> = Vec::new();
|
||
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") {
|
||
continue;
|
||
}
|
||
let text = std::fs::read_to_string(&path).expect("read");
|
||
let mut in_comment = false;
|
||
for (n, raw) in text.lines().enumerate() {
|
||
// Strip whatever of this line sits inside an XML comment, carrying the state over.
|
||
let mut code = String::new();
|
||
let mut cur = raw;
|
||
loop {
|
||
if in_comment {
|
||
match cur.find("-->") {
|
||
Some(at) => {
|
||
in_comment = false;
|
||
cur = &cur[at + 3..];
|
||
}
|
||
None => break,
|
||
}
|
||
} else {
|
||
match cur.find("<!--") {
|
||
Some(at) => {
|
||
code.push_str(&cur[..at]);
|
||
in_comment = true;
|
||
cur = &cur[at + 4..];
|
||
}
|
||
None => {
|
||
code.push_str(cur);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if code.trim_start().starts_with("--") {
|
||
continue; // a Lua comment line
|
||
}
|
||
for lit in sink_literals(&code) {
|
||
if key_shaped(lit) {
|
||
offenders.push(format!(
|
||
"{}:{}: SetText(\"{lit}\") — a GlobalStrings KEY, not the word",
|
||
path.file_name().unwrap().to_string_lossy(),
|
||
n + 1
|
||
));
|
||
}
|
||
}
|
||
}
|
||
swept += 1;
|
||
}
|
||
assert!(offenders.is_empty(), "{}", offenders.join("\n"));
|
||
// A floor, so the sweep cannot pass by finding nothing.
|
||
assert!(swept >= 6, "only {swept} xml files swept — sweep broke");
|
||
}
|
||
|
||
/// Every `$parentTextureFrame` outranks its unit frame's status bars by frame level: level is the
|
||
/// only draw-key term above the layer, which would otherwise lift an ARTWORK bar fill over
|
||
/// BACKGROUND art. The stock frames spend a level on it (`TargetFrame.lua:32-34`) or nest the art
|
||
/// in anonymous frames (`PlayerFrame.xml:50-52`).
|
||
#[test]
|
||
fn every_texture_frame_outranks_its_status_bars() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
use benilla_ui::order::unpack;
|
||
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
// Every unit frame shown: a hidden frame emits no quads to read a level from.
|
||
for unit in [
|
||
"player",
|
||
"target",
|
||
"targettarget",
|
||
"party1",
|
||
"party2",
|
||
"party3",
|
||
"party4",
|
||
] {
|
||
s.set_unit(
|
||
unit,
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Someone".into()),
|
||
health: 60,
|
||
max_health: 100,
|
||
level: 60,
|
||
power_type: 0,
|
||
power: 60,
|
||
max_power: 100,
|
||
..benilla_ui::script::UnitState::default()
|
||
}),
|
||
);
|
||
}
|
||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
|
||
// Target-of-target ships off (`SHOW_TARGET_OF_TARGET = "0"`, `UIOptionsFrame.lua:116`); it
|
||
// holds the second of the reference's two named TextureFrames.
|
||
s.run(r#"SHOW_TARGET_OF_TARGET = "1""#).unwrap();
|
||
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
// owner frame name → its (strata, level), read off the packed draw key the renderer sorts by.
|
||
let mut level_of: std::collections::BTreeMap<String, (u8, u16)> =
|
||
std::collections::BTreeMap::new();
|
||
for q in s.extract() {
|
||
if let Some(name) = s.quad_owner_name(q.target) {
|
||
let p = unpack(q.z);
|
||
level_of.insert(name, (p.strata, p.level));
|
||
}
|
||
}
|
||
|
||
let mut checked = 0;
|
||
for (texture_frame, &(tf_strata, tf_level)) in &level_of {
|
||
let Some(base) = texture_frame.strip_suffix("TextureFrame") else {
|
||
continue;
|
||
};
|
||
for suffix in ["HealthBar", "ManaBar"] {
|
||
let bar = format!("{base}{suffix}");
|
||
let Some(&(bar_strata, bar_level)) = level_of.get(&bar) else {
|
||
continue;
|
||
};
|
||
assert_eq!(
|
||
bar_strata, tf_strata,
|
||
"{bar} and {texture_frame} must share a strata for the level to decide"
|
||
);
|
||
assert!(
|
||
tf_level > bar_level,
|
||
"{texture_frame} (level {tf_level}) must outrank {bar} (level {bar_level}): \
|
||
tied, the draw layer lifts the bar's ARTWORK fill over the frame's BACKGROUND art"
|
||
);
|
||
checked += 1;
|
||
}
|
||
}
|
||
// The reference names two TextureFrames, `TargetFrameTextureFrame` and
|
||
// `TargetofTargetTextureFrame` (TargetFrame.xml); the player, pet and party frames nest their
|
||
// art in anonymous frames instead: two families with two bars each.
|
||
assert!(
|
||
checked >= 4,
|
||
"only {checked} texture-frame/bar pairs checked — the name sweep found nothing"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The boot phase is inert: `Fonts.xml`, the only file loaded at `Startup`, is a pure registry and
|
||
/// materializes no frames.
|
||
#[test]
|
||
fn the_boot_phase_materializes_no_frames() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// Manifest entry 0, off the chain; `load_ui` fails on any loader error.
|
||
let frames = super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
|
||
assert_eq!(
|
||
frames, 0,
|
||
"the boot-phase load materialized {frames} frame(s) — the login screen is meant to carry none"
|
||
);
|
||
}
|
||
|
||
/// The boot-time font registry alone declares every (font, height, outline) the whole manifest
|
||
/// does; a failure is a font object outside `Fonts.xml` with a new height or an outline.
|
||
#[test]
|
||
fn the_font_registry_alone_covers_the_whole_bake_plan() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let plan = |whole: bool| -> std::collections::BTreeSet<(String, String, String)> {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
if whole {
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let _ = super::load_default_ui(&s);
|
||
} else {
|
||
let _ = super::load_font_registry(&s);
|
||
}
|
||
s.font_objects()
|
||
.iter()
|
||
.map(|f| {
|
||
(
|
||
f.font.clone().unwrap_or_default().to_ascii_lowercase(),
|
||
format!("{:?}", f.height),
|
||
format!("{:?}", f.outline),
|
||
)
|
||
})
|
||
.collect()
|
||
};
|
||
let whole = plan(true);
|
||
let registry_only = plan(false);
|
||
let missing: Vec<_> = whole.difference(®istry_only).collect();
|
||
assert!(
|
||
missing.is_empty(),
|
||
"these (font, height, outline) combinations exist in the full manifest but NOT in the \
|
||
boot-time font registry, so the atlas would never bake them: {missing:#?}"
|
||
);
|
||
// Never let this pass by finding nothing on both sides.
|
||
assert!(
|
||
registry_only.len() >= 19,
|
||
"only {} combinations swept — the registry sweep broke",
|
||
registry_only.len()
|
||
);
|
||
}
|
||
|
||
/// The shipped UI takes `VARIABLES_LOADED`, which the saved-variables load fires at every launch
|
||
/// before any window has shown, without a script error.
|
||
#[test]
|
||
fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "loader errors: {failures:?}");
|
||
let _ = s.errors(); // drain the load's own errors
|
||
s.fire_event("VARIABLES_LOADED", vec![]);
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"VARIABLES_LOADED script errors: {:?}",
|
||
s.errors()
|
||
);
|
||
}
|
||
|
||
/// Every `<Font name=…>` the manifest declares is a `Font` global answering the FontInstance
|
||
/// getters, in manifest order: `publish_global` never overwrites, so a same-named frame loaded
|
||
/// first would keep a font unpublished.
|
||
#[test]
|
||
fn every_shipped_font_object_is_published_as_a_lua_global() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "loader errors: {failures:?}");
|
||
|
||
// The names come from the manifest's own entries, chain files included.
|
||
let mut names: Vec<String> = Vec::new();
|
||
for entry in &super::addons::Addon::builtin().toc.files {
|
||
if !entry.to_ascii_lowercase().ends_with(".xml") {
|
||
continue;
|
||
}
|
||
let Some(bytes) = super::test_ui::read(&entry.replace('\\', "/")) else {
|
||
continue;
|
||
};
|
||
let text = benilla_ui::source::decode(&bytes);
|
||
for chunk in text.split("<Font ").skip(1) {
|
||
if let Some(rest) = chunk.split_once("name=\"") {
|
||
if let Some((name, _)) = rest.1.split_once('"') {
|
||
names.push(name.to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
names.sort();
|
||
names.dedup();
|
||
assert!(
|
||
names.len() >= 50,
|
||
"only {} <Font name=> declarations found — the sweep broke",
|
||
names.len()
|
||
);
|
||
|
||
let mut unpublished: Vec<&str> = Vec::new();
|
||
for name in &names {
|
||
match s.eval::<String>(&format!("return {name}:GetObjectType()")) {
|
||
Ok(t) if t == "Font" => {}
|
||
_ => unpublished.push(name),
|
||
}
|
||
}
|
||
assert!(
|
||
unpublished.is_empty(),
|
||
"declared <Font name=> that is not a Font global: {unpublished:?}"
|
||
);
|
||
|
||
// The four font objects addons use most carry a face and a size, not just a name.
|
||
for name in [
|
||
"GameFontNormal",
|
||
"GameTooltipText",
|
||
"GameFontHighlightSmall",
|
||
"GameTooltipHeaderText",
|
||
] {
|
||
let (face, height) = s
|
||
.eval::<(String, f32)>(&format!("return {name}:GetFont()"))
|
||
.unwrap_or_else(|e| panic!("{name}:GetFont() — {e}"));
|
||
assert!(
|
||
face.to_ascii_uppercase().ends_with(".TTF"),
|
||
"{name}: {face}"
|
||
);
|
||
assert!(height > 0.0, "{name}: height {height}");
|
||
}
|
||
}
|
||
|
||
/// `TargetBuffButtonTemplate` (`TargetFrame.xml`, 21×21) and `MainMenuBarMicroButton`
|
||
/// (`MainMenuBarMicroButtons.xml`, 29×58) confer their size, regions and scripts, read back as a
|
||
/// consumer gets them: an unresolved `inherits=` fails silently.
|
||
#[test]
|
||
fn the_inheritable_reference_templates_confer_their_shape() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
assert!(super::load_default_ui(&s).is_empty());
|
||
|
||
s.run(
|
||
r#"
|
||
BuffProbe = CreateFrame("Button", "BuffProbe", UIParent, "TargetBuffButtonTemplate")
|
||
MicroProbe = CreateFrame("Button", "MicroProbe", UIParent, "MainMenuBarMicroButton")
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
|
||
assert_eq!(
|
||
s.eval::<(f64, f64)>("return BuffProbe:GetWidth(), BuffProbe:GetHeight()")
|
||
.unwrap(),
|
||
(21.0, 21.0),
|
||
"the buff button carries the reference's 21x21"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return BuffProbeIcon ~= nil").unwrap(),
|
||
"$parentIcon is the region a consumer getglobals to set the texture"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>(r#"return BuffProbe:GetScript("OnEnter") ~= nil"#)
|
||
.unwrap(),
|
||
"the tooltip script comes with the template"
|
||
);
|
||
|
||
assert_eq!(
|
||
s.eval::<(f64, f64)>("return MicroProbe:GetWidth(), MicroProbe:GetHeight()")
|
||
.unwrap(),
|
||
(29.0, 58.0)
|
||
);
|
||
assert!(s
|
||
.eval::<bool>(r#"return MicroProbe:GetScript("OnEnter") ~= nil"#)
|
||
.unwrap());
|
||
assert!(s.errors().is_empty(), "no script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The stock inspect-cursor pair, `CursorUpdate` and `CursorOnUpdate` (`UIParent.lua:1805-1817`),
|
||
/// runs clean on the arms a frame without an item takes.
|
||
#[test]
|
||
fn the_inspect_cursor_pair_takes_both_arms() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
assert!(super::load_default_ui(&s).is_empty());
|
||
|
||
// Both exist as functions, the shape a caller checks before hooking.
|
||
assert_eq!(
|
||
s.eval::<(String, String)>("return type(CursorUpdate), type(CursorOnUpdate)")
|
||
.unwrap(),
|
||
("function".into(), "function".into())
|
||
);
|
||
|
||
// A frame without `this.hasItem` takes the `ResetCursor` arm.
|
||
s.run(
|
||
r#"
|
||
CursorProbe = CreateFrame("Frame", "CursorProbe", UIParent)
|
||
this = CursorProbe
|
||
CursorUpdate()
|
||
this = nil
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"the ResetCursor arm runs clean: {:?}",
|
||
s.errors()
|
||
);
|
||
|
||
// `CursorOnUpdate` does nothing unless the frame owns the tooltip.
|
||
s.run("this = CursorProbe CursorOnUpdate() this = nil")
|
||
.unwrap();
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"the unowned-tooltip gate short-circuits: {:?}",
|
||
s.errors()
|
||
);
|
||
}
|
||
|
||
/// Nothing of the interface stays visible through a cinematic, swept over what is visible rather
|
||
/// than a list. The one survivor is `CinematicFrame`, which the reference declares with no parent
|
||
/// and `SetFullScreenFrame` shows as it hides `UIParent` (`UIParent.lua:852`).
|
||
#[test]
|
||
fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
s.resolve();
|
||
|
||
// Our files' frames plus the stock HUD, which is most of what the cascade hides.
|
||
let mut names = shipped_frame_names();
|
||
names.extend(
|
||
[
|
||
"MainMenuBar",
|
||
"ChatFrame1",
|
||
"PlayerFrame",
|
||
"MinimapCluster",
|
||
"BuffFrame",
|
||
"MainMenuBarBackpackButton",
|
||
"CharacterMicroButton",
|
||
"UIErrorsFrame",
|
||
// The frame being shown, the one expected survivor.
|
||
"CinematicFrame",
|
||
]
|
||
.into_iter()
|
||
.map(String::from),
|
||
);
|
||
let visible = |s: &benilla_ui::script::UiScript, n: &str| -> bool {
|
||
s.eval::<i64>(&format!(
|
||
"local f = getglobal(\"{n}\") \
|
||
if not f or not f.IsVisible then return 0 end \
|
||
return f:IsVisible() and 1 or 0"
|
||
))
|
||
.unwrap_or(0)
|
||
== 1
|
||
};
|
||
|
||
// The sweep is worth something only if there was something to hide.
|
||
let before = names.iter().filter(|n| visible(&s, n)).count();
|
||
assert!(
|
||
// A floor over the named HUD, not over our files' census.
|
||
before >= 6,
|
||
"only {before} frames visible before the cinematic — the sweep found no interface to \
|
||
hide, so it would pass no matter what the cascade did"
|
||
);
|
||
|
||
s.set_in_cinematic(true);
|
||
s.fire_event("CINEMATIC_START", vec![]);
|
||
s.resolve();
|
||
|
||
let mut after: Vec<&str> = names
|
||
.iter()
|
||
.map(String::as_str)
|
||
.filter(|n| visible(&s, n))
|
||
.collect();
|
||
after.sort_unstable();
|
||
assert_eq!(
|
||
after,
|
||
["CinematicFrame"],
|
||
"something is drawing over the fly-by (see this test's header for why exactly these \
|
||
two are allowed to survive)"
|
||
);
|
||
|
||
// …and the player gets it all back.
|
||
s.set_in_cinematic(false);
|
||
s.fire_event("CINEMATIC_STOP", vec![]);
|
||
s.resolve();
|
||
let restored = names.iter().filter(|n| visible(&s, n)).count();
|
||
assert_eq!(
|
||
restored, before,
|
||
"the interface comes back exactly as it was"
|
||
);
|
||
}
|
||
|
||
/// Every `parent=` the shipped tree declares attaches, read off the loaded tree: a parent name that
|
||
/// names nothing only warns, and the loader falls back to the enclosing element.
|
||
#[test]
|
||
fn every_declared_parent_really_attaches() {
|
||
benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI loads on world entry, so a player always exists by then.
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefour".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
s.resolve();
|
||
|
||
let declared = shipped_frame_parents();
|
||
assert!(
|
||
// A sanity floor for the scan, not a census.
|
||
declared.len() >= 2,
|
||
"only {} parent declarations found — the scan broke",
|
||
declared.len()
|
||
);
|
||
let mut wrong: Vec<String> = Vec::new();
|
||
for (child, parent) in &declared {
|
||
let got = s
|
||
.eval::<String>(&format!(
|
||
"local f = getglobal(\"{child}\") \
|
||
if not f or not f.GetParent then return \"<not a frame>\" end \
|
||
local p = f:GetParent() \
|
||
return p and (p:GetName() or \"<anonymous>\") or \"<none>\""
|
||
))
|
||
.unwrap_or_else(|_| "<error>".to_string());
|
||
if got != *parent {
|
||
wrong.push(format!(
|
||
" {child}: declared parent={parent}, attached to {got}"
|
||
));
|
||
}
|
||
}
|
||
assert!(
|
||
wrong.is_empty(),
|
||
"{} frame(s) did not attach to the parent they declare:\n{}",
|
||
wrong.len(),
|
||
wrong.join("\n")
|
||
);
|
||
}
|
||
|
||
/// Every named, non-virtual frame in the shipped tree that declares a `parent=`, with that parent.
|
||
fn shipped_frame_parents() -> Vec<(String, String)> {
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut out: Vec<(String, String)> = Vec::new();
|
||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||
let path = entry.path();
|
||
if !path.extension().is_some_and(|e| e == "xml") {
|
||
continue;
|
||
}
|
||
let text = std::fs::read_to_string(&path).expect("read");
|
||
for chunk in text.split('<').skip(1) {
|
||
let head = &chunk[..chunk.find('>').unwrap_or(chunk.len())];
|
||
if head.contains("virtual=\"true\"") {
|
||
continue;
|
||
}
|
||
let attr = |key: &str| -> Option<String> {
|
||
let i = head.find(key)?;
|
||
let rest = &head[i + key.len()..];
|
||
let j = rest.find('"')?;
|
||
Some(rest[..j].to_string())
|
||
};
|
||
let (Some(name), Some(parent)) = (attr("name=\""), attr("parent=\"")) else {
|
||
continue;
|
||
};
|
||
if !name.contains('$') && !parent.contains('$') {
|
||
out.push((name, parent));
|
||
}
|
||
}
|
||
}
|
||
out.sort();
|
||
out.dedup();
|
||
out
|
||
}
|
||
|
||
/// Every named, non-virtual frame the shipped tree declares.
|
||
pub(super) fn shipped_frame_names() -> Vec<String> {
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut names: Vec<String> = Vec::new();
|
||
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
|
||
let path = entry.path();
|
||
if !path.extension().is_some_and(|e| e == "xml") {
|
||
continue;
|
||
}
|
||
let text = std::fs::read_to_string(&path).expect("read");
|
||
for chunk in text.split('<').skip(1) {
|
||
let head = &chunk[..chunk.find('>').unwrap_or(chunk.len())];
|
||
if head.contains("virtual=\"true\"") {
|
||
continue;
|
||
}
|
||
let Some(i) = head.find("name=\"") else {
|
||
continue;
|
||
};
|
||
let rest = &head[i + 6..];
|
||
let Some(j) = rest.find('"') else { continue };
|
||
let n = &rest[..j];
|
||
// `$parent`-templated names are not globals; nothing can look them up by name.
|
||
if !n.is_empty() && !n.contains('$') {
|
||
names.push(n.to_string());
|
||
}
|
||
}
|
||
}
|
||
names.sort();
|
||
names.dedup();
|
||
names
|
||
}
|
||
|
||
/// The macro icon picker opens off the shipped manifest, not a harness list: the on-demand macro
|
||
/// window's `MacroPopupScrollFrame` inherits a template only the manifest's
|
||
/// `ClassTrainerFrameTemplates.xml` row brings.
|
||
#[test]
|
||
fn the_shipped_manifest_opens_the_macro_icon_picker() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefive".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
// One saved macro, so "Change Name/Icon" has something selected.
|
||
s.set_macros(benilla_ui::script::MacroState {
|
||
account: vec![benilla_ui::script::MacroView {
|
||
name: "die".into(),
|
||
texture: Some(r"Interface\Icons\Ability_Ambush".into()),
|
||
body: ".die".into(),
|
||
..Default::default()
|
||
}],
|
||
character: Vec::new(),
|
||
});
|
||
s.set_macro_icons(vec![
|
||
r"Interface\Icons\Ability_Ambush".into(),
|
||
r"Interface\Icons\Ability_Backstab".into(),
|
||
]);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
s.resolve();
|
||
let _ = s.errors();
|
||
|
||
// A LoadOnDemand addon, which `ShowMacroFrame` loads through the stock `MacroFrame_LoadUI`.
|
||
super::test_ui::seat_chain_addon(&mut s, "Blizzard_MacroUI");
|
||
s.run("ShowMacroFrame()").unwrap();
|
||
s.run("MacroButton1:Click()").unwrap();
|
||
s.run("MacroEditButton:Click()").unwrap();
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"opening the icon picker off the shipped manifest must not raise: {:?}",
|
||
s.errors()
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return MacroPopupFrame:IsShown() and true or false")
|
||
.unwrap(),
|
||
"the picker is up"
|
||
);
|
||
}
|
||
|
||
/// The stock Interface Options window loads hidden as its own frame, and ours is still the one the
|
||
/// ESC menu opens. It loads before `MultiActionBars.lua:10` sets fields on five of its check-button
|
||
/// rows, as in `FrameXML.toc` (lines 21 and 39).
|
||
#[test]
|
||
fn the_stock_interface_options_window_loads_hidden_and_ours_is_still_the_players() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefive".into()),
|
||
level: 60,
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
|
||
// The stock frame, by the children only it declares.
|
||
for name in [
|
||
"UIOptionsFrame",
|
||
"UIOptionsFrameTitle",
|
||
"UIOptionsFrameTab1",
|
||
"UIOptionsFrameTab2",
|
||
"UIOptionsFrameOkay",
|
||
"UIOptionsFrameCancel",
|
||
"UIOptionsFrameDefaults",
|
||
"UIOptionsFrameResetTutorials",
|
||
"UIOptionsFrameCheckButton1",
|
||
"UIOptionsFrameCheckButton69",
|
||
"UIOptionsFrameSlider1",
|
||
"UIOptionsFrameSlider4",
|
||
"UIOptionsFrameClickCameraDropDown",
|
||
"UIOptionsFrameCameraDropDown",
|
||
"UIOptionsFrameTargetofTargetDropDown",
|
||
"UIOptionsFrameCombatTextDropDown",
|
||
"BasicOptions",
|
||
"BasicOptionsGeneral",
|
||
"BasicOptionsDisplay",
|
||
"BasicOptionsCamera",
|
||
"BasicOptionsHelp",
|
||
"AdvancedOptions",
|
||
"AdvancedOptionsActionBars",
|
||
"AdvancedOptionsChat",
|
||
"AdvancedOptionsRaid",
|
||
"AdvancedOptionsCombatText",
|
||
] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return getglobal({name:?}) ~= nil"))
|
||
.unwrap(),
|
||
"{name} — pfUI's options-interface skin walks every one of these"
|
||
);
|
||
}
|
||
assert!(
|
||
s.eval::<bool>("return UIOptionsFrame ~= BenillaOptionsFrame")
|
||
.unwrap(),
|
||
"the alias is gone: the stock frame is its own frame, not ours under a second name"
|
||
);
|
||
|
||
// …and hidden, by its own file's attribute.
|
||
assert!(
|
||
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
|
||
"the stock window must never be on screen"
|
||
);
|
||
|
||
// The load-order proof, the other way round from 1.12's. Stock patched these five rows from
|
||
// `MultiActionBarFrame_OnLoad`, under a comment calling itself a "Hack to get around load
|
||
// order dependencies", which let the options window load **first**. The realm gutted that
|
||
// function (`MultiActionBars.lua:7-12`) and wrote the readers into the table literal instead
|
||
// (`UIOptionsFrame.lua:58-62`), where they capture whatever the globals are at the window's own
|
||
// load — so the window's `benilla.toc` row has to sit **below** the bars'. Below them it does:
|
||
// all five rows carry their readers. Above them all five capture nil, the rows fall through
|
||
// `UIOptionsFrame_Save`'s arms to `SetCVar(nil, ...)`, and that raises — `0x488c10` checks
|
||
// `lua_isstring(1)` and takes `luaL_error("Usage: SetCVar(…)")` (`0x6f4940` is `luaL_error`:
|
||
// `where` + `pushvfstring` + `concat 2` + `lua_error`) — abandoning the rest of the save.
|
||
let rows: Vec<String> = s
|
||
.eval::<Vec<String>>(
|
||
"local out = {} \
|
||
for _, k in ipairs({ \"SHOW_MULTIBAR1_TEXT\", \"SHOW_MULTIBAR2_TEXT\", \
|
||
\"SHOW_MULTIBAR3_TEXT\", \"SHOW_MULTIBAR4_TEXT\", \"ALWAYS_SHOW_MULTIBARS_TEXT\" }) do \
|
||
local row = UIOptionsFrameCheckButtons[k] \
|
||
if row and row.func and row.setFunc then table.insert(out, k) end \
|
||
end \
|
||
return out",
|
||
)
|
||
.expect("UIOptionsFrameCheckButtons is a table with rows");
|
||
assert_eq!(
|
||
rows.len(),
|
||
5,
|
||
"all five multibar rows must carry the readers `MultiActionBars.lua` defines — got {rows:?}"
|
||
);
|
||
|
||
// Ours is still the window the ESC menu opens.
|
||
assert_eq!(
|
||
s.eval::<String>(
|
||
"return GameMenuButtonOptions:GetScript(\"OnClick\") and \"bound\" or \"\""
|
||
)
|
||
.unwrap(),
|
||
"bound"
|
||
);
|
||
s.run("GameMenuButtonOptions:Click()").unwrap();
|
||
assert!(
|
||
s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
|
||
.unwrap(),
|
||
"the player's Options button opens OUR window"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
|
||
"…and never the stock one"
|
||
);
|
||
}
|
||
|
||
/// The stock Sound Options window loads hidden (`SoundOptionsFrame.xml:18`) as its own frame; ours
|
||
/// loads later, so an alias would replace the real frame. `SoundOptionsFrame_Load()` runs clean
|
||
/// with every CVar its tables name, and the ESC menu still opens ours.
|
||
#[test]
|
||
fn the_stock_sound_options_window_loads_hidden_and_the_alias_is_gone() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefive".into()),
|
||
level: 60,
|
||
class: Some("Warrior".into()),
|
||
class_file: Some("WARRIOR".into()),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
|
||
// The stock frame, by the children only it declares; the stock file has no CheckButton3.
|
||
for name in [
|
||
"SoundOptionsFrame",
|
||
"SoundOptionsFrameHeader",
|
||
"SoundOptionsFrameOkay",
|
||
"SoundOptionsFrameCancel",
|
||
"SoundOptionsFrameDefaults",
|
||
"SoundOptionsFrameCheckButton1",
|
||
"SoundOptionsFrameCheckButton2",
|
||
"SoundOptionsFrameCheckButton4",
|
||
"SoundOptionsFrameCheckButton8",
|
||
"SoundOptionsFrameSlider1",
|
||
"SoundOptionsFrameSlider4",
|
||
] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return getglobal({name:?}) ~= nil"))
|
||
.unwrap(),
|
||
"{name} — pfUI's options-sound skin walks every one of these"
|
||
);
|
||
}
|
||
assert!(
|
||
s.eval::<bool>("return SoundOptionsFrame ~= BenillaOptionsFrame")
|
||
.unwrap(),
|
||
"the alias is gone — and because OUR file loads later, an alias would have CLOBBERED the \
|
||
real frame rather than merely shadowed it"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return SoundOptionsFrameCheckButton3 == nil")
|
||
.unwrap(),
|
||
"the stock file declares 1,2,4..8 — a CheckButton3 here means this is not that file"
|
||
);
|
||
|
||
// …and hidden, by its own file's attribute.
|
||
assert!(
|
||
!s.eval::<bool>("return SoundOptionsFrame:IsShown()")
|
||
.unwrap(),
|
||
"the stock Sound window must never be on screen"
|
||
);
|
||
|
||
// `_Load` runs clean, and every CVar its two tables name answers.
|
||
s.run("this = SoundOptionsFrameOkay SoundOptionsFrame_Load()")
|
||
.expect("_Load runs to completion");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
let unbacked: Vec<String> = s
|
||
.eval(
|
||
"local out = {} \
|
||
for _, v in ipairs(SoundOptionsFrameSliders) do \
|
||
if GetCVar(v.cvar) == nil then table.insert(out, v.cvar) end \
|
||
end \
|
||
for _, v in ipairs(SoundOptionsFrameCheckButtons) do \
|
||
if v.cvar and GetCVar(v.cvar) == nil then table.insert(out, v.cvar) end \
|
||
end \
|
||
return out",
|
||
)
|
||
.expect("read the Sound window's own two tables");
|
||
assert!(
|
||
unbacked.is_empty(),
|
||
"these Sound-window CVars answer nil: {unbacked:?} — a SLIDER among them is a live raise"
|
||
);
|
||
|
||
// Ours is still the window the ESC menu opens.
|
||
s.run("GameMenuButtonOptions:Click()").unwrap();
|
||
assert!(
|
||
s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
|
||
.unwrap(),
|
||
"the player's Options button opens OUR window"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return SoundOptionsFrame:IsShown()")
|
||
.unwrap(),
|
||
"…and never the stock Sound one"
|
||
);
|
||
|
||
// `IsOptionFrameOpen` names the stock options windows, all hidden; it sees ours through the
|
||
// wrapper `GameMenuFrame.xml` installs.
|
||
assert!(
|
||
s.eval::<bool>("return IsOptionFrameOpen() and true or false")
|
||
.unwrap(),
|
||
"UIParent.lua:997 must still see an open options window with the alias retired"
|
||
);
|
||
}
|
||
|
||
/// 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.
|
||
#[test]
|
||
fn the_stock_video_options_window_loads_hidden_and_owns_its_own_name() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// 1600x900 is not one of the monitor's modes, as a player's windowed size usually is not;
|
||
// `GetCurrentResolution` must still index a row that exists.
|
||
s.set_screen_resolutions(
|
||
vec![
|
||
benilla_ui::script::ScreenResolution {
|
||
width: 1280,
|
||
height: 720,
|
||
},
|
||
benilla_ui::script::ScreenResolution {
|
||
width: 1920,
|
||
height: 1080,
|
||
},
|
||
],
|
||
Some(benilla_ui::script::ScreenResolution {
|
||
width: 1600,
|
||
height: 900,
|
||
}),
|
||
);
|
||
s.set_video_caps(benilla_ui::script::VideoCaps {
|
||
anisotropic: true,
|
||
pixel_shaders: true,
|
||
vertex_shaders: true,
|
||
trilinear: true,
|
||
triple_buffering: false,
|
||
max_anisotropy: 16,
|
||
hardware_cursor: true,
|
||
});
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefive".into()),
|
||
level: 60,
|
||
class: Some("Warrior".into()),
|
||
class_file: Some("WARRIOR".into()),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
|
||
// The stock frame, by the children only it declares.
|
||
for name in [
|
||
// The realm rewrote this window end to end. What is left of 1.12's video page is the
|
||
// frame, its header and the three footer buttons; everything else is new — a category
|
||
// list, a scroll frame, a search box and twenty result rows. Gone with the old page:
|
||
// `OptionsFrameDisplay`, `OptionsFrameWorldAppearance`, `OptionsFrameBrightness`,
|
||
// `OptionsFramePixelShaders`, `OptionsFrameMiscellaneous`, the three resolution/refresh/
|
||
// multisample dropdowns, `OptionsFrameSlider1..9` and `OptionsFrameCheckButton1..18`.
|
||
//
|
||
// That is a compatibility fact worth stating rather than asserting around: **pfUI's
|
||
// options-video skin walks the old names and finds none of them**, so on this realm it
|
||
// skins nothing there. Not ours to fix, and not a defect of ours either.
|
||
"OptionsFrameHeader",
|
||
"OptionsFrameOkay",
|
||
"OptionsFrameCancel",
|
||
"OptionsFrameDefaults",
|
||
"OptionsFrameCategoryList",
|
||
// Nested inside the list, so `$parent` is the list and not the window.
|
||
"OptionsFrameCategoryListCategory1",
|
||
"OptionsFrameCategoryListCategory18",
|
||
"OptionsFrameCategoryListScrollFrame",
|
||
"OptionsFrameContentScrollFrame",
|
||
"OptionsFrameSearchBox",
|
||
"OptionsFrameSearchResultsResult1",
|
||
"OptionsFrameSearchResultsResult20",
|
||
] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return getglobal({name:?}) ~= nil"))
|
||
.unwrap(),
|
||
"{name} — the realm's own options window publishes this"
|
||
);
|
||
}
|
||
assert!(
|
||
s.eval::<bool>("return OptionsFrame ~= BenillaOptionsFrame")
|
||
.unwrap(),
|
||
"the video window's name is the video window's; ours answers to its own"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return OptionsFrameCancel:GetParent() == OptionsFrame")
|
||
.unwrap(),
|
||
"`OptionsFrameCancel` is the stock window's own button now — the alias that pointed it at \
|
||
our Close button would have clobbered it, ours loading later"
|
||
);
|
||
|
||
// …and hidden, by its own file's attribute.
|
||
assert!(
|
||
!s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
|
||
"the stock Video window must never be on screen"
|
||
);
|
||
|
||
// The stock nine slider rows, and our Graphics page standing on them.
|
||
assert_eq!(
|
||
s.eval::<f64>("return table.getn(OptionsFrameSliders)")
|
||
.unwrap() as i32,
|
||
9,
|
||
"our three-row overwrite is gone — this is the reference's table"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return OptionsFrameSliders[3].func")
|
||
.unwrap(),
|
||
"WorldDetail",
|
||
"pfUI's hdgraphic writes index 3 and must still land on Environment Detail"
|
||
);
|
||
let (lo, hi): (f64, f64) = s
|
||
.eval(
|
||
"local sl = BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlSlider \
|
||
return sl:GetMinMaxValues()",
|
||
)
|
||
.expect("our Terrain Distance row's bounds");
|
||
assert_eq!(
|
||
(lo, hi),
|
||
(
|
||
s.eval::<f64>("return OptionsFrameSliders[2].minValue")
|
||
.unwrap(),
|
||
s.eval::<f64>("return OptionsFrameSliders[2].maxValue")
|
||
.unwrap()
|
||
),
|
||
"our row is built from the REFERENCE's row 2, not from a transcription of it"
|
||
);
|
||
|
||
// The display verbs, at the shapes their consumers read.
|
||
let res: Vec<String> = s
|
||
.eval("return { GetScreenResolutions() }")
|
||
.expect("the resolution list");
|
||
assert_eq!(
|
||
res,
|
||
["1280x720", "1600x900", "1920x1080"],
|
||
"`WxH`, ascending by pixel AREA (the reference's own key), with the live windowed size \
|
||
folded in — 1600×900 is not a monitor mode and is exactly the case CT_Viewport needs"
|
||
);
|
||
// `CT_Viewport.lua:105-107`, verbatim: index the varargs, then parse `WxH`.
|
||
let (w, h): (f64, f64) = s
|
||
.eval(
|
||
"local function pick(...) local r = arg[GetCurrentResolution()] \
|
||
local _, _, x, y = string.find(r, \"(%d+)x(%d+)\") return tonumber(x), tonumber(y) end \
|
||
return pick(GetScreenResolutions())",
|
||
)
|
||
.expect("CT_Viewport's own read");
|
||
assert_eq!(
|
||
(w, h),
|
||
(1600.0, 900.0),
|
||
"CT_Viewport must find the live size"
|
||
);
|
||
// No return values, the reference's "nothing to offer" (`0x48c136 xor eax,eax`); the stock
|
||
// lone-`0` branch of `OptionsFrame_GetRefreshRates` needs the OS to report 0 Hz for every mode.
|
||
let rates: Vec<f64> = s.eval("return { GetRefreshRates() }").expect("the rates");
|
||
assert!(
|
||
rates.is_empty(),
|
||
"the reference answers ZERO values, never a lone 0: {rates:?}"
|
||
);
|
||
// The stock refresh dropdown this used to leave empty-but-enabled is gone with the rest of
|
||
// 1.12's video page (see the name list above), so there is no `OptionsFrameRefreshDropDown`
|
||
// to ask. The verb's shape is the part that still matters, and it is asserted here: zero
|
||
// values, never a lone 0.
|
||
assert!(
|
||
s.eval::<bool>("return OptionsFrameRefreshDropDown == nil")
|
||
.unwrap(),
|
||
"the realm's rewritten window declares no refresh dropdown"
|
||
);
|
||
// The optional index argument is tolerated.
|
||
assert!(
|
||
s.eval::<bool>("return table.getn({ GetRefreshRates(2) }) == 0")
|
||
.unwrap(),
|
||
"the argument is optional AND ignored here; it must not raise"
|
||
);
|
||
// The caps, at the types `OptionsFrame_Load` reads them as.
|
||
let caps: Vec<String> = s
|
||
.eval(
|
||
"local out = {} \
|
||
local t = { GetVideoCaps() } \
|
||
for i = 1, 7 do table.insert(out, tostring(t[i])) end \
|
||
return out",
|
||
)
|
||
.expect("the seven caps");
|
||
assert_eq!(
|
||
caps,
|
||
["1", "1", "1", "1", "0", "16", "1"],
|
||
"THREE shapes: four flags as 1/nil, slot 5 an unconditional NUMBER, slot 6 raw"
|
||
);
|
||
// Slot 5 is never nil and `0` is truthy, so the stock `not hasTripleBuffering` tests
|
||
// (`OptionsFrame.lua:87`, `:386`) are dead in the reference too; only `== 1` (`:159`) decides.
|
||
assert!(
|
||
s.eval::<bool>(
|
||
"local a, b, c, d, hasTriple = GetVideoCaps() \
|
||
return (not (not hasTriple)) and hasTriple ~= 1"
|
||
)
|
||
.unwrap(),
|
||
"`not hasTripleBuffering` must stay FALSE (the dead clause) while `== 1` is also false"
|
||
);
|
||
|
||
// `SetScreenResolution`: no argument picks entry one and a fraction truncates, as in the
|
||
// reference. Deviation: an index past the end clamps to the last entry, because the reference
|
||
// reads one past the end of its list.
|
||
assert!(
|
||
s.eval::<bool>("SetScreenResolution() return GetCVar(\"gxResolution\") == \"1280x720\"")
|
||
.unwrap(),
|
||
"the tolerant family: a missing argument selects entry ONE, it does not raise"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("SetScreenResolution(2.7) return GetCVar(\"gxResolution\") == \"1600x900\"")
|
||
.unwrap(),
|
||
"truncated toward zero — 2.7 is entry 2"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("SetScreenResolution(99) return GetCVar(\"gxResolution\") == \"1920x1080\"")
|
||
.unwrap(),
|
||
"out of range CLAMPS to the last entry — the reference reads `list[count]`, one past the \
|
||
end, and reproducing an out-of-bounds read to apply an uninitialised size is not fidelity"
|
||
);
|
||
// The CVar now says 1920x1080 and the window 1600x900: `GetCurrentResolution` reads the window.
|
||
assert_eq!(
|
||
s.eval::<f64>("return GetCurrentResolution()").unwrap(),
|
||
2.0,
|
||
"the live size is still entry 2; a pick stages `gxResolution` and takes effect on apply"
|
||
);
|
||
|
||
// Ours is still the window the ESC menu opens, in the centre slot.
|
||
s.run("GameMenuButtonOptions:Click()").unwrap();
|
||
assert!(
|
||
s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
|
||
.unwrap(),
|
||
"the player's Options button opens OUR window"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return OptionsFrame:IsShown()").unwrap(),
|
||
"…and never the stock Video one"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return GetCenterFrame():GetName()")
|
||
.unwrap(),
|
||
"BenillaOptionsFrame",
|
||
"the restated `UIPanelWindows` row — without it `ShowUIPanel` places nothing"
|
||
);
|
||
|
||
// The three stock consumers, which hear about our window only through the wrappers.
|
||
assert!(
|
||
s.eval::<bool>("return IsOptionFrameOpen() and true or false")
|
||
.unwrap(),
|
||
"UIParent.lua:996 must see an open options window"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return MainMenuMicroButton:GetButtonState() == \"PUSHED\"")
|
||
.unwrap(),
|
||
"MainMenuBarMicroButtons.lua:47-58 must show the micro button pushed"
|
||
);
|
||
s.run("ToggleGameMenu()").unwrap();
|
||
assert!(
|
||
!s.eval::<bool>("return BenillaOptionsFrame:IsShown()")
|
||
.unwrap(),
|
||
"the ESC ladder's options rung must close OUR window, in the reference's own order"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return GameMenuFrame:IsShown()").unwrap(),
|
||
"…and take that press, rather than falling through to the game menu"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return IsOptionFrameOpen() and true or false")
|
||
.unwrap(),
|
||
"with ours closed and all three stock windows hidden, the honest answer is no"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// `GetGamma`/`SetGamma` speak the stock slider's unit: `0x4891d0` is FSUBR, so `GetGamma()` is
|
||
/// `1.0 − gamma` and `SetGamma(v)` writes `1.0 − v`. The reference has no clamp (unlike `baseMip`'s
|
||
/// validating callback `0x689090`), so `SetGamma(5)` stores `"-4.000000"`; benilla clamps only at
|
||
/// the render consumer.
|
||
#[test]
|
||
fn the_display_brightness_pair_speaks_the_reference_slider_unit() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
|
||
// The registered `gamma = "1.0"` reads as 0.0, the centre of the stock slider.
|
||
assert_eq!(
|
||
s.eval::<f64>("return GetGamma()").unwrap(),
|
||
0.0,
|
||
"a fresh client sits at the centre of the stock slider's [-0.5, 0.5]"
|
||
);
|
||
// Both ends, in the CVar's own text: `SStrPrintf(buf, 0x10, "%f", 1.0 - v)`.
|
||
for (slider, cvar) in [
|
||
(0.5, "0.500000"),
|
||
(-0.5, "1.500000"),
|
||
(0.0, "1.000000"),
|
||
// No clamp: the reference accepts this and writes a negative gamma.
|
||
(5.0, "-4.000000"),
|
||
] {
|
||
s.eval::<()>(&format!("SetGamma({slider})")).unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>("return GetCVar(\"gamma\")").unwrap(),
|
||
cvar,
|
||
"SetGamma({slider}) writes 1 - v with six decimals"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<f64>("return GetGamma()").unwrap(),
|
||
slider,
|
||
"…and the getter is its exact inverse"
|
||
);
|
||
}
|
||
// No return values (`eax = 0` at every `ret`), counted by assignment: this VM has no `select`.
|
||
assert_eq!(
|
||
s.eval::<i64>(
|
||
"local a, b = SetGamma(0) \
|
||
if a ~= nil or b ~= nil then return 1 end \
|
||
return 0"
|
||
)
|
||
.unwrap(),
|
||
0,
|
||
"SetGamma pushes nothing"
|
||
);
|
||
// …and it requires its argument (`0x4891fe`, raising through `0x6f4940`, which never returns).
|
||
let err = s.eval::<()>("SetGamma()").unwrap_err().to_string();
|
||
assert!(
|
||
err.contains("Usage: SetGamma(value)"),
|
||
"the reference's own usage string, verbatim: {err}"
|
||
);
|
||
|
||
// Our Graphics row drives the pair, as the stock slider does, with the bounds of the stock
|
||
// `OptionsFrameSliders[6]`.
|
||
s.eval::<()>("SetGamma(0)").unwrap();
|
||
let row = "BenillaOptionsFrameContainerBodyGraphicsRowBrightness";
|
||
let bounds = s
|
||
.eval::<Vec<f64>>(&format!(
|
||
"local r = getglobal({row:?}) \
|
||
local sl = getglobal({row:?} .. \"ControlSlider\") \
|
||
local lo, hi = sl:GetMinMaxValues() \
|
||
return {{ lo, hi, sl:GetValueStep(), r.numeric }}"
|
||
))
|
||
.unwrap();
|
||
// The step is an f32 on the widget (0.100000001…); the bounds and the numeric flag are exact.
|
||
assert_eq!(
|
||
(bounds[0], bounds[1], bounds[3]),
|
||
(-0.5, 0.5, 1.0),
|
||
"the reference's slider-6 bounds, on a numeric api row"
|
||
);
|
||
assert!(
|
||
(bounds[2] - 0.1).abs() < 1e-6,
|
||
"…and its step: {}",
|
||
bounds[2]
|
||
);
|
||
// The readout is the thumb's share of the groove (default 50%), not the stored offset; the
|
||
// page's refresh writes it, so the window must be up.
|
||
s.eval::<()>(
|
||
"ShowUIPanel(BenillaOptionsFrame) \
|
||
BenillaOptionsFrameCategoryListRowGraphics:Click()",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.eval::<String>(&format!(
|
||
"return getglobal({row:?} .. \"ControlValue\"):GetText()"
|
||
))
|
||
.unwrap(),
|
||
"50%",
|
||
);
|
||
}
|
||
|
||
/// The video window's slider walk finds none of these ten names: `OptionsFrame.lua:110` (`_Load`)
|
||
/// and `:208-209` (`_Save`) call `getglobal("Get"..value.func)` and its `Set` twin, falling back to
|
||
/// the CVar on a miss; eight of the eighteen composed names are real. The reference's `GetFarclip`
|
||
/// (`0x488f00`) and `SetFarclip` (`0x488f30`) miss the row's `"farclip"` only because `getglobal`
|
||
/// is case-sensitive.
|
||
#[test]
|
||
fn the_video_windows_ten_composed_names_stay_nil() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
let failures = super::load_default_ui(&s);
|
||
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
|
||
|
||
for name in [
|
||
"Getuiscale",
|
||
"Setuiscale",
|
||
"Getfarclip",
|
||
"Setfarclip",
|
||
"Getanisotropic",
|
||
"Setanisotropic",
|
||
"GetspellEffectLevel",
|
||
"SetspellEffectLevel",
|
||
"GetweatherDensity",
|
||
"SetweatherDensity",
|
||
] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return getglobal({name:?}) == nil"))
|
||
.unwrap(),
|
||
"{name} must not exist — `OptionsFrame_Load` branches on it and would stop using the \
|
||
CVar path for that slider"
|
||
);
|
||
}
|
||
// Case-sensitivity, against a name that is defined (Environment Detail's getter).
|
||
assert!(
|
||
s.eval::<bool>("return getglobal(\"GetWorldDetail\") ~= nil")
|
||
.unwrap(),
|
||
"the control: this one is real"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return getglobal(\"Getworlddetail\") == nil")
|
||
.unwrap(),
|
||
"`getglobal` must stay case-sensitive — case-folding it would resolve `Getfarclip` to the \
|
||
reference's `GetFarclip` and silently change which path the far-clip slider takes"
|
||
);
|
||
}
|
||
|
||
/// `UIOptionsFrame_Load()` and `_Save()` run clean, as pfUI's `modules/gui.lua:146-148` calls them
|
||
/// around a checkbox write: every slider CVar `_Load` reads is registered, and `Slider:SetValue`
|
||
/// (`0x790980`) raises on nil in the reference too.
|
||
#[test]
|
||
fn the_stock_options_windows_load_and_save_are_reachable_for_addons() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
s.set_unit(
|
||
"player",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Probefive".into()),
|
||
level: 60,
|
||
// `_Load`'s tail upper-cases `UnitClass("player")` (`UIOptionsFrame.lua:759-763`); a
|
||
// warrior takes the branch that disables the combo-point box.
|
||
class: Some("Warrior".into()),
|
||
class_file: Some("WARRIOR".into()),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
assert!(super::load_default_ui(&s).is_empty());
|
||
|
||
// `_Load` first, the window's own order: the reference loads on show and saves on Okay.
|
||
s.run("this = UIOptionsFrameOkay UIOptionsFrame_Load()")
|
||
.expect("_Load runs to completion — every slider CVar it reads is registered");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
|
||
// Every slider CVar in the stock table must answer, or `_Load` raises.
|
||
let unbacked: Vec<String> = s
|
||
.eval(
|
||
"local out = {} \
|
||
for _, v in ipairs(UIOptionsFrameSliders) do \
|
||
if GetCVar(v.cvar) == nil then table.insert(out, v.cvar) end \
|
||
end \
|
||
return out",
|
||
)
|
||
.expect("read UIOptionsFrameSliders");
|
||
assert!(
|
||
unbacked.is_empty(),
|
||
"these slider CVars answer nil, and `_Load` raises on the first of them: {unbacked:?}"
|
||
);
|
||
|
||
// The walk reached slider 3, Mouse Look Speed, on `cameraYawMoveSpeed`'s registered 180
|
||
// (`UIOptionsFrame.lua:89`).
|
||
let slider3: f64 = s
|
||
.eval("return UIOptionsFrameSlider3:GetValue()")
|
||
.expect("the Mouse Look Speed slider's value");
|
||
assert!(
|
||
(slider3 - 180.0).abs() < 0.001,
|
||
"slider 3 should carry cameraYawMoveSpeed's registered 180, got {slider3}"
|
||
);
|
||
|
||
// `_SetDefaults` is the same walk through `GetCVarDefault`.
|
||
s.run("this = UIOptionsFrameDefaults UIOptionsFrame_SetDefaults()")
|
||
.expect("_SetDefaults runs to completion too");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
|
||
// Through the Okay button's own `<OnClick>` (`UIOptionsFrame.xml:1205-1209`), which sets
|
||
// `this`: the `SHOW_PARTY_PETS` arm reaches `RefreshBuffs`, whose first act is
|
||
// `this.hasDispellable = nil` (`BuffFrame.lua:266`).
|
||
s.run("UIOptionsFrameOkay:Click()")
|
||
.expect("the stock Okay button's own handler");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
assert!(
|
||
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
|
||
"…and the window it hides on the way out was hidden to begin with"
|
||
);
|
||
|
||
// `_Save` writes the yaw slider's value and `cameraPitchMoveSpeed = value / 2`
|
||
// (`UIOptionsFrame.lua:355-356`), as the registered defaults, 180 and 90, agree.
|
||
let (yaw, pitch): (f64, f64) = (
|
||
s.eval(r#"return tonumber(GetCVar("cameraYawMoveSpeed"))"#)
|
||
.expect("yaw move speed"),
|
||
s.eval(r#"return tonumber(GetCVar("cameraPitchMoveSpeed"))"#)
|
||
.expect("pitch move speed"),
|
||
);
|
||
assert!(
|
||
(yaw - 180.0).abs() < 0.001 && (pitch - 90.0).abs() < 0.001,
|
||
"_Save should write the slider's 180 and its half; got yaw={yaw} pitch={pitch}"
|
||
);
|
||
}
|
||
|
||
/// `assets/ui` does not grow: the interface is the stock 1.12 FrameXML off the player's own patch
|
||
/// chain, and a new file means a window was authored rather than migrated. Point `benilla.toc` at
|
||
/// `Interface\FrameXML\<Window>.xml`, delete ours, and build the engine verbs the stock file calls.
|
||
#[test]
|
||
fn assets_ui_does_not_grow() {
|
||
const SHIPPED: &[&str] = &[
|
||
// Not a window: no-op `AIO_BLOCKHANDLES` registrations for the server blocks this client
|
||
// has no feature for. An unregistered block is an `error()` AIO means as a clean abort,
|
||
// but pfUI replaces the global `error` with a non-raising chat printer, so the abort
|
||
// falls through into `attempt to call local 'handledata'`. There is no stock file to
|
||
// migrate — AIO is the realm's, not Blizzard's. The file's own header has the trace.
|
||
"AIOHandlers.lua",
|
||
// Not a window either: the ClassicAPI-era FrameXML templates addons inherit by name,
|
||
// which have no stock 1.12 file to migrate from because 1.12 has no such templates. They
|
||
// live in XML rather than `script/compat/` only because `inherits=` is resolved by the
|
||
// loader and cannot be answered from Lua. Ace3 names two of them **unguarded**, so a
|
||
// missing one raises inside the library: `CreateFrame(): Couldn't find inherited node
|
||
// "DialogBorderOpaqueTemplate"` is where OpenMapClassic stopped, on its first file.
|
||
"CompatTemplates.xml",
|
||
"ContainerFrameAdapters.xml",
|
||
"GameMenuFrame.xml",
|
||
"KeyBindingsPage.xml",
|
||
"OptionsFrame.xml",
|
||
// The six `OptionsFrame_*` enable/disable helpers stock 1.12 keeps in its own
|
||
// `OptionsFrame.lua`, which the realm's rewrite of that file (Turtle's, `patch-4.mpq`)
|
||
// drops. `benilla.toc` is the only thing that loads the two stock windows calling them
|
||
// (`SoundOptionsFrame.xml`, `UIOptionsFrame.xml`, neither in the realm's toc), so there is
|
||
// nothing to migrate from: no file in the realm's 151 interface rows defines one. Same
|
||
// shape as `CharacterFrameAdapters.xml` above — a name the realm's FrameXML uses and no
|
||
// longer provides. The file's own header has the trace.
|
||
"OptionsFrameAdapters.lua",
|
||
"ScriptLogFrame.xml",
|
||
"ScrollTemplates.xml",
|
||
"SpellBookAdapters.xml",
|
||
"benilla.toc",
|
||
];
|
||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
|
||
let mut found: Vec<String> = std::fs::read_dir(&dir)
|
||
.expect("assets/ui")
|
||
.flatten()
|
||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||
.filter(|n| !n.starts_with('.'))
|
||
.collect();
|
||
found.sort();
|
||
let mut shipped: Vec<String> = SHIPPED.iter().map(|s| s.to_string()).collect();
|
||
shipped.sort();
|
||
assert_eq!(
|
||
found, shipped,
|
||
"assets/ui changed. It does not grow: a window is migrated, not authored (docs/METHOD.md). \
|
||
A file that retired comes off this list; a new one needs a reason this list can name."
|
||
);
|
||
}
|