- the reference's own video options window loads hidden, so pfUI's video skin and any addon reaching for its sliders and check buttons by name finds real frames; our own options window is BenillaOptionsFrame now, and ESC still opens it (2177) - GetScreenResolutions, GetCurrentResolution, SetScreenResolution, GetRefreshRates, GetVideoCaps, RestartGx and RestoreVideoDefaults answer off the monitor's own modes, so CT_Viewport loads clean (2177) - three of those verbs were corrected against a second read of the binary: GetRefreshRates answers nothing on a device with no rates, GetVideoCaps always gives a number for triple buffering, and RestoreVideoDefaults writes the ten rows the verb writes (2177) - the camera's water collision is back with both halves: the boom stops at a water surface, and a swimmer's camera keeps a constant 2/9 yd clearance over the waterline whatever the body underneath does (2179) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2247 lines
103 KiB
Rust
2247 lines
103 KiB
Rust
//! Whole-directory guards over the shipped `assets/ui/*.xml` — the coverage the per-window test
|
||
//! modules structurally cannot give, since each of those loads only the file it is about.
|
||
|
||
/// EVERY shipped `assets/ui/*.xml` parses — not just the ones a window test happens to load.
|
||
///
|
||
/// The per-window tests each load their own file, which left real holes: `CraftFrame.xml` and
|
||
/// `TradeSkillFrame.xml` ship in [`super::load_default_ui`]'s manifest and had NO parse coverage at
|
||
/// all, because the manifest is an inline array no test walks. A malformed comment in either (XML
|
||
/// forbids `--` inside `<!-- -->`, which a prose edit hits easily) would have reached a real run
|
||
/// untouched by a green suite.
|
||
///
|
||
/// Deliberately parse-only, not `loader::load`: loading one file out of manifest order reports
|
||
/// legitimate errors for templates its predecessors define, so a load-sweep would have to duplicate
|
||
/// the load order to say anything. Parsing is order-free and catches the whole well-formedness
|
||
/// class on its own.
|
||
#[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;
|
||
}
|
||
}
|
||
// Never let the sweep pass by finding nothing — a moved assets dir would otherwise turn this
|
||
// into a test that guards zero files while staying green.
|
||
assert!(
|
||
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751),
|
||
// so the floor sits well under the count rather than one step above it (1956). Nine
|
||
// files stand after 2014; the glue screens and the dev frames alone are more than six.
|
||
checked >= 6,
|
||
"only {checked} xml files swept — sweep broke"
|
||
);
|
||
}
|
||
|
||
/// The WHOLE manifest LOADS, in its real order, with zero loader errors — the check the parse
|
||
/// sweep above structurally cannot give, and the one the app itself never made.
|
||
///
|
||
/// [`super::load_default_ui`] only ever *logged* its errors, so a broken manifest entry reached a
|
||
/// real run behind a log line nobody greps: a mistyped file name, a frame name colliding with a
|
||
/// later window's, a template used before its definer, an `<Include>` that resolves to nothing.
|
||
/// Nor could a capture run have caught it — captures skip the manifest entirely unless
|
||
/// `WOW_CAPTURE_UI=1`. This is that assertion, over the array the app really walks, so a new
|
||
/// window's entry is covered the moment it is added rather than when someone remembers to test it.
|
||
#[test]
|
||
fn the_whole_shipped_manifest_loads_without_errors() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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.** Materializing the shipped manifest is bookkeeping — nothing
|
||
/// has opened, so nothing may be heard (decision 1033).
|
||
///
|
||
/// The defect this pins: a dropdown's `OnLoad` calls `UIDropDownMenu_Initialize`, which —
|
||
/// faithfully, ref `UIDropDownMenu.lua` l.49-52 — *calls the init function immediately*. For the
|
||
/// unit popups that init function reaches `UnitPopup_ShowMenu`, whose last line is
|
||
/// `PlaySound("igMainMenuOpen")`. The ref never gets there at load: `UnitPopup_HideButtons` leaves
|
||
/// nothing but CANCEL shown for a unit that does not exist, tripping the "only one item, don't show
|
||
/// the menu" early-out. Ours was missing that hide for FOLLOW and INSPECT (ref l.304-307/316-319),
|
||
/// so all four party dropdowns rang on startup — four copies of the menu-open tack stacked in one
|
||
/// frame, on the login screen.
|
||
///
|
||
/// Deliberately asserted over the WHOLE manifest rather than the popup: any future window that
|
||
/// plays a sound from a load-time handler is the same bug, and this is where it gets caught. Sound
|
||
/// is on by default now (decision 1026), so a load-time sound is something the director hears on
|
||
/// every single launch.
|
||
#[test]
|
||
fn loading_the_shipped_ui_queues_no_sounds() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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 (decision 1033)"
|
||
);
|
||
}
|
||
|
||
/// **Every texture path the shipped UI names RESOLVES in the real archives** — the tripwire for a
|
||
/// mis-typed `file=`, which the renderer reports by drawing a plain WHITE QUAD and nothing else.
|
||
///
|
||
/// A sprite that fails to resolve is `None` all the way to `ui_pass`, where a texture-less quad
|
||
/// samples the shared 1×1 white image (that fallback is what makes flat-shaded quads batch, so it
|
||
/// cannot itself be made loud). The bug it hid (1046): six `file=` attributes in
|
||
/// `SpellBookFrame.xml` were written with **doubled** separators — `Interface\\SpellBook\\…`, the
|
||
/// Lua escaping, in an XML attribute where a backslash is already literal. `normalize_path` only
|
||
/// folds case and slashes, so the doubled key missed the archive hash and the pet book shipped with
|
||
/// a white slab over its autocast ring and another under its tab row. Every gate was green.
|
||
///
|
||
/// The shape half runs everywhere; the resolve half needs client data and skips without it (a
|
||
/// The reference's pet bar, up with the hunter fixture (Claw autocasting), settled past its
|
||
/// slide — the pet half of the two shine pins below.
|
||
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 **corner brackets** (`UI-AutoCastableOverlay`), per template — the sibling pin to
|
||
/// the shine's, and the same invariant: not a magic size, but the **bracket square against its
|
||
/// button**, which is the thing an eye actually reads.
|
||
///
|
||
/// The art fills only the middle `33/64` of the texture, so the drawn size decides where the
|
||
/// brackets land. The reference gets this right on the pet button (58 x 0.5156 = 29.9 on 30) and
|
||
/// loose on the spell book (60 x 0.5156 = 30.9 on 37 — three units inside each edge). Decision
|
||
/// 1393 carries the pet button's ratio across; this test is what keeps it carried, and what will
|
||
/// catch a third template added with a borrowed number.
|
||
#[test]
|
||
fn the_autocast_brackets_reach_each_buttons_corners() {
|
||
use benilla_ui::framexml::{Element, TopLevel};
|
||
|
||
/// The fraction of `UI-AutoCastableOverlay.blp` the bracket art actually covers, measured off
|
||
/// the shipped BLP (`benilla-extract blp`): art bbox 15..47 of 64.
|
||
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 overlay is the reference's own template resized by SpellBookAdapters.xml's
|
||
// script (1952/2014), so its number is measured off a VM, not read off an XML.
|
||
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 overlay is the reference's own template (1953) — 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 (decision 1393)",
|
||
brackets / button
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The two shipped autocast-shine panes, and the rim/viewport ratio each one asks for — the one
|
||
/// place the widget's geometry is decided, so the one place to pin it. Read off the live panes
|
||
/// (the stock `$parentAutoCast` Models the tile renderer draws, 2013/2014), never an XML.
|
||
///
|
||
/// The pet button is the REFERENCE's own numbers (`setAllPoints` on 30x30 at `scale="1.2"`), and
|
||
/// its ratio is why that button reads as a rim: the rim square is 1.024x its viewport, so it runs
|
||
/// ON the edge and the tile's cell — the widget's own scissor — halves every star (1387/1391).
|
||
///
|
||
/// The spell book is **one deliberate deviation** (decision 1392). The reference writes
|
||
/// `scale="1.22"` into a 36-unit viewport — a 0.87x rim that floats clear of the edge, is never
|
||
/// clipped, and washes the icon; the real 1.12 client looks the same way (director-checked), so
|
||
/// this is taste, not fidelity. We re-seat the stock Model to 1.48 on a 37-unit rim (1393's
|
||
/// numbers, SpellBookAdapters.xml) to borrow the pet button's ratio. This test is what stops that
|
||
/// drifting, or being "corrected" back to 1.22 by someone who only read the ref.
|
||
#[test]
|
||
fn the_shine_panes_ask_for_the_rims_we_meant() {
|
||
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 (decisions 1387/1392)",
|
||
rim / view
|
||
);
|
||
}
|
||
}
|
||
|
||
/// hand-kept list of real file names would rot into agreeing with itself — the `text=` sweep's
|
||
/// argument below). Resolution goes through the renderer's own `sprite_candidates`, not a copy of it —
|
||
/// including its `.blp`/`.tga` fallback, so the sweep accepts exactly what the renderer accepts.
|
||
#[test]
|
||
fn every_shipped_texture_path_resolves_in_the_client_archives() {
|
||
use benilla_ui::framexml::{Element, TopLevel};
|
||
|
||
// `file=` on a texture element is an ARCHIVE path; `<Script file=>`/`<Include file=>` name our
|
||
// own source files and are top-level items, not elements, so walking elements can't see them.
|
||
fn walk(el: &Element, file: &str, out: &mut Vec<(String, String, String)>) {
|
||
// `<Model file=>` names a **model** (`.mdx`), not a texture: it never reaches
|
||
// `sprite_candidates` and it cannot draw a white quad, because this engine renders no
|
||
// FrameXML models at all. The one shipped case is the reference's `CooldownFrameTemplate`,
|
||
// transcribed with its own attributes so an addon's `inherits=` resolves, while the sweep
|
||
// it drives lives in our native `<Cooldown>` widget instead (decision 0263).
|
||
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(_) => {}
|
||
}
|
||
}
|
||
}
|
||
// Never let the sweep pass by matching nothing — the shipped UI still names over a hundred
|
||
// textures (173 after 1971 retired the auction transcription, fewer again after 1973 the
|
||
// crafting pair, 64 after 1980 the world map; the count falls with every window that
|
||
// migrates, and the floor follows it).
|
||
assert!(refs.len() >= 50, "only {} texture paths swept", refs.len());
|
||
|
||
// The shape half: a doubled separator is the Lua escaping written into XML, and it resolves to
|
||
// nothing. Checked without the client so a data-less machine still catches this exact class.
|
||
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** — the mirror image
|
||
/// of the doubled-separator check above, and the tripwire for the defect that left the raid tab
|
||
/// with no window art at all (the `FriendsFrame_Update` arm was written
|
||
/// `"Interface\PaperDollInfoFrame\…"`, one backslash, so Lua ate both separators and
|
||
/// `SetTexture` was handed `InterfacePaperDollInfoFrameUI-Character-General-TopLeft`).
|
||
///
|
||
/// The two halves of the same trap, and they pull in OPPOSITE directions, which is exactly why
|
||
/// neither check can stand in for the other:
|
||
///
|
||
/// - an **XML attribute** is not a Lua string, so `file="Interface\Buttons\X"` is right and a
|
||
/// doubled separator there is the bug (the sweep above);
|
||
/// - a **Lua literal** is, so `SetTexture("Interface\\Buttons\\X")` is right and a single
|
||
/// separator here is the bug — and it fails SILENTLY: `\P` is not an escape Lua rejects, it is
|
||
/// one Lua drops the backslash from, so the string is well-formed, the call succeeds, and the
|
||
/// only symptom is a texture that never appears.
|
||
///
|
||
/// The shape half runs without client data; the resolution half needs the archives and skips
|
||
/// without them, the same posture as its sibling.
|
||
#[test]
|
||
fn every_archive_path_a_shipped_lua_chunk_names_survives_its_own_escaping() {
|
||
use benilla_ui::framexml::{Element, ScriptRef, TopLevel};
|
||
|
||
// The archive roots a path literal can start with. A prefix list rather than "has a
|
||
// backslash", because Lua strings legitimately carry `\n`/`\124` and those are not paths.
|
||
const ROOTS: [&str; 7] = [
|
||
"interface",
|
||
"textures",
|
||
"world",
|
||
"sound",
|
||
"character",
|
||
"item",
|
||
"spells",
|
||
];
|
||
|
||
/// Every `"…"`/`'…'` literal in one Lua chunk, as its RAW source text (escapes uninterpreted —
|
||
/// what this sweep is looking for is an escape Lua would have eaten, so interpreting them
|
||
/// first would destroy the evidence).
|
||
///
|
||
/// **Comments and long strings are skipped, not scanned.** A `--` line quoting a path (this
|
||
/// house writes plenty: "the file is `Interface\\Foo\\Bar`") is prose, and a prose backslash
|
||
/// is nobody's bug — flagging one would train the next session to weaken this test rather
|
||
/// than read it.
|
||
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 means this was never a literal (an
|
||
// apostrophe in a comment, most often) — drop it and resume at the next char.
|
||
if c == '\n' {
|
||
break;
|
||
}
|
||
if c == quote {
|
||
closed = true;
|
||
break;
|
||
}
|
||
raw.push(c);
|
||
j += 1;
|
||
}
|
||
if closed {
|
||
// Is this the WHOLE path, or a fragment? A literal with `..` against either side
|
||
// is being concatenated onto, and one carrying a `%` spec is a `format` template —
|
||
// in both cases the string in the source is a prefix and resolving it would fail
|
||
// by construction. Read off the SITE rather than guessed from the text (a
|
||
// "ends with a dash" rule would be a list to feed forever), so a fragment that
|
||
// later becomes whole starts being resolved on its own.
|
||
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(&'.')
|
||
});
|
||
// …and the two fragments a `..` cannot see, because they are assigned to a
|
||
// variable first and joined somewhere else: a literal ending in the SEPARATOR is a
|
||
// directory, and one ending in a DASH is a family prefix (`"…\\MageFire-"` .. rank).
|
||
// Neither is a file name — no texture in the chain ends in either character — so
|
||
// this excludes fragments without excluding anything real.
|
||
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 in the file: the top-level `<Script>` blocks and every element body (the
|
||
// `<OnLoad>`-family handlers). Through the PARSER, not a text scan — an attribute value is
|
||
// also inside double quotes, and a text scan cannot tell the two apart, which is the whole
|
||
// distinction this test exists to make.
|
||
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));
|
||
}
|
||
}
|
||
}
|
||
// Never let the sweep pass by matching nothing (19 after 1971, 8 after 1980, 4 after 1987;
|
||
// the floor follows the census down as windows migrate).
|
||
assert!(
|
||
paths.len() >= 4,
|
||
"only {} archive paths swept out of the shipped Lua",
|
||
paths.len()
|
||
);
|
||
|
||
// The shape half: after collapsing every `\\` pair, no backslash may remain — one that does
|
||
// is a separator Lua is about to eat.
|
||
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()
|
||
// `sprite_candidates` answers for TEXTURES; a sound or a model path is a different
|
||
// resolver's business, so only the two texture roots take the resolution half — and only
|
||
// a WHOLE path, never a concatenation fragment.
|
||
.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 `text=` in the shipped UI is answerable against the REAL `GlobalStrings.lua`** — the
|
||
/// tripwire for the defect that put "CREATE_MACROS" across the macro window's title bar (0991).
|
||
///
|
||
/// `text=` is a global-string LOOKUP, not a literal (wow-re rf28 l.36/l.115 → `FrameScript_GetText
|
||
/// 0x703bf0`). The loader didn't do the lookup at all, so **23 key-shaped values across six
|
||
/// windows** were rendering as their own key names — and nothing anywhere said so. Two halves, both
|
||
/// needed:
|
||
///
|
||
/// - a **key-shaped** value (`SCREAMING_SNAKE`) must resolve to a non-empty string, or it reaches a
|
||
/// real screen as its own key;
|
||
/// - a **literal** value ("Send Mail") must NOT collide with a real global, because the loader's
|
||
/// deliberate literal-fallback divergence means a collision would silently swap the words on
|
||
/// screen for a localized string nobody asked for.
|
||
///
|
||
/// Skips without client data (the real string table is the whole point — a hand-kept copy of the
|
||
/// keys would rot into agreeing with itself).
|
||
#[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 loader's own shape test, restated here so the two can't drift apart silently.
|
||
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"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Never let the sweep pass by matching nothing: 23 key-shaped values across six windows was
|
||
// the floor as of 0991; ONE is left after 1971, and a regex that stops matching is exactly how
|
||
// this guard would retire — so the floor is one, and the guard retires with the last file of
|
||
// ours that writes a key-shaped `text=`.
|
||
assert!(keys >= 1, "only {keys} key-shaped text= values swept");
|
||
}
|
||
|
||
/// **No shipped script hands a GlobalStrings KEY to a text sink as if it were the string.**
|
||
///
|
||
/// The defect this pins, twice now: `TargetFrame_CheckDead` did `deadText:SetText("DEAD")`, and
|
||
/// `DEAD` is not the word — it is `GlobalStrings.lua` l.898's key for `"Dead"`. So the target
|
||
/// frame wore **DEAD** in caps over every corpse, in every locale, until the director looked at
|
||
/// an Onyxia and said so. The same class shipped once before with `"CREATE_MACROS"` written
|
||
/// across the macro window's title bar (0983 → 0991).
|
||
///
|
||
/// The XML half of this class is already guarded twice: `loader::resolve_text` warns at load
|
||
/// whenever a **key-shaped** `text=` attribute resolves to nothing, and the sweep directly above
|
||
/// answers every one of them against the real `GlobalStrings.lua`. The **Lua** half had no guard
|
||
/// at all — `SetText` takes a string and cannot tell a key from a word — and that is the
|
||
/// half both escapes came through. This is that missing guard, as a shape test: a literal that is
|
||
/// all `[A-Z0-9_]` with an uppercase in it is `is_global_string_key`'s own predicate, and nothing
|
||
/// we would ever want a player to read.
|
||
///
|
||
/// Swept over the whole directory rather than the file that broke, because the next one will be a
|
||
/// different window. If a genuinely uppercase word ever needs to reach a sink, it goes through the
|
||
/// house's `gs(key, fallback)` helper (AuctionFrame/HelpFrame/MailFrame) or the XML `text=`
|
||
/// attribute — both resolve the global first, and both are what "faithful" means here.
|
||
#[test]
|
||
fn no_shipped_script_sets_a_global_string_key_as_display_text() {
|
||
/// `loader::is_global_string_key`'s predicate, restated here because it is private: at least
|
||
/// two characters, uppercase/digit/underscore only, and at least one uppercase.
|
||
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 literal after a text sink's open paren, if the call opens on this line at all. Prose is
|
||
// the noise here — the house documents its own history in comments, this very record included
|
||
// — so XML comment blocks are tracked across lines and Lua `--` lines are dropped.
|
||
fn sink_literals(line: &str) -> Vec<&str> {
|
||
let mut out = Vec::new();
|
||
// `:SetFormattedText(` used to sit between these two and never could have matched —
|
||
// it is not a 1.12 verb, so no shipped script writes it (2142 retired ours).
|
||
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"));
|
||
// The sweep must never pass by finding nothing to sweep.
|
||
// The same walk floor as above (1956).
|
||
assert!(swept >= 6, "only {swept} xml files swept — sweep broke");
|
||
}
|
||
|
||
/// **The `$parentTextureFrame` idiom's contract, over the whole shipped UI**: a frame whose art is
|
||
/// meant to cap a unit frame must sit at a strictly HIGHER frame level than that frame's status
|
||
/// bars — because frame level is the only key term above the draw layer, and the layer would
|
||
/// otherwise lift an ARTWORK bar fill straight over BACKGROUND art.
|
||
///
|
||
/// Decision 0884 pinned the layer as bucket-wide and above the frame. Two files had been getting
|
||
/// this right for the wrong reason — our `UnitFrames.xml` and `PartyFrame.xml` both *declared* the
|
||
/// TextureFrame after the bars and leaned on the retired key's insertion-order tie-break — so both
|
||
/// inverted the instant the key was corrected, and both reached the director's screen. (Both files
|
||
/// are retired now, 1751; the lesson is kept because it is about the KEY, not about them.) The
|
||
/// reference spends a real frame level on this in both of its own spellings (`TargetFrame.lua`
|
||
/// l.32-34's explicit `SetFrameLevel(textureFrame-1)`, `PlayerFrame.xml` l.50-52's two anonymous
|
||
/// nesting frames); benilla now uses the first, everywhere.
|
||
///
|
||
/// Name-driven on purpose, so a *new* copy of the idiom is covered the day it is written rather
|
||
/// than when someone remembers to test it. A geometric sweep was run once over this same UI —
|
||
/// every substantially-overlapping cross-frame quad pair at one `(strata, level)`, i.e. every pair
|
||
/// the draw layer alone orders — and found no third instance; the surviving 45 pairs were all
|
||
/// benign (action-bar and bag-slot chrome adjacency, text over art, and a bar fill over its own
|
||
/// frame's trough, which is the intended order). It is not kept as a gate: frozen, that list would
|
||
/// churn on every action-bar edit, and a noisy gate is a gate nobody reads.
|
||
#[test]
|
||
fn every_texture_frame_outranks_its_status_bars() {
|
||
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 materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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 family painting at once: the frames hide themselves without a unit, and 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![]);
|
||
// The target-of-target frame ships OFF (`SHOW_TARGET_OF_TARGET = "0"`, the reference's own
|
||
// default, declared in OptionsFrame.xml which the manifest above has just loaded). Turn it on
|
||
// here: it is the SECOND of the two named TextureFrames the reference spells this way, and a
|
||
// gate that only ever sees the first covers half of what it claims to.
|
||
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", "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 predicted reduction, arrived.** This asserted 12 while the unit frames were ours,
|
||
// because our transcription spelled the idiom with a named `$parentTextureFrame` on EVERY
|
||
// family. The reference does not: it has exactly TWO named ones, `TargetFrameTextureFrame` and
|
||
// `TargetofTargetTextureFrame`, both in TargetFrame.xml. `PlayerFrame`, `PetFrame` and the
|
||
// party rows reach the same order the other way — the art inside ANONYMOUS nested
|
||
// `<Frame setAllPoints="true">` wrappers, outranking the bars by nesting depth rather than by
|
||
// a level on a name. There is nothing for a name sweep to look up, so those families move from
|
||
// this assertion's cover to the loader's nesting rule.
|
||
//
|
||
// The note above this line predicted the drop when PartyFrame went to the chain and called it
|
||
// "a real reduction rather than a rename". That was right, and it under-counted: the whole kit
|
||
// went at once, so player and pet left by the same door. Two families × (HealthBar, ManaBar,
|
||
// ManaBar) = 6 is the reference's own coverage, and it is the floor now. Still never zero —
|
||
// matching nothing is exactly how a renamed frame would silently retire the check.
|
||
assert!(
|
||
checked >= 6,
|
||
"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` since 1051 — is a
|
||
/// pure registry: it materializes no frames, so nothing of the in-game UI can draw, tick or ring
|
||
/// before a character is in the world. (1033's tack was a load-time handler firing on the login
|
||
/// screen; this is the structural half of that fix.)
|
||
#[test]
|
||
fn the_boot_phase_materializes_no_frames() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The font registry is manifest entry 0 and comes off the chain since 1888, so this reads
|
||
// through `test_ui::load_ui` rather than joining `assets/ui` — the file is not ours any more.
|
||
// `load_ui` returns the same `report.frames` this asserted on and 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 font registry alone covers the WHOLE glyph-atlas bake plan** — the property that makes
|
||
/// 1051's split safe, asserted rather than reasoned.
|
||
///
|
||
/// Our native glue screens share the one atlas, and that atlas bakes **once**, on the first
|
||
/// `Update`, from `script.font_objects()` (`ui_text/atlas.rs` — the face size-list extension *and*
|
||
/// the outlined-cell census). So the registry must be complete at boot even though the other 55
|
||
/// manifest files now load at world entry. It is: three font objects live outside `Fonts.xml`
|
||
/// (`GameFontNormalMed1` 13, `OptionsFontHighlightMedium` 14, `OptionsFontHighlightHuge` 20), all
|
||
/// un-outlined, all at heights `Fonts.xml` already declares.
|
||
///
|
||
/// If this fails, someone added a font object to a non-`Fonts.xml` file with a new height or an
|
||
/// outline. In-game text would silently lose that variant for the whole session — the bake has
|
||
/// already happened by the time the file loads. Either move it into `Fonts.xml` or make the atlas
|
||
/// rebakeable.
|
||
#[test]
|
||
fn the_font_registry_alone_covers_the_whole_bake_plan() {
|
||
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 materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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 whole shipped UI survives `VARIABLES_LOADED` (decision 1128) — the event the saved-variables
|
||
/// load fires at every launch, and which nothing fired before this arc existed.
|
||
///
|
||
/// Today no shipped file registers it, so this asserts a clean no-op; the moment one does (the
|
||
/// combat-text option family and `TwentyFourHourTime` are next on 1128's list) this is what catches a
|
||
/// handler that errors on the one event that runs before any window has been shown.
|
||
#[test]
|
||
fn the_shipped_ui_takes_variables_loaded_without_a_script_error() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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 anything the load itself logged; this test is about the event
|
||
s.fire_event("VARIABLES_LOADED", vec![]);
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"VARIABLES_LOADED script errors: {:?}",
|
||
s.errors()
|
||
);
|
||
}
|
||
|
||
/// **Every `<Font name=…>` the shipped manifest declares is a real Lua global** — a `Font` object
|
||
/// answering the FontInstance getters, not a bare style record with no name.
|
||
///
|
||
/// The per-fragment test in `benilla-ui` proves the mechanism on a two-font document; this proves
|
||
/// it over the 54 fonts our real `Fonts.xml` (and the windows after it) actually declare, in
|
||
/// manifest order, which is the only place a name collision with a *frame* of the same name — the
|
||
/// one way publication can silently not happen, since `publish_global` never overwrites — could
|
||
/// show up.
|
||
///
|
||
/// The named spot-checks are the corpus's four most-wanted font objects: `GameFontNormal` (98
|
||
/// addons), `GameTooltipText` (89), `GameFontHighlightSmall` (69), `GameTooltipHeaderText` (the
|
||
/// `Tablet-2.0.lua:289` header-size probe, 268 read sites).
|
||
#[test]
|
||
fn every_shipped_font_object_is_published_as_a_lua_global() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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:?}");
|
||
|
||
// Collect the declared names straight out of the MANIFEST's own entries, so the sweep cannot
|
||
// go stale — and so it follows a file onto the chain. It swept `assets/ui` until 1888 put the
|
||
// font registry on the chain and the count fell from 51 to 3, which is the failure this shape
|
||
// prevents: the sweep's subject is what the manifest declares, not what happens to be ours.
|
||
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 the corpus wants most actually 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}");
|
||
}
|
||
}
|
||
|
||
/// **Two reference templates addons INHERIT, and the silence that hid them.**
|
||
///
|
||
/// `TargetBuffButtonTemplate` (ref `TargetFrame.xml`) and `MainMenuBarMicroButton` (ref
|
||
/// `MainMenuBarMicroButtons.xml`) are declared by the reference and were not by us. An unresolved
|
||
/// `inherits=` is **silent** — no load error, no session error, nothing in any harness column — so
|
||
/// a consumer just gets a button with no size, no hit-rect and no scripts, and nothing anywhere
|
||
/// says so. They surfaced only in the report's missing-TEMPLATES ranking.
|
||
///
|
||
/// Asserted through what a consumer actually gets: inherit the template, then read back the
|
||
/// geometry and the script the reference confers.
|
||
#[test]
|
||
fn the_inheritable_reference_templates_confer_their_shape() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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());
|
||
|
||
// `CT_UnitFrames/CT_TargetFrame.xml:4` builds its own virtual template on top of this one, so
|
||
// the inherit has to work through a second hop as well as directly.
|
||
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());
|
||
}
|
||
|
||
/// **`CursorUpdate` / `CursorOnUpdate` — the inspect-cursor pair, transcribed from FrameXML.**
|
||
///
|
||
/// Both are `framexml` origin in `reference/1.12-globals.tsv` (while `ResetCursor` and
|
||
/// `ShowInspectCursor` beside them are `engine`), so they are ours to write rather than to bind.
|
||
/// Two corpus addons call `CursorUpdate` and one calls `CursorOnUpdate`; it is also one of the
|
||
/// blockers on two reference templates addons inherit, and the sourced `ContainerFrame.lua` calls
|
||
/// it from its keyring fork.
|
||
#[test]
|
||
fn the_inspect_cursor_pair_takes_both_arms() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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())
|
||
);
|
||
|
||
// **The no-ctrl arm, which is the one that runs in practice.** `this.hasItem` is read as a
|
||
// plain truthy field, so a frame that never sets it simply takes `ResetCursor` — this is why
|
||
// the pair is safe to ship before anything sets that field.
|
||
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` gates on tooltip ownership, so with the tooltip unowned it must do nothing
|
||
// at all rather than reach through to the cursor.
|
||
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 is left on screen during a cinematic** — swept over the whole
|
||
/// shipped manifest, not one file at a time.
|
||
///
|
||
/// This is the test the director's eye had to stand in for. Decision 1734 restored 72 dropped
|
||
/// `parent=` declarations and a per-window test proved the cascade worked; the chat still drew
|
||
/// over the fly-by, because benilla carries the reference's `FloatingChatFrameTemplate` and
|
||
/// `ChatTabTemplate` under its own names (`BenillaChatFrameTemplate`, `BenillaChatTabTemplate`)
|
||
/// and the gap analysis that found the 72 skipped `virtual="true"` templates entirely. A
|
||
/// name-for-name comparison against the reference could not see it. **Sweeping what is actually
|
||
/// visible can**, which is why this is written against the observable and not against a list.
|
||
///
|
||
/// The two survivors are each required to survive (a third, `WorldFrame`, was one while our
|
||
/// `UIParent.xml` declared it; since decision 1983 it is the reference's own file off the chain,
|
||
/// which this sweep over the SHIPPED tree does not walk — it stays up through a fly-by exactly as
|
||
/// before, top-level and never hidden):
|
||
///
|
||
/// - `CinematicFrame` — the frame being *shown*. The reference declares it with no parent for
|
||
/// exactly this reason, and `SetFullScreenFrame` shows it in the same breath as hiding UIParent.
|
||
/// - (Until 1988 a `BenillaFadeDriver` survived beside it — our fade kit's tick frame. The stock
|
||
/// `UIFrameFadeUpdate` runs from UIParent's own OnUpdate, which a cinematic's `UIParent:Hide()`
|
||
/// stops exactly as the reference's does.)
|
||
#[test]
|
||
fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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();
|
||
|
||
// The sweep's subjects: the frames OUR files declare — plus the reference's own HUD, because
|
||
// `assets/ui` is nearly empty now (1988 retired the last of the glue) and a sweep over what is
|
||
// left would pass without ever looking at the interface the cascade actually hides.
|
||
let mut names = shipped_frame_names();
|
||
names.extend(
|
||
[
|
||
"MainMenuBar",
|
||
"ChatFrame1",
|
||
"PlayerFrame",
|
||
"MinimapCluster",
|
||
"BuffFrame",
|
||
"MainMenuBarBackpackButton",
|
||
"CharacterMicroButton",
|
||
"UIErrorsFrame",
|
||
// The frame being SHOWN — the sweep's 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 only worth anything if there was something to hide in the first place.
|
||
let before = names.iter().filter(|n| visible(&s, n)).count();
|
||
assert!(
|
||
// 50 until 1938 took the three bar files stock, 20 until 1974 took the minimap cluster
|
||
// (its 22 named frames were most of what this sweep counted), 12 until 1987 took the
|
||
// micro row and 1988 the glue. The floor is over the named HUD above now, not over our
|
||
// files' census, so it stops following the migration down.
|
||
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 REALLY attaches** — the tripwire for 1734's trap.
|
||
///
|
||
/// A parent name is resolved at LOAD, and a name that names nothing is **not an error**: the loader
|
||
/// warns ("names no frame — falling back to the enclosing one") and silently reparents to whatever
|
||
/// element encloses the declaration. So a typo, a renamed window, or a file that loads earlier in
|
||
/// the manifest than its parent's leaves the frame attached to the wrong thing with a green suite
|
||
/// and one log line nobody greps — which is how 1734's 41 harness failures were the *lucky*
|
||
/// outcome and a silent half-fix was the unlucky one.
|
||
///
|
||
/// Asserted on the LOADED tree, not on the text: `GetParent():GetName()` is what the frame ended up
|
||
/// attached to, which is the only form of this claim worth making. It is also the standing answer
|
||
/// to "does this engine read a cross-file `parent=`?" — several file headers still said it does
|
||
/// not, years after it did, and five pages carry a hand-written substitute for `setAllPoints`
|
||
/// because of it.
|
||
#[test]
|
||
fn every_declared_parent_really_attaches() {
|
||
let mut s = benilla_ui::script::UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
|
||
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
|
||
// into its label inside its own OnLoad. A manifest load with no player is a state the client
|
||
// never reaches (decision 1848).
|
||
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 — the declarations retire with the files
|
||
// that carry them (1751); 67 before 1938, 30 after 1956, 14 after 1970, 2 after 1987
|
||
// (the micro row's eight were most of what was left).
|
||
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")
|
||
);
|
||
}
|
||
|
||
/// `(named non-virtual frame, the `parent=` it declares)` for every such declaration in the shipped
|
||
/// tree — the population of [`every_declared_parent_really_attaches`].
|
||
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 — the sweep's population.
|
||
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 icon picker opens, against the SHIPPED manifest.** Not a harness list — the manifest, the
|
||
/// way the client loads it.
|
||
///
|
||
/// The distinction is the whole point. `macro_tests` stands this window up from its own file list
|
||
/// and passed throughout, because a harness names the dependencies it needs; the manifest had not
|
||
/// listed `ClassTrainerFrameTemplates.xml`, so in the client `MacroPopupScrollFrame` inherited a
|
||
/// template nothing had loaded, came up bare, and `MacroPopupFrame_Update` multiplied a nil offset
|
||
/// (decision 1862). A window is only as loaded as the manifest says.
|
||
#[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 macro, so the edit path has something selected — the director's own flow was a saved
|
||
// macro and the "Change Name/Icon" button beside it.
|
||
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();
|
||
|
||
// The window is a LoadOnDemand addon the app seats at setup (1957) and `ShowMacroFrame`
|
||
// loads through the reference's own `MacroFrame_LoadUI` (1967).
|
||
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 reference's own Interface Options window is on the manifest, and it stays HIDDEN**
|
||
/// (decision 2115) — the whole shape of that record, asserted in one place.
|
||
///
|
||
/// Four claims, and each one has a way to fail that nothing else here would catch:
|
||
///
|
||
/// 1. **`UIOptionsFrame` is the reference's frame, not an alias onto ours.** The alias
|
||
/// (`UIOptionsFrame = OptionsFrame`, when that was our window's name) is what this replaces, and under it every one of these
|
||
/// reads would have succeeded while pointing at the window the player opens — pfUI's skin does
|
||
/// `UIOptionsFrame:SetWidth(1024)`. So the test asks for the reference's own CHILDREN, which an
|
||
/// alias could never grow.
|
||
/// 2. **It is never shown.** `hidden="true"` is the stock file's own attribute and nothing of ours
|
||
/// may `Show()` it.
|
||
/// 3. **`UIOptionsFrameCheckButtons` carries the five rows `MultiActionBars.lua:10` writes** —
|
||
/// which IS the load-order proof. That file writes them at its own load, under the reference's
|
||
/// own comment *"Hack to get around load order dependencies"*, so the rows are there only if
|
||
/// the options window's manifest row sits above the bars', as `FrameXML.toc` l.21 vs l.39 has
|
||
/// it. Reorder the manifest and this is what goes red.
|
||
/// 4. **Ours is still the player's.** `GameMenuButtonOptions` opens `BenillaOptionsFrame`.
|
||
#[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:#?}");
|
||
|
||
// 1 · the reference's own 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"
|
||
);
|
||
|
||
// 2 · …and hidden, by its own file's attribute.
|
||
assert!(
|
||
!s.eval::<bool>("return UIOptionsFrame:IsShown()").unwrap(),
|
||
"the stock window must never be on screen"
|
||
);
|
||
|
||
// 3 · the order proof.
|
||
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,
|
||
"MultiActionBars.lua:10 writes five rows into UIOptionsFrameCheckButtons at ITS load, so \
|
||
the options window's manifest row must sit above the bars' — got {rows:?}"
|
||
);
|
||
|
||
// 4 · 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 reference's own Sound Options window is on the manifest, HIDDEN, and the alias is gone**
|
||
/// — 2115's argument applied to the window its §9 handed over.
|
||
///
|
||
/// Four claims, each with its own way to fail:
|
||
///
|
||
/// 1. **`SoundOptionsFrame` is the reference's frame, not an alias onto ours.** This one is not
|
||
/// the interface window's story repeated: our `OptionsFrame.xml` loads *after* the stock file,
|
||
/// so `SoundOptionsFrame = OptionsFrame` — the alias as it was written, when that was our
|
||
/// window's name — would not have sat beside the real frame; it would
|
||
/// have **clobbered** it, and pfUI's `options-sound.lua` would then have stripped textures off
|
||
/// and re-anchored the window the player opens. The check is the reference's own CHILDREN,
|
||
/// which an alias could never grow, plus the identity itself.
|
||
/// 2. **It is never shown** — `hidden="true"` at the stock file's own xml l.18.
|
||
/// 3. **`SoundOptionsFrame_Load()` runs clean**, which is the Sound window's counterpart to the
|
||
/// interface window's `cameraYawMoveSpeed` hole: its `_Load` reaches
|
||
/// `slider:SetValue(value.initialValue)` for four sliders, and all four of their CVars are
|
||
/// registered. The three check-button CVars that were missing are built now too, so the walk
|
||
/// has nothing to trip on.
|
||
/// 4. **Ours is still the player's** — the ESC menu's Options button opens `BenillaOptionsFrame`, and
|
||
/// neither stock window.
|
||
#[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:#?}");
|
||
|
||
// 1 · the reference's own frame, by the children only it declares — and this list IS the set
|
||
// pfUI's `skins/blizzard/options-sound.lua` walks (there is deliberately no CheckButton3 in
|
||
// the stock file; pfUI's own `if btn then` guard handles the hole).
|
||
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"
|
||
);
|
||
|
||
// 2 · …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"
|
||
);
|
||
|
||
// 3 · `_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"
|
||
);
|
||
|
||
// 4 · 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"
|
||
);
|
||
|
||
// The two stock consumers still answer correctly with every alias retired. They read the
|
||
// reference's three window names unguarded in one `or` chain, and since 2177 all three are
|
||
// real hidden frames — so that chain is now false whatever our window is doing, and the
|
||
// wrappers `GameMenuFrame.xml` installs are the whole reason these still say yes.
|
||
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 reference's own VIDEO options window is on the manifest, HIDDEN, it owns the
|
||
/// `OptionsFrame` name again, and ours is still the player's** (decision 2177).
|
||
///
|
||
/// The last of the reference's three options windows, and the only one whose arrival took a name
|
||
/// off a file of ours — which is what makes this test different from its two siblings above.
|
||
///
|
||
/// Six claims:
|
||
///
|
||
/// 1. **`OptionsFrame` is the stock video window**, by children only that file declares. The list
|
||
/// is exactly what pfUI's `skins/blizzard/options-video.lua` walks — `OptionsFrameHeader`, the
|
||
/// five box frames, `OptionsFrameSlider1..9`, `OptionsFrameCheckButton1..18`, the three
|
||
/// dropdowns and the three buttons. Under the old arrangement `CreateBackdrop(OptionsFrame)`
|
||
/// and `OptionsFrameHeader:SetTexture("")` landed on the window the player opens.
|
||
/// 2. **It is never shown** — `hidden="true"` at the stock file's own xml l.5.
|
||
/// 3. **Ours is a different frame with a different name, and it is still the player's**: the ESC
|
||
/// menu's Options button opens `BenillaOptionsFrame` and no stock window, and it holds the
|
||
/// native-centre panel slot, which is the `UIPanelWindows` row that had to be restated.
|
||
/// 4. **The three stock "is an options window open?" consumers still answer correctly.** They
|
||
/// name the reference's three windows literally, and all three are hidden frames now, so the
|
||
/// honest chain is false — the wrappers in `GameMenuFrame.xml` are the whole reason
|
||
/// `IsOptionFrameOpen` says yes while ours is up, and the ESC ladder still closes it.
|
||
/// 5. **`OptionsFrameSliders` is the reference's nine rows**, not our three, and our Graphics page
|
||
/// reads its bounds off them.
|
||
/// 6. **The verbs answer with the shapes their consumers demand** — `"WxH"` with no decoration,
|
||
/// a `GetCurrentResolution` that really indexes the list `CT_Viewport.lua:105` reads it against,
|
||
/// the single `0` that makes `OptionsFrame_GetRefreshRates` grey its own dropdown, and seven
|
||
/// caps whose flags are `1`/nil (the one type assignment that satisfies both `not hasPixel\
|
||
/// Shaders` and `hasTripleBuffering == 1` in the same function).
|
||
#[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);
|
||
// The display facts the host pushes in a real run. 1600x900 is deliberately NOT one of the
|
||
// monitor's modes: the windowed size a player runs at usually is not, and the setter's promise
|
||
// that `GetCurrentResolution` indexes a row that exists is exactly what that case tests.
|
||
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:#?}");
|
||
|
||
// 1 · the reference's own frame, by the children only it declares — and this list IS what
|
||
// pfUI's `skins/blizzard/options-video.lua` walks.
|
||
for name in [
|
||
"OptionsFrameHeader",
|
||
"OptionsFrameDisplay",
|
||
"OptionsFrameWorldAppearance",
|
||
"OptionsFrameBrightness",
|
||
"OptionsFramePixelShaders",
|
||
"OptionsFrameMiscellaneous",
|
||
"OptionsFrameResolutionDropDown",
|
||
"OptionsFrameRefreshDropDown",
|
||
"OptionsFrameMultiSampleDropDown",
|
||
"OptionsFrameOkay",
|
||
"OptionsFrameCancel",
|
||
"OptionsFrameDefaults",
|
||
"OptionsFrameSlider1",
|
||
"OptionsFrameSlider9",
|
||
"OptionsFrameCheckButton1",
|
||
"OptionsFrameCheckButton18",
|
||
] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return getglobal({name:?}) ~= nil"))
|
||
.unwrap(),
|
||
"{name} — pfUI's options-video skin walks every one of these"
|
||
);
|
||
}
|
||
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"
|
||
);
|
||
|
||
// 2 · …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"
|
||
);
|
||
|
||
// 5 · the reference's own 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"
|
||
);
|
||
|
||
// 6 · the verbs, at the shapes their consumers demand.
|
||
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` is literally `arg[GetCurrentResolution()]` over these varargs, and
|
||
// `:107` then re-parses it with `string.find(currRes, \"(%d+)x(%d+)\")`. Both, verbatim.
|
||
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"
|
||
);
|
||
// **Zero return values**, which is the reference's own "nothing to offer" answer
|
||
// (`0x48c136 xor eax,eax`). It was written here as a single `0` first, from
|
||
// `OptionsFrame_GetRefreshRates`'s `arg.n == 1 and arg[1] == 0` opening; the binary has no
|
||
// `push 0` path at all, and that FrameXML branch is reachable only when the OS reports a `0`
|
||
// refresh rate for every matching 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:?}"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return OptionsFrameRefreshDropDownButton:IsEnabled() == 1")
|
||
.unwrap(),
|
||
"…so the dropdown is left empty but ENABLED — the greying branch is the driver-quirk one, \
|
||
and inventing a 0 to reach it would be inventing a value the binary never produces \
|
||
(`IsEnabled` answers a NUMBER — wow-re `binding-shapes.tsv` 0x7800b0)"
|
||
);
|
||
// The optional index argument is tolerated, which is the shape `SetScreenResolution` shares.
|
||
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 two types the one consuming function demands of them.
|
||
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"
|
||
);
|
||
// The stock defect this reproduces, asserted as a defect: `OptionsFrame_Load` tests
|
||
// `not hasTripleBuffering` twice and `hasTripleBuffering == 1` once. Slot 5 is never nil, and
|
||
// `0` is truthy in Lua — so the two `not` clauses are dead in the reference too, and only the
|
||
// `== 1` one decides anything. Answering nil here would REVIVE a branch the binary cannot reach.
|
||
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`'s three carved argument behaviours, and the one deliberate divergence.
|
||
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"
|
||
);
|
||
// …and now the CVar says 1920x1080 while the live window is still 1600x900, which is exactly
|
||
// the case `GetCurrentResolution` must answer from the WINDOW rather than from the CVar.
|
||
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"
|
||
);
|
||
|
||
// 3 · 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"
|
||
);
|
||
|
||
// 4 · the three stock consumers, which now 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());
|
||
}
|
||
|
||
/// **The ten names the video window's slider walk must NOT find** (decision 2177).
|
||
///
|
||
/// `OptionsFrame_Load:110` and `_Save:208-209` do `getglobal("Get"..value.func)` /
|
||
/// `("Set"..value.func)` over the nine `OptionsFrameSliders` rows and **branch on the result**: a
|
||
/// hit is called, a miss falls through to `GetCVar`/`SetCVar`. Of the eighteen composed names, six
|
||
/// resolve to real bindings in the reference — `Get/SetWorldDetail`, `Get/SetTerrainMip`,
|
||
/// `Get/SetBaseMip` — and ten must resolve to nil. Defining any of the ten changes this window's
|
||
/// behaviour **without erroring**, which is why it needs an assertion rather than a comment.
|
||
///
|
||
/// The trap that makes it worth pinning: `GetFarclip 0x488f00` and `SetFarclip 0x488f30` really do
|
||
/// exist in the reference, with a capital F, while `value.func` is the lowercase `"farclip"`. The
|
||
/// stock client takes the CVar path for far clip **only because `getglobal` is case-sensitive** — so
|
||
/// this asserts that too, against a name we DO define.
|
||
///
|
||
/// (wow-re `ui/scratch/video-options-verbs.md` §5 and §7.7, both VERIFIED.)
|
||
#[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 (2163's Environment Detail pair).
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// **pfUI's `UIOptionsFrame_Save()` path runs clean, and `_Load()` stops at exactly one thing**
|
||
/// (decision 2115).
|
||
///
|
||
/// pfUI's `modules/gui.lua` l.146-148 wraps a GVAR checkbox's write in
|
||
/// `UIOptionsFrame_Load()` … `UIOptionsFrame_Save()`, so both are reached at runtime by a real
|
||
/// addon and both had to be more than nil.
|
||
///
|
||
/// **Both run clean now.** 2115 shipped this test asserting that `_Load` *raised* — pinning the
|
||
/// gap rather than hiding it, and saying in as many words that "the day something registers it,
|
||
/// THIS assertion goes red and gets deleted". That day is this change: `UIOptionsFrameSliders`
|
||
/// row 3 is `cameraYawMoveSpeed`, `_Load` does `slider:SetValue(GetCVar(value.cvar))`, and
|
||
/// `Slider:SetValue` is a shape-A binding (`0x790980`, wow-re `numeric-arg-coercion-law.md`) that
|
||
/// raises on a nil in the reference too. All four slider CVars are registered, so the walk reaches
|
||
/// its end — and `_SetDefaults`, which does the same through `GetCVarDefault`, with it.
|
||
#[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,
|
||
// **The player has a CLASS, because a real one always does** — and `_Load`'s tail
|
||
// needs it: `UIOptionsFrame_UpdateDependencies` (lua l.759-763) does
|
||
// `local temp, class = UnitClass("player"); class = strupper(class)` to decide whether
|
||
// the combo-point combat-text box applies, and `strupper(nil)` raises. It could not be
|
||
// reached before, because `_Load` died at slider 3 forty lines earlier — so this is
|
||
// 2115 §10's lesson a second time, in the same window: a probe VM that runs the stock
|
||
// interface has to be a VM the stock interface's own assumptions hold in. Warrior
|
||
// rather than Rogue/Druid so the DISABLE branch runs, which is the one with a call in
|
||
// it (`OptionsFrame_DisableCheckBox`).
|
||
class: Some("Warrior".into()),
|
||
class_file: Some("WARRIOR".into()),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
assert!(super::load_default_ui(&s).is_empty());
|
||
|
||
// The Okay path, whole: 69 check buttons, four sliders, four dropdowns, the multibar toggles
|
||
// and the combat-text tail. Driven through the reference's OWN caller — `UIOptionsFrameOkay`'s
|
||
// `<OnClick>` (xml l.1205-1209) — rather than as a bare call, and that is not ceremony: the
|
||
// `SHOW_PARTY_PETS` arm reaches `RefreshBuffs`, whose first act is `this.hasDispellable = nil`
|
||
// (`BuffFrame.lua:266`), so the reference's ambient handler global has to be set. It is, at
|
||
// every real call site: the Okay button here, and pfUI's own checkbox on its GVAR path.
|
||
// **`_Load` first, because that is the window's own order** — the reference loads on show and
|
||
// saves on Okay, and driving Okay over a window nothing had loaded is what made an earlier cut
|
||
// of this test read a slider floor back out of the CVar. It also makes the Okay run below
|
||
// exercise a *populated* window, which is strictly the better coverage.
|
||
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());
|
||
|
||
// The four slider CVars, by the file's own table rather than by a list retyped here: every one
|
||
// must answer, because a single nil among them puts the raise back.
|
||
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:?}"
|
||
);
|
||
|
||
// …and the walk really did reach slider 3 rather than stopping short of it: the Mouse Look
|
||
// Speed slider is sitting on `cameraYawMoveSpeed`'s registered 180. Slider 3 is the one that
|
||
// matters — it is the row whose nil raised, forty lines into a walk of 69 check buttons.
|
||
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`, and it had the same raise.
|
||
s.run("this = UIOptionsFrameDefaults UIOptionsFrame_SetDefaults()")
|
||
.expect("_SetDefaults runs to completion too");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
|
||
// The Okay path, over the window `_Load` just populated. Driven through the reference's OWN
|
||
// caller — `UIOptionsFrameOkay`'s `<OnClick>` (xml l.1205-1209) — rather than as a bare call,
|
||
// and that is not ceremony: the `SHOW_PARTY_PETS` arm reaches `RefreshBuffs`, whose first act
|
||
// is `this.hasDispellable = nil` (`BuffFrame.lua:266`), so the reference's ambient handler
|
||
// global has to be set. It is, at every real call site: the Okay button here, and pfUI's own
|
||
// checkbox on its GVAR path.
|
||
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 two move-speed CVars for real now**, which is the other half of the
|
||
// wiring and was a silent no-op before they were registered: it writes the yaw slider's value
|
||
// and, beside it, `cameraPitchMoveSpeed = value / 2` (lua l.355-356) — the relation the
|
||
// reference's own registered defaults confirm, 180 and 90.
|
||
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}"
|
||
);
|
||
}
|