Files
benilla-octo/crates/benilla-ui/src/loader/tests.rs
T
SamandClaude Fable 5 251daa14c5 addons come alive: Bagnon draws, hovers and clicks
- Lua 5.0's generic-for and nested long strings are restored in a forked VM — 60 corpus addons stopped dying on one deleted opcode
- a day of demand-driven carving takes the 218-addon corpus from 24 addons surviving a session start to 114, with 144 loading without a single error
- Bagnon draws a real bag and answers real gestures — hover tooltip, pickup, use, drag — because our frames wear the reference's names, and the reference's own ContainerFrame.lua runs off the player's install instead of being transcribed
- the physics broad phase leaves: nothing in the client reads a contact, and it was measured at up to 41% of main-thread samples in real sessions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:19:08 +07:00

1435 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Loader tests — split from `mod.rs` (file-size budget); same `super::*` view.
mod loader_tests {
use crate::framexml;
use crate::loader::*;
use crate::order::ZTarget;
use crate::script::{QuadContent, UiScript};
/// No-provider `files` closure (for docs with no `<Include>`/`<Script file=>`).
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
fn parse(text: &str) -> framexml::ParsedDocument {
framexml::parse(text).expect("valid FrameXML")
}
/// **`parent="Name"` attaches a top-level element** — the reference's own way of doing it,
/// because its FrameXML is flat (decision 1211). 79 corpus addons, 708 sites.
///
/// Three claims in one document: the attribute supplies the parent when there is no lexical
/// one; an anchor with no `relativeTo` then measures from THAT parent rather than the screen
/// (the silent half — a frame in the wrong place with nothing to report); and a name that
/// resolves to nothing warns and falls back instead of raising.
#[test]
fn a_top_level_parent_attribute_attaches_and_anchors() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Frame name="Host">
<Size><AbsDimension x="200" y="100"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT">
<Offset><AbsDimension x="100" y="-50"/></Offset>
</Anchor>
</Anchors>
</Frame>
<Frame name="Attached" parent="Host">
<Size><AbsDimension x="20" y="20"/></Size>
<Anchors>
<Anchor point="TOPLEFT">
<Offset><AbsDimension x="5" y="-5"/></Offset>
</Anchor>
</Anchors>
</Frame>
<Frame name="Orphan" parent="NoSuchFrame">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
s.resolve();
assert_eq!(
s.eval::<String>("return Attached:GetParent():GetName()")
.unwrap(),
"Host",
"the attribute supplied the parent"
);
// Host's TOPLEFT is screen (100, 550) in y-up; Attached hangs 5 in and 5 down from it.
assert_eq!(
s.eval::<(f64, f64)>("return Attached:GetLeft(), Attached:GetTop()")
.unwrap(),
(105.0, 545.0),
"a relativeTo-less anchor measures from the PARENT, not the screen"
);
// The miss: a warning, a usable frame, and no parent — never an error.
assert!(s.eval::<bool>("return Orphan ~= nil").unwrap());
assert!(s.eval::<bool>("return Orphan:GetParent() == nil").unwrap());
assert!(
report.warnings.iter().any(|w| w.contains("NoSuchFrame")),
"the unresolvable parent is named in a warning: {:?}",
report.warnings
);
// And a name that exists but is NOT a frame is the same miss, not a raise. `_G` is one
// namespace and an addon's own `MyAddon = {}` lives in it — the corpus writes
// `parent="TheoryCraft"` where the addon owns that name. Handing a plain table to
// CreateFrame would kill the element and its whole subtree.
s.run("NotAFrame = { some = 'table' }").unwrap();
let doc2 = parse(
r#"<Ui>
<Frame name="Confused" parent="NotAFrame">
<Size><AbsDimension x="10" y="10"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
</Frame>
</Ui>"#,
);
let report2 = load(&s, &doc2, &no_files);
assert!(report2.errors.is_empty(), "{:?}", report2.errors);
assert!(s.eval::<bool>("return Confused ~= nil").unwrap());
assert!(s
.eval::<bool>("return Confused:GetParent() == nil")
.unwrap());
assert!(
report2.warnings.iter().any(|w| w.contains("NotAFrame")),
"a non-frame global of the right name is named too: {:?}",
report2.warnings
);
}
/// End-to-end: a virtual template, an instance inheriting it (with `<Size>`, screen `<Anchors>`,
/// a `<Layers>` coloured `<Texture>`, a nested child `<Frame>` in `<Frames>`, and `<OnLoad>`
/// handlers on both) — proving name publication, bottom-up OnLoad, `$parent`, and that
/// resolve()+extract() places the texture at the anchored rect.
#[test]
fn synthetic_full_document_materializes() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
// Seed the ordering witness before the load runs any OnLoad.
s.run("loadorder = {}").unwrap();
let doc = parse(
r#"<Ui>
<Frame name="MyTemplate" virtual="true">
<Size><AbsDimension x="200" y="100"/></Size>
</Frame>
<Frame name="MyFrame" inherits="MyTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT">
<Offset><AbsDimension x="10" y="-20"/></Offset>
</Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentTex">
<Color r="1" g="0" b="0" a="1"/>
</Texture>
</Layer>
</Layers>
<Frames>
<Frame name="$parentChild">
<Scripts>
<OnLoad>table.insert(loadorder, "child"); ChildLoaded = self:GetName()</OnLoad>
</Scripts>
</Frame>
</Frames>
<Scripts>
<OnLoad>table.insert(loadorder, "parent"); ParentLoaded = this:GetName()</OnLoad>
</Scripts>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert_eq!(report.frames, 2, "parent + child materialized");
// Frame exists by name; the child's $parent resolved to MyFrameChild; the texture published
// its $parent-resolved name.
assert!(s.eval::<bool>("return MyFrame ~= nil").unwrap());
assert!(s.eval::<bool>("return MyFrameChild ~= nil").unwrap());
assert!(s.eval::<bool>("return MyFrameTex ~= nil").unwrap());
// OnLoad fired for both (modern `self` in child, legacy `this` in parent — both work).
assert_eq!(
s.eval::<String>("return ChildLoaded").unwrap(),
"MyFrameChild"
);
assert_eq!(s.eval::<String>("return ParentLoaded").unwrap(), "MyFrame");
// Bottom-up: the child's OnLoad ran before the parent's (rf26).
let order: Vec<String> = s.eval("return loadorder").unwrap();
assert_eq!(order, vec!["child".to_string(), "parent".to_string()]);
// resolve()+extract(): the texture quad sits at MyFrame's anchored rect.
// Screen [0,0,600,800], TOPLEFT+(10,-20), size 200x100 → Rect(bottom 480, left 10, top 580,
// right 210). Region rect = its owner frame's resolved rect (v1).
s.resolve();
let quads = s.extract();
let tex = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Texture { color: Some(_), .. }))
.expect("the coloured texture quad");
assert!(
matches!(&tex.content, QuadContent::Texture { color: Some(c), path: None, .. } if *c == [1.0, 0.0, 0.0, 1.0])
);
assert_eq!(
tex.rect,
Some(crate::layout::Rect::new(480.0, 10.0, 580.0, 210.0))
);
}
/// An instance's own `<Size>` beats its template's: expansion appends the instance's children
/// after the template's, and `apply_size` walks EVERY `<Size>` in document order so the last
/// write wins — exactly the client's process-each-child behavior. (Regression: `.next()` here
/// once pinned every templated button to the TEMPLATE's size; the quest log's Abandon button
/// declared 125×21 and rendered 80×22, wrapping its label onto two lines.)
#[test]
fn instance_size_overrides_template_size() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Button name="SizedTemplate" virtual="true">
<Size><AbsDimension x="80" y="22"/></Size>
</Button>
<Button name="SizedInstance" inherits="SizedTemplate">
<Size><AbsDimension x="125" y="21"/></Size>
</Button>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert_eq!(
s.eval::<f32>("return SizedInstance:GetWidth()").unwrap(),
125.0
);
assert_eq!(
s.eval::<f32>("return SizedInstance:GetHeight()").unwrap(),
21.0
);
}
/// A `<Frame setAllPoints="true">` (no `<Size>`/`<Anchors>` of its own) resolves to its
/// parent's rect — the frame path honors the shorthand like the region path does.
/// (Regression: only regions applied it; the world map's chrome frames carried the attribute,
/// never resolved, and their whole subtrees — backdrop, title, buttons — silently vanished.)
#[test]
fn frame_set_all_points_pins_to_parent() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Frame name="PinHost">
<Size><AbsDimension x="300" y="200"/></Size>
<Anchors>
<Anchor point="TOPLEFT">
<Offset><AbsDimension x="40" y="-50"/></Offset>
</Anchor>
</Anchors>
<Frames>
<Frame name="PinChild" setAllPoints="true"/>
</Frames>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
s.resolve();
assert_eq!(s.eval::<f32>("return PinChild:GetLeft()").unwrap(), 40.0);
assert_eq!(s.eval::<f32>("return PinChild:GetTop()").unwrap(), 550.0);
assert_eq!(s.eval::<f32>("return PinChild:GetWidth()").unwrap(), 300.0);
assert_eq!(s.eval::<f32>("return PinChild:GetHeight()").unwrap(), 200.0);
}
/// `<Include>` resolved through an in-memory provider closure.
#[test]
fn include_resolves_through_provider() {
let s = UiScript::new().unwrap();
let provider = |path: &str| -> Option<Vec<u8>> {
match path {
"Sub.xml" => Some(br#"<Ui><Frame name="FromInclude"/></Ui>"#.to_vec()),
_ => None,
}
};
let doc = parse(
r#"<Ui>
<Include file="Sub.xml"/>
<Frame name="FromMain"/>
</Ui>"#,
);
let report = load(&s, &doc, &provider);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert_eq!(report.frames, 2);
assert!(s
.eval::<bool>("return FromInclude ~= nil and FromMain ~= nil")
.unwrap());
}
/// A missing include is an **error**, and the rest of the load still proceeds.
///
/// It was a warning until decision 1186. The load-and-continue half is unchanged and faithful
/// (0068: the client logs and carries on) — what changed is the *reporting*, because a warning
/// is not in the value callers assert on. Bagnon missed all eleven of its references and came
/// back with zero errors, which read as a clean load of an addon that had built nothing.
#[test]
fn missing_include_errors_and_continues() {
let s = UiScript::new().unwrap();
let doc = parse(r#"<Ui><Include file="Nope.xml"/><Frame name="Still"/></Ui>"#);
let report = load(&s, &doc, &no_files);
assert!(
report.errors.iter().any(|e| e.contains("Nope.xml")),
"an unresolved include drops a whole document: {:?}",
report.errors
);
assert!(
s.eval::<bool>("return Still ~= nil").unwrap(),
"and the load continues past it"
);
}
/// So is a missing `<Script file=>` — it drops every handler the file would have defined.
#[test]
fn missing_script_file_errors_and_continues() {
let s = UiScript::new().unwrap();
let doc = parse(r#"<Ui><Script file="Nope.lua"/><Frame name="Still"/></Ui>"#);
let report = load(&s, &doc, &no_files);
assert!(
report.errors.iter().any(|e| e.contains("Nope.lua")),
"{:?}",
report.errors
);
assert!(s.eval::<bool>("return Still ~= nil").unwrap());
}
/// `$parent` resolves in a child's *name* and in an anchor's `relativeTo`.
#[test]
fn parent_token_in_child_name_and_anchor_relative_to() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Frame name="PF">
<Size><AbsDimension x="100" y="100"/></Size>
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="50" y="-50"/></Offset></Anchor>
</Anchors>
<Frames>
<Frame name="$parentInner">
<Size><AbsDimension x="100" y="100"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parent" relativePoint="TOPLEFT"/>
</Anchors>
</Frame>
</Frames>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// Child $parent-name resolved to PFInner and published.
assert!(s.eval::<bool>("return PFInner ~= nil").unwrap());
// relativeTo="$parent" resolved to PF, so the child sits exactly on the parent's TOPLEFT.
s.resolve();
let quads = s.extract();
let rects: Vec<_> = quads
.iter()
.filter_map(|q| match q.target {
ZTarget::Frame(_) => q.rect,
_ => None,
})
.collect();
// Parent: screen [0,0,600,800] TOPLEFT+(50,-50), 100x100 → Rect(450,50,550,150). The child,
// anchored TOPLEFT→parent TOPLEFT with no offset, resolves to the same rect.
let expected = crate::layout::Rect::new(450.0, 50.0, 550.0, 150.0);
assert!(
rects.iter().filter(|r| **r == expected).count() >= 2,
"rects: {rects:?}"
);
}
/// A handler with a syntax error yields an `errors[]` entry, the load continues, and the other
/// frame is still built with a working handler.
#[test]
fn bad_handler_is_an_error_and_load_continues() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Broken">
<Scripts><OnLoad>this that syntax error(((</OnLoad></Scripts>
</Frame>
<Frame name="Fine">
<Scripts><OnLoad>FineRan = true</OnLoad></Scripts>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert_eq!(report.frames, 2, "both frames still created");
assert!(
report.errors.iter().any(|e| e.contains("compiling")),
"expected a compile error, got {:?}",
report.errors
);
// The broken frame exists but its OnLoad never ran; the good frame's did.
assert!(s.eval::<bool>("return Broken ~= nil").unwrap());
assert!(s.eval::<bool>("return FineRan == true").unwrap());
}
/// Unsupported handler names (e.g. the keyboard-focus handlers `OnKeyDown`/`OnChar`, not yet
/// modeled) are warn-once gaps, not hard errors, and don't stop the frame from building.
#[test]
fn unsupported_script_name_is_a_warning() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui><Frame name="Keyed"><Scripts><OnKeyDown>x = 1</OnKeyDown></Scripts></Frame></Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert_eq!(report.frames, 1);
assert!(report.errors.is_empty(), "{:?}", report.errors);
assert!(report.warnings.iter().any(|w| w.contains("OnKeyDown")));
}
/// An unknown frame type is an error that drops that subtree but not the rest of the load.
#[test]
fn unknown_frame_type_errors_but_continues() {
let s = UiScript::new().unwrap();
let doc = parse(r#"<Ui><Bogus name="X"/><Frame name="Real"/></Ui>"#);
let report = load(&s, &doc, &no_files);
assert_eq!(report.frames, 1, "only the real frame built");
assert!(report.errors.iter().any(|e| e.contains("CreateFrame")));
assert!(s.eval::<bool>("return Real ~= nil").unwrap());
}
/// Env-gated smoke test over a real extracted FrameXML file (never committed; extract with
/// benilla-extract and point `BENILLA_FRAMEXML_LOAD` at it). Parses + loads with a provider over
/// the file's own directory, asserts no hard *parse/loader-internal* error, and reports how many
/// frames materialized plus the API/handler gaps the file surfaced. Skips silently otherwise,
/// matching framexml.rs's `real_framexml_when_available` pattern so gates never depend on client
/// data.
#[test]
fn real_framexml_load_when_available() {
let Ok(path) = std::env::var("BENILLA_FRAMEXML_LOAD") else {
return;
};
let text = std::fs::read_to_string(&path).expect("reading BENILLA_FRAMEXML_LOAD");
let dir = std::path::Path::new(&path)
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
// Provider: resolve an XML/Lua reference against the file's directory, trying the path as
// given and by basename (Blizzard paths use backslashes and are dir-relative).
let provider = move |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
let s = UiScript::new().unwrap();
let doc = framexml::parse(&text).unwrap_or_else(|e| panic!("{path}: {e}"));
let report = load(&s, &doc, &provider);
eprintln!("== {path}: {} frames materialized ==", report.frames);
for e in &report.errors {
eprintln!(" error: {e}");
}
for w in &report.warnings {
eprintln!(" warn: {w}");
}
// Script errors surfaced by any OnLoad that ran against missing API:
for e in s.errors() {
eprintln!(" script-error: {e}");
}
}
/// `<StatusBar>` LoadXML extras (RF-28): value attributes, `<BarTexture>` + `<BarColor>`
/// children, orientation — landing in the widget state and scaling the extracted bar quad.
#[test]
fn statusbar_xml_extras_apply() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<StatusBar name="XmlBar" minValue="0" maxValue="100" defaultValue="50">
<Size><AbsDimension x="100" y="10"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="0"/></Offset>
</Anchor>
</Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
<BarColor r="0.1" g="0.9" b="0.1"/>
</StatusBar>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let ok: bool = s
.eval(
r#"
local mn, mx = XmlBar:GetMinMaxValues()
local r, g, b, a = XmlBar:GetStatusBarColor()
return mn == 0 and mx == 100 and XmlBar:GetValue() == 50
and XmlBar:GetOrientation() == "HORIZONTAL"
and XmlBar:GetStatusBarTexture() ~= nil
and r < 0.2 and g > 0.8 and a == 1
"#,
)
.unwrap();
assert!(ok, "XML attributes + children landed in the widget state");
s.resolve();
let bar = s
.extract()
.into_iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-StatusBar"))
})
.expect("bar fill quad");
let r = bar.rect.expect("bar rect resolved");
assert_eq!(
(r.left, r.right, r.bottom, r.top),
(0.0, 50.0, 0.0, 10.0),
"defaultValue 50/100 fills half the 100px width"
);
}
#[test]
fn button_xml_extras_apply() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Button name="XmlBtn" text="Push Me">
<Size><AbsDimension x="64" y="24"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="0"/></Offset>
</Anchor>
</Anchors>
<NormalTexture file="Interface\B-Up"/>
<PushedTexture file="Interface\B-Down"/>
<HighlightTexture file="Interface\B-Hi"/>
<Scripts>
<OnClick>xml_clicked = true</OnClick>
</Scripts>
</Button>
<CheckButton name="XmlCheck" checked="true">
<Size><AbsDimension x="24" y="24"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="100" y="0"/></Offset>
</Anchor>
</Anchors>
<NormalTexture file="Interface\CB-Up"/>
<CheckedTexture file="Interface\CB-Check"/>
</CheckButton>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let ok: bool = s
.eval(
r#"
return XmlBtn:GetText() == "Push Me"
and XmlBtn:GetNormalTexture() ~= nil
and XmlBtn:GetPushedTexture() ~= nil
and XmlCheck:GetChecked() == true
"#,
)
.unwrap();
assert!(ok, "button XML attrs + textures landed");
// The checked CheckButton draws its check mark; the idle Button draws only its normal.
s.resolve();
let texs: Vec<String> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
_ => None,
})
.collect();
assert!(texs.contains(&"Interface\\B-Up".into()));
assert!(!texs.contains(&"Interface\\B-Down".into()));
assert!(!texs.contains(&"Interface\\B-Hi".into()));
assert!(texs.contains(&"Interface\\CB-Check".into()));
// A physical click fires the XML OnClick.
s.mouse_button(30.0, 10.0, "LeftButton", true);
s.mouse_button(30.0, 10.0, "LeftButton", false);
assert!(s.eval::<bool>("return xml_clicked == true").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A state texture's own `<Size>` + `<Anchors>` scope it to less than its button (the merchant
/// row's icon-scoped highlight): hovering the 100px-wide button, the highlight quad renders at
/// the anchored 20px square, not stretched over the button.
#[test]
fn button_state_texture_takes_size_and_anchors() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Button name="ScopedBtn">
<Size><AbsDimension x="100" y="30"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="0"/></Offset>
</Anchor>
</Anchors>
<HighlightTexture file="Interface\Scoped-Hi" alphaMode="ADD">
<Size><AbsDimension x="20" y="20"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</HighlightTexture>
</Button>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Hover the button's far-right end — the highlight still sits at the anchored TOPLEFT.
// (Resolve first: hit-testing reads the resolved-rect cache.)
s.resolve();
s.mouse_move(90.0, 15.0);
s.resolve();
let hi = s
.extract()
.into_iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("Scoped-Hi"))
})
.expect("hovered highlight quad");
let r = hi.rect.expect("highlight rect resolved");
assert_eq!(
(r.left, r.right, r.bottom, r.top),
(0.0, 20.0, 10.0, 30.0),
"highlight is the anchored 20px square at the button's TOPLEFT, not the whole button"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `<EditBox>` LoadXML extras (RF-0082): `letters` → SetMaxLetters and the config flags land in
/// the widget state; a click focuses it and the `<OnTextChanged>` handler fires on a typed char.
#[test]
fn editbox_xml_extras_apply() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<EditBox name="XmlEdit" letters="5" numeric="true">
<Size><AbsDimension x="120" y="20"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="0" y="0"/></Offset>
</Anchor>
</Anchors>
<Scripts>
<OnTextChanged>typed = true</OnTextChanged>
</Scripts>
</EditBox>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// letters=5 landed; numeric abort rejects a non-digit insert wholesale.
assert_eq!(s.eval::<i64>("return XmlEdit:GetNumLetters()").unwrap(), 0);
s.run(r#"XmlEdit:Insert("12x")"#).unwrap(); // numeric: aborts
s.run(r#"XmlEdit:Insert("123456")"#).unwrap(); // digits, capped at 5
assert_eq!(
s.eval::<String>("return XmlEdit:GetText()").unwrap(),
"12345"
);
// A click focuses the box (mouse-enabled by construction); a typed char fires OnTextChanged.
s.resolve();
s.mouse_button(50.0, 10.0, "LeftButton", true);
s.mouse_button(50.0, 10.0, "LeftButton", false);
assert!(s.eval::<bool>("return XmlEdit:HasFocus()").unwrap());
s.run("typed = false").unwrap();
assert!(s.char_input("7"));
assert!(s.eval::<bool>("return typed == true").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ── TexCoords + Font objects (decision 0084 engine slice) ───────────────────────────────────
/// `<TexCoords left right top bottom>` on a `<Texture>` parses into the region's UV rect and
/// surfaces on the extracted quad as `[left, right, top, bottom]`.
#[test]
fn texcoords_parse_to_uv_on_extracted_quad() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Frame name="TC">
<Size><AbsDimension x="100" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer level="ARTWORK">
<Texture name="$parentArt" file="Interface\Foo">
<TexCoords left="0.0" right="0.5" top="0.25" bottom="0.75"/>
</Texture>
</Layer></Layers>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
s.resolve();
let tex = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains("Foo")))
.expect("the Foo texture quad");
assert!(
matches!(
&tex.content,
QuadContent::Texture { tex_coords: Some(crate::script::TexCoords::Rect(tc)), .. }
if *tc == [0.0, 0.5, 0.25, 0.75]
),
"got {:?}",
tex.content
);
}
/// A `<Font>` `inherits=` chain resolves height + color through two levels: the leaf inherits its
/// face + one value from the root and overrides another mid-chain (last-wins merge).
#[test]
fn font_inherits_chain_resolves_height_and_color_two_levels() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Font name="Base" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Font name="Mid" inherits="Base" virtual="true">
<Color r="1.0" g="1.0" b="1.0"/>
</Font>
<Font name="Leaf" inherits="Mid" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
</Font>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let base = s.font_object("Base").expect("Base registered");
assert_eq!(base.font.as_deref(), Some("Fonts\\FRIZQT__.TTF"));
assert_eq!(base.height, Some(12.0));
assert_eq!(base.color, Some([1.0, 0.82, 0.0, 1.0]));
// Leaf: face inherited from Base (through Mid), color from Mid (white), height overridden (10).
let leaf = s.font_object("Leaf").expect("Leaf registered");
assert_eq!(leaf.font.as_deref(), Some("Fonts\\FRIZQT__.TTF"));
assert_eq!(leaf.height, Some(10.0));
assert_eq!(leaf.color, Some([1.0, 1.0, 1.0, 1.0]));
}
/// A `<FontString inherits="GameFontNormalSmall">` reports the *smaller* resolved height (and the
/// inherited face + color) on the extracted quad — the per-FontString size the renderer bakes at.
#[test]
fn fontstring_inherits_reports_smaller_resolved_height() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Font name="GameFontNormalSmall" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Frame name="FS">
<Size><AbsDimension x="100" y="30"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer level="ARTWORK">
<FontString name="$parentBig" inherits="GameFontNormal" text="Big"/>
<FontString name="$parentSmall" inherits="GameFontNormalSmall" text="Small"/>
</Layer></Layers>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
s.resolve();
let height_of = |s: &UiScript, want: &str| -> Option<f32> {
s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Text {
text: Some(t),
font_height,
..
} if t == want => Some(font_height?),
_ => None,
})
};
assert_eq!(height_of(&s, "Big"), Some(12.0));
assert_eq!(
height_of(&s, "Small"),
Some(10.0),
"the *Small font object resolves to the smaller height"
);
// The inherited face + color also flow through.
let small = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Small"))
.unwrap();
assert!(matches!(
&small.content,
QuadContent::Text { font: Some(f), color: Some(c), .. }
if f == "Fonts\\FRIZQT__.TTF" && *c == [1.0, 0.82, 0.0, 1.0]
));
}
/// An explicit `<Color>` on a FontString overrides its font object's color; an `inherits=` naming
/// no registered font object is a warn-once gap, not a dropped region.
#[test]
fn fontstring_color_overrides_object_and_unknown_inherits_warns() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
<Color r="1.0" g="0.82" b="0"/>
</Font>
<Frame name="OV">
<Size><AbsDimension x="100" y="30"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers><Layer level="ARTWORK">
<FontString name="$parentA" inherits="GameFontNormal" text="A">
<Color r="0.1" g="0.2" b="0.3" a="1.0"/>
</FontString>
<FontString name="$parentB" inherits="NoSuchFont" text="B"/>
</Layer></Layers>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
report.warnings.iter().any(|w| w.contains("NoSuchFont")),
"unknown font object should warn: {:?}",
report.warnings
);
s.resolve();
// A's explicit <Color> wins over GameFontNormal's gold; its height still comes from the object.
let a = s
.extract()
.into_iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "A"))
.unwrap();
assert!(matches!(
&a.content,
QuadContent::Text { color: Some(c), font_height: Some(h), .. }
if *c == [0.1, 0.2, 0.3, 1.0] && *h == 12.0
));
// B still materialized (the unknown inherits didn't drop it).
assert!(s
.extract()
.into_iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "B")));
}
/// The chat edit box's shape: an EditBox with BOTH a `<Layers>` FontString (the "Say:" header)
/// and the special direct-child `<FontString>` (the engine's text-font slot). The box must
/// adopt the SPECIAL one as its text region — the engine ASSIGNS that slot at LoadXML, it
/// never searches. (The old find-first adoption grabbed the header: typing then overwrote
/// "Say:", and the `<TextInsets>` re-anchor clobbered the header's LEFT anchor into a
/// full-width rect — the centered-header + invisible-typing chat bug.)
#[test]
fn editbox_adopts_special_fontstring_not_a_layers_header() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<EditBox name="EB" letters="255">
<Size><AbsDimension x="600" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
<Offset><AbsDimension x="100" y="100"/></Offset>
</Anchor>
</Anchors>
<TextInsets><AbsInset left="47" right="13" top="0" bottom="0"/></TextInsets>
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentHeader" text="Say:">
<Anchors>
<Anchor point="LEFT"><Offset><AbsDimension x="13" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<FontString justifyH="LEFT"/>
</EditBox>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Type into the focused box.
s.run("EB:SetFocus()").unwrap();
assert!(s.char_input("h"));
assert!(s.char_input("i"));
// Resolve; answer every fontstring measure with a stand-in extent (30×12), resolve again
// (the round-trip's second solve).
s.resolve();
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.iter()
.map(|r| (r.id, 30.0, 12.0, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
let quads = s.extract();
// The header still reads "Say:" — typing lands in the box's own text region, never the
// header. Both strings render.
let header = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Say:"))
.expect("the header FontString still renders its own text");
let typed = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "hi"))
.expect("the typed text renders in the box's text region");
// Header rect: its own LEFT+13 anchor + the measured 30×12 extent (left-flush against the
// box's left edge at x=100, v-centered on the 100..132 box) — NOT the insets rect.
assert_eq!(
header.rect,
Some(crate::layout::Rect::new(110.0, 113.0, 122.0, 143.0)),
"header must keep its own anchor + auto-size"
);
// Typed text rect: the box minus the XML `<TextInsets>` (47 left, 13 right).
assert_eq!(
typed.rect,
Some(crate::layout::Rect::new(100.0, 147.0, 132.0, 687.0)),
"the text region is anchored by the insets"
);
}
/// `<HitRectInsets>` reaches SetHitRectInsets, and the inset band stops capturing the mouse
/// while the frame's own geometry is untouched — the ref micro-button shape (a 29x58 frame whose
/// art fills only the lower ~40, `top="18"`).
#[test]
fn hit_rect_insets_element_shrinks_the_mouse_rect() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Button name="Micro">
<Size><AbsDimension x="29" y="58"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors>
<HitRectInsets><AbsInset left="0" right="0" top="18" bottom="0"/></HitRectInsets>
</Button>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
s.resolve();
let (l, r, t, b) = s
.eval::<(f64, f64, f64, f64)>("return Micro:GetHitRectInsets()")
.unwrap();
assert_eq!((l, r, t, b), (0.0, 0.0, 18.0, 0.0));
// Geometry untouched: still a 58-tall button.
assert_eq!(s.eval::<f64>("return Micro:GetHeight()").unwrap(), 58.0);
// A button is mouse-enabled by construction, so the hit test is live.
assert!(
s.hit_test(14.0, 50.0).is_none(),
"the dead 18-unit header must not capture"
);
assert!(s.hit_test(14.0, 20.0).is_some(), "the art band still hits");
}
/// **The `<Scripts>` walker auto-enables the mouse kind when it attaches a mouse handler**
/// (`0x769ef0` → `0x76af00(2,-1)` per handler name; wow-re `ui/scratch/scripts-auto-enable.md`,
/// §5 cross-checked) — the reference's GameTimeFrame declares `<OnEnter>`/`<OnLeave>` and no
/// `enableMouse`, yet its tooltip hovers in the real client. The kind-2 name set is exactly
/// {OnEnter, OnLeave, OnMouseDown, OnMouseUp, OnDragStart}: `OnDragStop`/`OnReceiveDrag` are
/// NOT in it, and the law is XML-load-time only — the Lua SetScript binding (`0x7748d0`)
/// never auto-enables (verified negative there).
#[test]
fn scripts_block_mouse_handlers_auto_enable_mouse() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Hoverable">
<Scripts><OnEnter>-- hover</OnEnter></Scripts>
</Frame>
<Frame name="DropTarget">
<Scripts><OnDragStop>-- outside the kind-2 set</OnDragStop></Scripts>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
s.eval::<bool>("return Hoverable:IsMouseEnabled()").unwrap(),
"an XML mouse handler arms EnableMouse like the attribute would"
);
assert!(
!s.eval::<bool>("return DropTarget:IsMouseEnabled()")
.unwrap(),
"OnDragStop is outside the kind-2 name set"
);
// Runtime SetScript never auto-enables — the law is the XML walker's, not SetScript's.
s.run("rt = CreateFrame('Frame', 'Rt'); rt:SetScript('OnEnter', function() end)")
.unwrap();
assert!(
!s.eval::<bool>("return Rt:IsMouseEnabled()").unwrap(),
"a runtime-created frame still needs an explicit EnableMouse"
);
}
/// **`text=` is a GLOBAL-STRING LOOKUP, not a literal** (wow-re rf28 l.36/l.115 →
/// `FrameScript_GetText 0x703bf0`). Every arm of [`Loader::resolve_text`] in one document:
/// a `<Button text=>`, a `<ButtonText text=>` and a `<FontString text=>` all resolve through
/// the VM's globals; a value with no matching global falls back to the LITERAL (benilla's own
/// divergence, so its plain-English FrameXML keeps working); and a **key-shaped** miss warns,
/// which is the tripwire that "CREATE_MACROS" across a title bar never had.
#[test]
fn a_text_attribute_resolves_through_the_global_strings() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
// The reference boots GlobalStrings.lua before any XML; so does the app.
s.run(r#"DELETE = "Delete" EXIT_GAME = "Exit Game" TITLE = "The Title""#)
.unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Holder">
<Layers>
<Layer level="ARTWORK">
<FontString name="$parentTitle" text="TITLE"/>
<FontString name="$parentProse" text="No results found."/>
</Layer>
</Layers>
<Frames>
<Button name="$parentDelete" text="DELETE"/>
<Button name="$parentQuit">
<ButtonText name="$parentText" text="EXIT_GAME"/>
</Button>
<Button name="$parentGhost" text="NO_SUCH_KEY"/>
</Frames>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
let texts: Vec<String> = s
.eval(
"return HolderTitle:GetText(), HolderProse:GetText(), HolderDelete:GetText(), \
HolderQuit:GetText(), HolderGhost:GetText()",
)
.map(|(a, b, c, d, e): (String, String, String, String, String)| vec![a, b, c, d, e])
.unwrap();
assert_eq!(
texts,
vec![
// The key's VALUE, on all three element shapes…
"The Title",
// …a non-key literal untouched (the divergence that keeps our own XML working)…
"No results found.",
"Delete",
"Exit Game",
// …and a key-shaped MISS keeps its key on screen rather than blanking.
"NO_SUCH_KEY",
]
);
// Exactly one warning, and it names the miss — the plain-English literal must not warn.
assert_eq!(
report
.warnings
.iter()
.filter(|w| w.contains("GlobalStrings key"))
.count(),
1,
"warnings: {:?}",
report.warnings
);
assert!(report.warnings.iter().any(|w| w.contains("NO_SUCH_KEY")));
}
/// The key SHAPE test itself: `SCREAMING_SNAKE`, two characters or more. The floor is what
/// keeps a close button's `text="X"` from being reported as a missing string.
#[test]
fn key_shape_is_screaming_snake_of_two_or_more() {
for yes in ["DELETE", "EXIT_GAME", "CHARACTER_POINTS1_COLON", "AB", "A1"] {
assert!(is_global_string_key(yes), "{yes} is key-shaped");
}
for no in ["X", "", "Send Mail", "No results found.", "Okay", "1", "12"] {
assert!(!is_global_string_key(no), "{no} is not key-shaped");
}
}
}
mod layer_blend_tests {
use crate::framexml::parse;
use crate::loader::*;
use crate::script::{QuadContent, UiScript};
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
/// `alphaMode="ADD"` on a `<Layers>` texture reaches the extracted quad's blend flag — the
/// state-texture path always applied it, the layer path silently dropped it (the quest log's
/// selection glow rendered as an opaque gray bar — the 0109 look fix).
#[test]
fn layers_texture_alpha_mode_add_reaches_the_quad() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="GlowHost">
<Size><AbsDimension x="100" y="30"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="GlowTex" file="Interface\Glow" alphaMode="ADD">
<Anchors><Anchor point="TOPLEFT"/><Anchor point="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
let mut s = s;
s.set_screen_size(800.0, 600.0);
s.resolve();
let additive = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
additive,
..
} if p == "Interface\\Glow" => Some(*additive),
_ => None,
})
.expect("the layer texture extracted");
assert!(additive, "alphaMode=ADD must ride into the quad blend flag");
}
}
mod region_template_tests {
use crate::framexml::parse;
use crate::loader::*;
use crate::script::{QuadContent, UiScript};
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
/// A `<Texture inherits="…">` layer region splices its virtual template — file, `<Size>`,
/// `<Anchors>` — with the instance's own nodes winning (the talent branch/arrow art pool's
/// shape; before this, the template was silently ignored and every pooled texture sat at
/// 0×0 with no file: shown, positioned by the runtime SetPoint, and invisible).
#[test]
fn layers_texture_inherits_a_virtual_texture_template() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Texture name="BranchTemplate" file="Interface\Branches" virtual="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Frame name="Host">
<Size><AbsDimension x="300" y="300"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="BACKGROUND">
<Texture name="Branch1" inherits="BranchTemplate"/>
<Texture name="Branch2" inherits="BranchTemplate">
<Size><AbsDimension x="64" y="16"/></Size>
</Texture>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
report.warnings.is_empty(),
"a registered region template must splice silently: {:?}",
report.warnings
);
let mut s = s;
s.set_screen_size(800.0, 600.0);
// The template's size/file landed; the instance's own <Size> wins over the template's.
assert_eq!(
s.eval::<(f64, f64)>("return Branch1:GetWidth(), Branch1:GetHeight()")
.unwrap(),
(32.0, 32.0)
);
assert_eq!(
s.eval::<(f64, f64)>("return Branch2:GetWidth(), Branch2:GetHeight()")
.unwrap(),
(64.0, 16.0)
);
s.resolve();
let branch_quads = s
.extract()
.iter()
.filter(|q| {
matches!(
&q.content,
QuadContent::Texture { path: Some(p), .. } if p == "Interface\\Branches"
)
})
.count();
assert_eq!(
branch_quads, 2,
"both templated textures extract with the template's file"
);
}
/// A `<FontString inherits="GameFontNormal">` names a FONT OBJECT, not an element template —
/// the region-template gate must pass it through untouched (no "unknown template" warning).
#[test]
fn fontstring_font_object_inherits_stays_out_of_the_template_path() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="12"/></FontHeight>
</Font>
<Frame name="Host2">
<Size><AbsDimension x="100" y="30"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
<FontString name="Label" inherits="GameFontNormal" text="hello"/>
</Layer></Layers>
</Frame>
</Ui>"#,
)
.unwrap();
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
report.warnings.is_empty(),
"a font-object inherits must not warn as an unknown template: {:?}",
report.warnings
);
assert_eq!(s.eval::<String>("return Label:GetText()").unwrap(), "hello");
}
}
/// Chunk naming — which FILE and which LINE a `<Script>` raise reports (decision 1217).
mod chunk_name_tests {
use crate::framexml;
use crate::loader::*;
use crate::script::UiScript;
fn no_files(_: &str) -> Option<Vec<u8>> {
None
}
fn parse(text: &str) -> framexml::ParsedDocument {
framexml::parse(text).expect("valid FrameXML")
}
/// **A raise inside a `<Script>` block names the file it came from, and the file's own line.**
///
/// Both halves are load-bearing and both were wrong. `Loader::run` loaded every chunk with no
/// name, and mlua's `load` is `#[track_caller]` — so the chunk was named after the *Rust* line
/// that ran it, and a corpus read-back of 70 failures had 26 rows saying
/// `crates/benilla-ui/src/loader/mod.rs:327` where the addon's file belonged (decision 1217).
///
/// Naming it is only half. A Lua chunk starts at line 1, so an inline block 40 lines down a
/// file would report `File.xml:3` for its third line — a lie you can go and check, which is
/// strictly worse than the Rust path that was at least obviously not yours. The chunk is padded
/// to the block's own offset, so the number is the number you scroll to.
#[test]
fn a_script_raise_names_its_own_file_and_line() {
let s = UiScript::new().unwrap();
// `error()` with no level prefixes "chunkname:line:" — exactly what a real raise carries.
let doc = parse(
"<Ui>\n\
<Frame name=\"Pad\"/>\n\
<Script>\n\
local x\n\
error(\"boom\")\n\
</Script>\n\
</Ui>",
);
let report = load_in(&s, &doc, "Bagnon/src/main.xml", &no_files);
let err = report.errors.join("\n");
// The file, in the shape an addon's own `debugstack()` matches (backslashes, no `@`).
assert!(
err.contains("Bagnon\\src\\main.xml:"),
"the raise must name the document, got: {err}"
);
// Line 5 of the literal above is `error("boom")` — the body starts on line 4 and the
// padding carries it there. Without the pad this reads `:2:`.
assert!(
err.contains("main.xml:5:"),
"the line must be the FILE's line, not the block's, got: {err}"
);
}
/// The same, one level down: an `<Include>`d document's inline script names **its own** file,
/// not the includer's. The path is saved and restored around the recursion exactly as the
/// directory used to be, so depth-1 and depth-5 each report themselves.
#[test]
fn an_included_documents_script_names_the_included_file() {
let s = UiScript::new().unwrap();
let inner = "<Ui>\n<Script>\nerror(\"inner\")\n</Script>\n</Ui>";
let files = |req: &str| -> Option<Vec<u8>> {
(req == "Addon/sub/inner.xml").then(|| inner.as_bytes().to_vec())
};
let doc = parse("<Ui>\n<Include file=\"sub\\inner.xml\"/>\n</Ui>");
let report = load_in(&s, &doc, "Addon/outer.xml", &files);
let err = report.errors.join("\n");
assert!(
err.contains("Addon\\sub\\inner.xml:3:"),
"the INCLUDED file and its line, not the includer's: {err}"
);
assert!(
!err.contains("outer.xml"),
"the includer must not be blamed: {err}"
);
}
/// **The CDATA shape, which is the only shape our own FrameXML uses.** `<![CDATA[` sits
/// between the element's start and the body's first byte, so taking the line from the
/// *element* would be short by however much of the opening tag precedes it — and a
/// single-line-offset lie is the hardest kind to notice. The line comes from the text child's
/// own range instead, which is what this pins.
#[test]
fn a_cdata_script_block_reports_the_files_line() {
let s = UiScript::new().unwrap();
let doc = parse(
"<Ui>\n\
<Frame name=\"A\"/>\n\
<Frame name=\"B\"/>\n\
<Script><![CDATA[\n\
local ok = 1\n\
error(\"cdata boom\")\n\
]]></Script>\n\
</Ui>",
);
let report = load_in(&s, &doc, "Ours/Bag.xml", &no_files);
let err = report.errors.join("\n");
assert!(
err.contains("Ours\\Bag.xml:6:"),
"line 6 is `error(\"cdata boom\")` in the literal above, got: {err}"
);
}
/// A `<Script file=>` chunk is named after the file it loaded, not the document that named it.
#[test]
fn a_script_file_chunk_is_named_after_that_file() {
let s = UiScript::new().unwrap();
let files = |req: &str| -> Option<Vec<u8>> {
(req == "Addon/code.lua").then(|| b"\nerror(\"from lua\")\n".to_vec())
};
let doc = parse("<Ui>\n<Script file=\"code.lua\"/>\n</Ui>");
let report = load_in(&s, &doc, "Addon/host.xml", &files);
let err = report.errors.join("\n");
assert!(
err.contains("Addon\\code.lua:2:"),
"the .lua file and its own line: {err}"
);
}
/// **A captured script, called back the reference's way, still knows its frame.**
///
/// This is *the* 1.12 hook idiom and the whole reason `GetScript` exists: an addon takes the
/// client's handler, installs its own, and calls the original with **no arguments**, because
/// the reference's contract passes the frame as the `this` global and nothing else. Our
/// wrapper is `function(self, ...)` and our own FrameXML is written against `self`, so before
/// the fallback in [`super::Loader::compile_handler`] that re-entry handed every body a nil
/// frame. The director met it as `bad argument #2: error converting Lua nil to table` out of
/// `GameTooltip:SetOwner`, from a handler that works perfectly when the engine calls it.
///
/// Both spellings are asserted, because the fallback must not cost the engine's own call.
#[test]
fn a_script_captured_and_called_with_no_arguments_still_sees_its_frame() {
let s = UiScript::new().unwrap();
let doc = parse(
r#"<Ui>
<Frame name="Hooked" hidden="true">
<Scripts>
<OnShow>SEEN = self:GetName()</OnShow>
</Scripts>
</Frame>
</Ui>"#,
);
let report = load_in(&s, &doc, "Test.xml", &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// The engine's own call: `self` is passed and `this` is set — the modern spelling.
s.run("SEEN = nil Hooked:Show()").unwrap();
assert_eq!(s.eval::<String>("return SEEN").unwrap(), "Hooked");
// The addon's: capture the script, then call it back bare with only `this` standing in for
// the frame — exactly `Bagnon.lua:87`'s `bMainBag_OnEnter()`.
s.run(
"SEEN = nil \
local original = Hooked:GetScript(\"OnShow\") \
this = Hooked \
original() \
this = nil",
)
.unwrap();
assert_eq!(
s.eval::<String>("return SEEN").unwrap(),
"Hooked",
"the reference's own no-argument contract must reach the same frame"
);
}
}
/// [`super::join_ref`] — the FrameXML path rule (decision 1186).
///
/// A reference is relative to the directory of the file containing it, `\` and `/` are the same
/// separator, and `..` walks up. The escape case is the load-bearing one: a `..` above the root is
/// KEPT as a leading `..` rather than clamped away, because the provider has to be able to SEE the
/// escape to refuse it. Clamping would silently hand it a path that looks contained.
#[test]
fn join_ref_resolves_relative_framexml_paths() {
use super::join_ref;
// Relative to the including file's own directory.
assert_eq!(
join_ref("Bagnon/src", "templates.xml"),
"Bagnon/src/templates.xml"
);
// Backslashes are separators, and `..` walks up — this is how a library addon is reached.
assert_eq!(
join_ref("Bagnon/src", "..\\..\\BagBrother\\core\\core.xml"),
"BagBrother/core/core.xml"
);
// At the root, a bare name stays bare (the builtin's flat tree).
assert_eq!(join_ref("", "Fonts.xml"), "Fonts.xml");
// `.` is a no-op; redundant separators collapse.
assert_eq!(join_ref("a/b", "./c//d.xml"), "a/b/c/d.xml");
// An escape above the root SURVIVES, so the provider can refuse it.
assert_eq!(join_ref("a", "../../secret"), "../secret");
assert_eq!(join_ref("", "../secret"), "../secret");
// A leading `/` re-roots rather than meaning the filesystem root.
assert_eq!(join_ref("a/b", "/c.xml"), "c.xml");
}