`framexml::merge` merged the inherited template's `name` onto the instance, so an unnamed child of a named virtual template took the template's name. ItemRack's `<Frame inherits="ItemRackQueueTemplate"/>` then named its texture after the template instead of the parent frame, and `Rack.OnEvent` indexed the missing `ItemRackInv*Queue` global. The reference reads `name` and `id` from the instance alone (`PreLoadXML 0x769770`); drop both from the merge base.
2505 lines
100 KiB
Rust
2505 lines
100 KiB
Rust
//! Loader tests.
|
||
|
||
mod loader_tests {
|
||
use crate::framexml;
|
||
use crate::loader::*;
|
||
use crate::order::ZTarget;
|
||
use crate::script::{QuadContent, UiScript};
|
||
|
||
fn no_files(_: &str) -> Option<Vec<u8>> {
|
||
None
|
||
}
|
||
|
||
fn parse(text: &str) -> framexml::ParsedDocument {
|
||
framexml::parse(text).expect("valid FrameXML")
|
||
}
|
||
|
||
/// `CMinimap::LoadXML 0x4ee2b0`: `minimapArrowModel` goes to engine children 1-8 (`0x4ee170`),
|
||
/// `minimapPlayerModel` to child 9 alone (`[Minimap+0x338]`, `0x4ee260`); without the
|
||
/// attributes the defaults are `0x84c768` and `0x8453c0`. `Minimap.xml:97` sets both.
|
||
#[test]
|
||
fn the_minimap_model_attributes_name_the_nine_engine_children() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Minimap name="Minimap"
|
||
minimapArrowModel="Interface\Minimap\Rotating-MinimapArrow.mdl"
|
||
minimapPlayerModel="Interface\Minimap\MinimapArrow.mdl">
|
||
<Size><AbsDimension x="140" y="140"/></Size>
|
||
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
|
||
<Frames>
|
||
<Frame name="MiniMapPingHost"/>
|
||
</Frames>
|
||
</Minimap>
|
||
<Minimap name="BareMinimap">
|
||
<Size><AbsDimension x="140" y="140"/></Size>
|
||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||
</Minimap>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
|
||
for i in 1..=8 {
|
||
assert_eq!(
|
||
s.eval::<String>(&format!(
|
||
"return ({{Minimap:GetChildren()}})[{i}]:GetModel()"
|
||
))
|
||
.unwrap(),
|
||
"Interface\\Minimap\\Rotating-MinimapArrow.mdl",
|
||
"engine child {i} takes minimapArrowModel"
|
||
);
|
||
}
|
||
assert_eq!(
|
||
s.eval::<String>("return ({Minimap:GetChildren()})[9]:GetModel()")
|
||
.unwrap(),
|
||
"Interface\\Minimap\\MinimapArrow.mdl",
|
||
"child 9 — [Minimap+0x338] — takes minimapPlayerModel alone"
|
||
);
|
||
|
||
// The `<Frames>` child comes after the nine: the factory runs before the descent
|
||
// (`0x6ee408` before `0x6ee4ea`) and both append at the tail.
|
||
assert_eq!(
|
||
s.eval::<String>("return ({Minimap:GetChildren()})[10]:GetName()")
|
||
.unwrap(),
|
||
"MiniMapPingHost"
|
||
);
|
||
|
||
assert_eq!(
|
||
s.eval::<String>("return ({BareMinimap:GetChildren()})[1]:GetModel()")
|
||
.unwrap(),
|
||
crate::widget::MINIMAP_DEFAULT_ARROW_MODEL
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return ({BareMinimap:GetChildren()})[9]:GetModel()")
|
||
.unwrap(),
|
||
crate::widget::MINIMAP_DEFAULT_PLAYER_MODEL
|
||
);
|
||
}
|
||
|
||
/// The stock FrameXML attaches with `parent=`; a `relativeTo`-less anchor measures from it.
|
||
#[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"
|
||
);
|
||
|
||
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
|
||
);
|
||
|
||
// A global that is not a frame is the same miss, not a raise.
|
||
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
|
||
);
|
||
}
|
||
|
||
/// A miss stores 0 over the default parent (`0x6ee3ef`), so the frame is built parentless; an
|
||
/// empty `parent=""` keeps the default silently (`0x6ee3c7`).
|
||
#[test]
|
||
fn a_parent_attribute_that_misses_leaves_the_frame_parentless() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="Enclosing">
|
||
<Size><AbsDimension x="200" y="100"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
<Frames>
|
||
<Frame name="$parentMissed" parent="NotLoadedYet">
|
||
<Size><AbsDimension x="10" y="10"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
</Frame>
|
||
<Frame name="$parentEmpty" parent="">
|
||
<Size><AbsDimension x="10" y="10"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
</Frame>
|
||
</Frames>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
s.resolve();
|
||
|
||
assert!(s.eval::<bool>("return TopMissed ~= nil").unwrap());
|
||
assert!(
|
||
s.eval::<bool>("return TopMissed:GetParent() == nil")
|
||
.unwrap(),
|
||
"a parent= that names nothing nulls the parent; it does not fall back"
|
||
);
|
||
// With no parent, `$parent` falls to the `"Top"` seed (`0x76c5b0`): `TopMissed`.
|
||
assert!(s.eval::<bool>("return EnclosingMissed == nil").unwrap());
|
||
assert!(
|
||
report
|
||
.warnings
|
||
.iter()
|
||
.any(|w| w == "Couldn't find frame parent: NotLoadedYet"),
|
||
"logged in the reference's own words (0x8710f0): {:?}",
|
||
report.warnings
|
||
);
|
||
|
||
assert_eq!(
|
||
s.eval::<String>("return EnclosingEmpty:GetParent():GetName()")
|
||
.unwrap(),
|
||
"Enclosing",
|
||
"parent=\"\" keeps the enclosing parent"
|
||
);
|
||
assert!(
|
||
!report.warnings.iter().any(|w| w.contains("Empty")),
|
||
"and says nothing about it: {:?}",
|
||
report.warnings
|
||
);
|
||
}
|
||
|
||
/// The reference attaches the parent (`0x6ee408`) before it applies `name=` (`0x6ee4d6`), and
|
||
/// `$parent` walks the real parent chain (`0x76c5b0`); `parent=` itself is never expanded.
|
||
#[test]
|
||
fn a_dollar_parent_name_resolves_against_the_parent_attribute() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="PlayerFrame">
|
||
<Size><AbsDimension x="200" y="100"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
</Frame>
|
||
<Frame name="$parentClassIcon" parent="PlayerFrame">
|
||
<Size><AbsDimension x="20" y="20"/></Size>
|
||
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
|
||
<Frames>
|
||
<Frame name="$parentDot">
|
||
<Size><AbsDimension x="4" y="4"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
</Frame>
|
||
</Frames>
|
||
</Frame>
|
||
<Frame name="$parentLoose">
|
||
<Size><AbsDimension x="10" y="10"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
</Frame>
|
||
<Frame name="Literal" parent="$parentPlayerFrame">
|
||
<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!(
|
||
s.eval::<bool>("return PlayerFrameClassIcon ~= nil")
|
||
.unwrap(),
|
||
"the top-level $parent name took the parent= frame's name, not the lexical \"Top\""
|
||
);
|
||
assert!(s.eval::<bool>("return TopClassIcon == nil").unwrap());
|
||
assert_eq!(
|
||
s.eval::<String>("return PlayerFrameClassIcon:GetParent():GetName()")
|
||
.unwrap(),
|
||
"PlayerFrame",
|
||
"and it is really attached there"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return PlayerFrameClassIconDot ~= nil")
|
||
.unwrap(),
|
||
"a nested child composes off the CORRECTED name"
|
||
);
|
||
|
||
// No attribute, no lexical parent: the `"Top"` seed.
|
||
assert!(s.eval::<bool>("return TopLoose ~= nil").unwrap());
|
||
|
||
// `parent=` is taken literally (`0x6ee3e8` hands it to `0x76c760`), so this one misses.
|
||
assert!(s.eval::<bool>("return Literal:GetParent() == nil").unwrap());
|
||
assert!(
|
||
report
|
||
.warnings
|
||
.iter()
|
||
.any(|w| w.contains("$parentPlayerFrame")),
|
||
"the unexpanded attribute is named verbatim in the warning: {:?}",
|
||
report.warnings
|
||
);
|
||
}
|
||
|
||
/// Names, `$parent`, bottom-up `OnLoad` and the anchored texture rect, end to end.
|
||
#[test]
|
||
fn synthetic_full_document_materializes() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
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");
|
||
|
||
assert!(s.eval::<bool>("return MyFrame ~= nil").unwrap());
|
||
assert!(s.eval::<bool>("return MyFrameChild ~= nil").unwrap());
|
||
assert!(s.eval::<bool>("return MyFrameTex ~= nil").unwrap());
|
||
|
||
// The child's handler uses `self`, the parent's `this`; 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 (`0x76a060`).
|
||
let order: Vec<String> = s.eval("return loadorder").unwrap();
|
||
assert_eq!(order, vec!["child".to_string(), "parent".to_string()]);
|
||
|
||
// TOPLEFT+(10,-20), 200x100 on 800x600: bottom 480, left 10, top 580, right 210.
|
||
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))
|
||
);
|
||
}
|
||
|
||
/// Expansion appends the instance's children after the template's and every `<Size>` applies
|
||
/// in order, as the client processes each child, so the last wins.
|
||
#[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 region's `inherits=` is one name matched case-insensitively (`0x6ee747`), as a frame's
|
||
/// is, so a mis-cased template name still reaches the template.
|
||
#[test]
|
||
fn a_region_takes_a_mis_cased_template() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Texture name="MarkTemplate" virtual="true">
|
||
<Size><AbsDimension x="32" y="16"/></Size>
|
||
</Texture>
|
||
<Frame name="MarkHost">
|
||
<Layers><Layer level="ARTWORK">
|
||
<Texture name="$parentMark" inherits="marktemplate"/>
|
||
</Layer></Layers>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
assert_eq!(
|
||
s.eval::<f32>("return MarkHostMark:GetWidth()").unwrap(),
|
||
32.0
|
||
);
|
||
}
|
||
|
||
#[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>` recurses into `0x6ede10`, which runs a path with a case-insensitive `.lua`
|
||
/// suffix and parses anything else as XML; it never sniffs content.
|
||
#[test]
|
||
fn include_runs_a_lua_target_and_still_parses_an_xml_one() {
|
||
let s = UiScript::new().unwrap();
|
||
let provider = |path: &str| -> Option<Vec<u8>> {
|
||
match path {
|
||
"libs/Lib.lua" => Some(b"IncludedLua = 41 + 1".to_vec()),
|
||
// Case-insensitive, exactly as `0x64a4c0` compares it.
|
||
"libs/Shouty.LUA" => Some(b"ShoutyLua = true".to_vec()),
|
||
"Sub.xml" => Some(br#"<Ui><Frame name="FromXmlInclude"/></Ui>"#.to_vec()),
|
||
_ => None,
|
||
}
|
||
};
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Include file="libs\Lib.lua"/>
|
||
<Include file="libs\Shouty.LUA"/>
|
||
<Include file="Sub.xml"/>
|
||
<Frame name="FromMain"/>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &provider);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
assert_eq!(
|
||
s.eval::<i64>("return IncludedLua").unwrap(),
|
||
42,
|
||
"a .lua Include must be executed as a chunk, not parsed as a document"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return ShoutyLua").unwrap(),
|
||
"case-insensitive suffix"
|
||
);
|
||
assert_eq!(report.frames, 2);
|
||
assert!(s
|
||
.eval::<bool>("return FromXmlInclude ~= nil and FromMain ~= nil")
|
||
.unwrap());
|
||
}
|
||
|
||
/// The reference logs the raise and falls through unconditionally (`0x6ee00d`-`0x6ee012`).
|
||
#[test]
|
||
fn a_raising_lua_include_does_not_take_the_rest_of_the_document() {
|
||
let s = UiScript::new().unwrap();
|
||
let provider = |path: &str| -> Option<Vec<u8>> {
|
||
match path {
|
||
"Bad.lua" => Some(b"error('library exploded')".to_vec()),
|
||
_ => None,
|
||
}
|
||
};
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Include file="Bad.lua"/>
|
||
<Frame name="AfterTheBadInclude"/>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &provider);
|
||
assert_eq!(
|
||
report.errors.len(),
|
||
1,
|
||
"the raise is reported: {:?}",
|
||
report.errors
|
||
);
|
||
assert!(report.errors[0].contains("Bad.lua"));
|
||
assert!(
|
||
s.eval::<bool>("return AfterTheBadInclude ~= nil").unwrap(),
|
||
"the element after a failed Include must still be built"
|
||
);
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
|
||
/// `CSimpleFrame::LoadXML 0x769820`: a strata `0x6f17d0` does not know logs at severity 1
|
||
/// (`0x7699a4`) and skips `SetFrameStrata 0x76a470`.
|
||
#[test]
|
||
fn an_unknown_xml_frame_strata_warns_and_leaves_the_stratum_alone() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="Bad" frameStrata="ARTWORK"/>
|
||
<Frame name="After"/>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"nothing raised on the XML door: {:?}",
|
||
report.errors
|
||
);
|
||
assert!(
|
||
report
|
||
.warnings
|
||
.iter()
|
||
.any(|w| w.contains("Unknown frame strata") && w.contains("ARTWORK")),
|
||
"…and it is reported: {report:?}"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return Bad:GetFrameStrata()").unwrap(),
|
||
"MEDIUM",
|
||
"the frame keeps what it had — the ctor's MEDIUM ([+0xc0] = 3) in the base case"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return After ~= nil").unwrap(),
|
||
"and the document carries on"
|
||
);
|
||
}
|
||
|
||
/// The Lua `SetFrameStrata 0x774360` raises on the same value (`luaL_error` at `0x774456`).
|
||
#[test]
|
||
fn the_lua_setframestrata_still_raises_on_the_same_value() {
|
||
let s = UiScript::new().unwrap();
|
||
s.run(r#"f = CreateFrame("Frame", "Loud")"#).unwrap();
|
||
let err = s
|
||
.run(r#"f:SetFrameStrata("ARTWORK")"#)
|
||
.unwrap_err()
|
||
.to_string();
|
||
assert!(err.contains("ARTWORK"), "{err}");
|
||
}
|
||
|
||
/// The reference sets the flag from a number, `0` included, and only reads it for any other
|
||
/// argument (`0x48845d`, after `lua_isnumber 0x6f34d0`).
|
||
#[test]
|
||
fn framexml_debug_is_a_get_or_set_and_gates_the_loader_traces() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = || parse(r#"<Ui><Frame name="Traced"/></Ui>"#);
|
||
|
||
assert_eq!(
|
||
s.eval::<i32>("return FrameXML_Debug()").unwrap(),
|
||
0,
|
||
"boots 0"
|
||
);
|
||
assert!(
|
||
load(&s, &doc(), &no_files).traces.is_empty(),
|
||
"and off means no trace at all"
|
||
);
|
||
|
||
assert_eq!(s.eval::<i32>("return FrameXML_Debug(1)").unwrap(), 1);
|
||
let on = load(&s, &doc(), &no_files);
|
||
assert!(
|
||
on.traces
|
||
.iter()
|
||
.any(|t| t.contains("Creating Frame named Traced")),
|
||
"{on:?}"
|
||
);
|
||
|
||
assert_eq!(
|
||
s.eval::<i32>("return FrameXML_Debug(nil)").unwrap(),
|
||
1,
|
||
"nil is a pure GET — the flag is untouched"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i32>("return FrameXML_Debug(0)").unwrap(),
|
||
0,
|
||
"…but 0 is TRUTHY in Lua, so it really does disable it"
|
||
);
|
||
assert!(load(&s, &doc(), &no_files).traces.is_empty());
|
||
// The stored value truncates toward zero (`0x40a2b0`).
|
||
assert_eq!(s.eval::<i32>("return FrameXML_Debug(1.9)").unwrap(), 1);
|
||
}
|
||
|
||
/// The reference logs `Couldn't open %s` (`0x6edaa0`, `0x846ff4`) and carries on.
|
||
#[test]
|
||
fn missing_include_is_a_missing_file_not_an_error_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.missing_files.iter().any(|e| e.contains("Nope.xml")),
|
||
"an unresolved include drops a whole document and says so: {report:?}"
|
||
);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"…but nothing raised, so it is not a script error: {:?}",
|
||
report.errors
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return Still ~= nil").unwrap(),
|
||
"and the load continues past it"
|
||
);
|
||
}
|
||
|
||
/// The reference logs `"Error loading %s"` (`0x872e50`) for it and returns normally.
|
||
#[test]
|
||
fn missing_script_file_is_a_missing_file_not_an_error_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.missing_files.iter().any(|e| e.contains("Nope.lua")),
|
||
"{report:?}"
|
||
);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
assert!(s.eval::<bool>("return Still ~= nil").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn a_script_file_that_raises_is_still_an_error() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(r#"<Ui><Script file="Boom.lua"/><Frame name="Still"/></Ui>"#);
|
||
let files = |req: &str| -> Option<Vec<u8>> {
|
||
(req == "Boom.lua").then(|| b"error('boom')".to_vec())
|
||
};
|
||
let report = load(&s, &doc, &files);
|
||
assert!(
|
||
report.errors.iter().any(|e| e.contains("Boom.lua")),
|
||
"a chunk that raised is an error, not a missing file: {report:?}"
|
||
);
|
||
assert!(report.missing_files.is_empty(), "{report:?}");
|
||
assert!(s.eval::<bool>("return Still ~= nil").unwrap());
|
||
}
|
||
|
||
#[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);
|
||
assert!(s.eval::<bool>("return PFInner ~= nil").unwrap());
|
||
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
let rects: Vec<_> = quads
|
||
.iter()
|
||
.filter_map(|q| match q.target {
|
||
ZTarget::Frame(_) => q.rect,
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
// The parent is Rect(450,50,550,150); the child, pinned TOPLEFT to TOPLEFT, matches it.
|
||
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:?}"
|
||
);
|
||
}
|
||
|
||
/// The stock trainer row's shape: its label hangs off `$parentHighlight`'s RIGHT
|
||
/// (`ClassTrainerFrameTemplates.xml:67`).
|
||
#[test]
|
||
fn a_named_state_texture_is_an_anchor_target_for_its_sibling_label() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Button name="Row">
|
||
<Size><AbsDimension x="293" y="16"/></Size>
|
||
<Anchors>
|
||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="22" y="-50"/></Offset></Anchor>
|
||
</Anchors>
|
||
<HighlightTexture name="$parentHighlight" file="Interface\Buttons\UI-PlusButton-Hilight">
|
||
<Size><AbsDimension x="16" y="16"/></Size>
|
||
<Anchors>
|
||
<Anchor point="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor>
|
||
</Anchors>
|
||
</HighlightTexture>
|
||
<ButtonText name="$parentText">
|
||
<Size><AbsDimension x="0" y="13"/></Size>
|
||
<Anchors>
|
||
<Anchor point="LEFT" relativeTo="$parentHighlight" relativePoint="RIGHT">
|
||
<Offset><AbsDimension x="2" y="1"/></Offset>
|
||
</Anchor>
|
||
</Anchors>
|
||
</ButtonText>
|
||
<NormalFont inherits="GameFontNormal" justifyH="LEFT"/>
|
||
</Button>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
s.resolve();
|
||
let (hl_right, text_left): (f64, f64) = s
|
||
.eval("return RowHighlight:GetRight(), RowText:GetLeft()")
|
||
.unwrap();
|
||
assert_eq!(hl_right, 22.0 + 3.0 + 16.0);
|
||
assert_eq!(
|
||
text_left,
|
||
hl_right + 2.0,
|
||
"the label hangs off the highlight, not the button"
|
||
);
|
||
assert!(
|
||
report
|
||
.warnings
|
||
.iter()
|
||
.all(|w| !w.contains("does not resolve")),
|
||
"no unresolved relativeTo: {:?}",
|
||
report.warnings
|
||
);
|
||
}
|
||
|
||
#[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
|
||
);
|
||
assert!(s.eval::<bool>("return Broken ~= nil").unwrap());
|
||
assert!(s.eval::<bool>("return FineRan == true").unwrap());
|
||
}
|
||
|
||
/// `OnAttributeChanged` is 2.0's; no 1.12 handler resolver has a slot for it.
|
||
#[test]
|
||
fn unsupported_script_name_is_a_warning() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Ui><Frame name="Keyed"><Scripts><OnAttributeChanged>x = 1</OnAttributeChanged></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("OnAttributeChanged")));
|
||
}
|
||
|
||
/// `Instantiate 0x6ee280` logs `"Unknown frame type: %s"` (`0x871124`) and builds nothing for
|
||
/// the node or its `<Frames>`; only the Lua `CreateFrame` raises (`0x872fa8`).
|
||
#[test]
|
||
fn an_unknown_xml_frame_type_is_logged_and_its_node_skipped() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Bogus name="X"><Frames><Frame name="Inside"/></Frames></Bogus>
|
||
<Frame name="Real"/>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(
|
||
report.errors.is_empty(),
|
||
"nothing raised on the XML door: {:?}",
|
||
report.errors
|
||
);
|
||
assert!(
|
||
report
|
||
.warnings
|
||
.iter()
|
||
.any(|w| w == "Unknown frame type: Bogus"),
|
||
"the reference's own wording, in the log channel: {:?}",
|
||
report.warnings
|
||
);
|
||
assert_eq!(report.frames, 1, "only the real frame built");
|
||
assert!(s.eval::<bool>("return X == nil and Inside == nil").unwrap());
|
||
assert!(s.eval::<bool>("return Real ~= nil").unwrap());
|
||
// The Lua door raises on the same lookup.
|
||
let err = s.run(r#"CreateFrame("Bogus")"#).unwrap_err().to_string();
|
||
assert!(err.contains("unknown frame type 'Bogus'"), "{err}");
|
||
}
|
||
|
||
/// A `.toc` line hands a listed `Bindings.xml` to `0x6ede10` (from `0x6edd51`), so each
|
||
/// `<Binding>` logs as an unknown frame type; `0x51f400` reads the bindings regardless.
|
||
#[test]
|
||
fn a_bindings_document_loaded_as_framexml_raises_nothing() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Bindings>
|
||
<Binding name="MONKEYDEV_STEPUP" header="MONKEYDEV">MonkeyStep_Inc()</Binding>
|
||
<Binding name="MONKEYDEV_STEPDOWN">MonkeyStep_Dec()</Binding>
|
||
</Bindings>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
assert_eq!(
|
||
report
|
||
.warnings
|
||
.iter()
|
||
.filter(|w| *w == "Unknown frame type: Binding")
|
||
.count(),
|
||
2,
|
||
"one log line per <Binding>: {:?}",
|
||
report.warnings
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return MONKEYDEV_STEPUP == nil").unwrap(),
|
||
"and no frame published under a binding's name"
|
||
);
|
||
}
|
||
|
||
/// The WorldFrame type is one-shot, so a second `<WorldFrame>` is an unknown type.
|
||
#[test]
|
||
fn a_second_xml_world_frame_is_logged_not_raised() {
|
||
let s = UiScript::new().unwrap();
|
||
s.run(r#"CreateFrame("WorldFrame", "WorldFrame")"#).unwrap();
|
||
let report = load(
|
||
&s,
|
||
&parse(r#"<Ui><WorldFrame name="Another"/></Ui>"#),
|
||
&no_files,
|
||
);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
assert!(report
|
||
.warnings
|
||
.iter()
|
||
.any(|w| w == "Unknown frame type: WorldFrame"));
|
||
assert!(s.eval::<bool>("return Another == nil").unwrap());
|
||
}
|
||
|
||
/// `<StatusBar>` LoadXML extras (`0x782ef0`).
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
/// An anchorless `<Texture>` gets `SetAllPoints` after its LoadXML (`0x7701c0`), so its
|
||
/// `<Size>` goes unread: `StackSplitFrame.xml:10` authors 256×32 and draws 172×96.
|
||
#[test]
|
||
fn sized_anchorless_layer_texture_fills_its_frame() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="Plate">
|
||
<Size><AbsDimension x="172" y="96"/></Size>
|
||
<Anchors>
|
||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="10" y="10"/></Offset></Anchor>
|
||
</Anchors>
|
||
<Layers><Layer level="BACKGROUND">
|
||
<Texture file="Interface\Panel">
|
||
<Size><AbsDimension x="256" y="32"/></Size>
|
||
</Texture>
|
||
</Layer></Layers>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
s.resolve();
|
||
let plate = s
|
||
.extract()
|
||
.into_iter()
|
||
.find(|q| {
|
||
matches!(&q.content,
|
||
QuadContent::Texture { path: Some(p), .. } if p == "Interface\\Panel")
|
||
})
|
||
.expect("the plate draws");
|
||
let r = plate.rect.expect("the plate resolved");
|
||
assert_eq!(
|
||
(r.left, r.right, r.bottom, r.top),
|
||
(10.0, 182.0, 10.0, 106.0),
|
||
"implicit SetAllPoints fills the 172×96 frame; the 256×32 size is unread"
|
||
);
|
||
}
|
||
|
||
/// A FontString with no anchors gets one middle-row point from its justify word (`&7`: 1 LEFT,
|
||
/// 4 RIGHT, else CENTER), so unlike a texture's its `<Size>` stays live.
|
||
#[test]
|
||
fn anchorless_fontstring_seats_at_its_justify_point() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="Page">
|
||
<Size><AbsDimension x="200" y="100"/></Size>
|
||
<Anchors>
|
||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||
</Anchors>
|
||
<Layers><Layer level="ARTWORK">
|
||
<FontString name="SeatLeft" justifyH="LEFT" text="body">
|
||
<Size><AbsDimension x="80" y="20"/></Size>
|
||
</FontString>
|
||
</Layer></Layers>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
s.resolve();
|
||
// LEFT to LEFT on the page's centreline, 80×20 from the size: [0,40]..[80,60].
|
||
let (l, r, t, b) = s
|
||
.eval::<(f64, f64, f64, f64)>(
|
||
"return SeatLeft:GetLeft(), SeatLeft:GetRight(), SeatLeft:GetTop(), SeatLeft:GetBottom()",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
(l, r, b, t),
|
||
(0.0, 80.0, 40.0, 60.0),
|
||
"justifyH=LEFT seats the implicit single anchor at the page's LEFT, size live"
|
||
);
|
||
}
|
||
|
||
/// The setter's implicit `SetAllPoints` is cleared before the authored anchors apply, so no
|
||
/// implicit corner survives to weld the state texture to the button.
|
||
#[test]
|
||
fn anchored_state_texture_keeps_its_authored_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="36" y="36"/></Size>
|
||
<Anchors>
|
||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor>
|
||
</Anchors>
|
||
<NormalTexture file="Interface\Scoped">
|
||
<Size><AbsDimension x="24" y="24"/></Size>
|
||
<Anchors>
|
||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="2" y="-2"/></Offset></Anchor>
|
||
</Anchors>
|
||
</NormalTexture>
|
||
</Button>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
s.resolve();
|
||
let scoped = s
|
||
.extract()
|
||
.into_iter()
|
||
.find(|q| {
|
||
matches!(&q.content,
|
||
QuadContent::Texture { path: Some(p), .. } if p == "Interface\\Scoped")
|
||
})
|
||
.expect("the state texture draws");
|
||
let r = scoped.rect.expect("resolved");
|
||
// Button [100,100]..[136,136]; TOPLEFT+2,-2 with 24×24 → [102,110]..[126,134].
|
||
assert_eq!(
|
||
(r.left, r.right, r.bottom, r.top),
|
||
(102.0, 126.0, 110.0, 134.0),
|
||
"authored anchors + size hold; no implicit corner welds it to the button"
|
||
);
|
||
}
|
||
|
||
/// `0x7025fd` names a handler chunk `"%s:%s"` (`0x872a28`) from the object's `GetName`, or
|
||
/// `<unnamed>` (`0x84c7f0`), and `0x704c70` loads the raw body, so body line n is chunk line n.
|
||
#[test]
|
||
fn an_xml_handler_chunk_is_named_by_its_frame_and_handler() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
"<Ui>\n\
|
||
<Button name=\"NamedBtn\">\n\
|
||
<Scripts>\n\
|
||
<OnClick>\n\
|
||
error(\"boom\")\n\
|
||
</OnClick>\n\
|
||
</Scripts>\n\
|
||
</Button>\n\
|
||
<Button>\n\
|
||
<Scripts>\
|
||
<OnLoad>BenillaAnon = this</OnLoad>\
|
||
<OnClick>error(\"anon\")</OnClick>\
|
||
</Scripts>\n\
|
||
</Button>\n\
|
||
<Button name=\"ProbeTmpl\" virtual=\"true\">\n\
|
||
<Scripts><OnClick>error(\"tmpl\")</OnClick></Scripts>\n\
|
||
</Button>\n\
|
||
</Ui>",
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
s.run(r#"Made = CreateFrame("Button", "Made", nil, "ProbeTmpl")"#)
|
||
.unwrap();
|
||
|
||
let raised = |lua: &str| -> String {
|
||
s.eval::<String>(&format!("local ok, e = pcall({lua}) return tostring(e)"))
|
||
.unwrap()
|
||
};
|
||
|
||
// The `error` is on the body's second line: `:2:`.
|
||
let named = raised(r#"NamedBtn:GetScript("OnClick")"#);
|
||
assert!(
|
||
named.starts_with(r#"[string "NamedBtn:OnClick"]:2: boom"#),
|
||
"{named}"
|
||
);
|
||
// A `CreateFrame` instance is named by its own `GetName()`, not by its template.
|
||
let made = raised(r#"Made:GetScript("OnClick")"#);
|
||
assert!(
|
||
made.starts_with(r#"[string "Made:OnClick"]:1: tmpl"#),
|
||
"{made}"
|
||
);
|
||
let anon = raised(r#"BenillaAnon:GetScript("OnClick")"#);
|
||
assert!(
|
||
anon.starts_with(r#"[string "<unnamed>:OnClick"]:1: anon"#),
|
||
"{anon}"
|
||
);
|
||
}
|
||
|
||
/// An inline `<Script>` is `"%s:<Scripts>"` (`0x871074`, at `0x6ee0ff`) over the document's
|
||
/// path with no `@`, so it reads `[string "…"]`; a `<Script file=>` is `"@%s"` (`0x8716e0`).
|
||
#[test]
|
||
fn an_inline_script_chunk_is_named_for_the_document_not_as_a_file() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
"<Ui>\n<Script file=\"Sibling.lua\"/>\n<Script>\nBenillaInline = ({}).missing.deeper\n</Script>\n</Ui>",
|
||
);
|
||
let report = load_in(&s, &doc, "Interface/AddOns/Probe/Probe.xml", &|p: &str| {
|
||
(p == "Interface/AddOns/Probe/Sibling.lua")
|
||
.then(|| b"BenillaFile = ({}).missing.deeper".to_vec())
|
||
});
|
||
assert_eq!(report.errors.len(), 2, "{:?}", report.errors);
|
||
assert!(
|
||
report.errors[0].contains("Interface\\AddOns\\Probe\\Sibling.lua:1:"),
|
||
"a <Script file=> chunk is still a plain `@`-path frame: {}",
|
||
report.errors[0]
|
||
);
|
||
assert!(
|
||
report.errors[1]
|
||
.contains("[string \"Interface\\AddOns\\Probe\\Probe.xml:<Scripts>\"]:4:"),
|
||
"an inline body is a `[string \"…\"]` frame naming the document: {}",
|
||
report.errors[1]
|
||
);
|
||
}
|
||
|
||
#[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() == 1
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(ok, "button XML attrs + textures landed");
|
||
|
||
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()));
|
||
|
||
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());
|
||
}
|
||
|
||
#[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);
|
||
|
||
// 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 (`0x779fb0`).
|
||
#[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);
|
||
|
||
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 typed char marks the box changed; `OnTextChanged` fires on the next tick's drain.
|
||
s.resolve();
|
||
s.mouse_button(50.0, 10.0, "LeftButton", true);
|
||
s.mouse_button(50.0, 10.0, "LeftButton", false);
|
||
assert_eq!(s.focused_editbox_name().as_deref(), Some("XmlEdit"));
|
||
s.run("typed = false").unwrap();
|
||
assert!(s.char_input("7"));
|
||
s.tick(0.0);
|
||
assert!(s.eval::<bool>("return typed == true").unwrap());
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// The EditBox ctor leaves autoFocus on (`flags = 1`), so the stock UI writes the attribute
|
||
/// only as `autoFocus="false"`: ten times across FrameXML and GlueXML.
|
||
#[test]
|
||
fn an_editbox_flag_written_false_in_xml_turns_the_flag_off() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<EditBox name="OptOut" autoFocus="false"/>
|
||
<EditBox name="OptIn" autoFocus="true"/>
|
||
<EditBox name="Silent"/>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
|
||
// Hidden first so `Show()` is a real transition: an autoFocus box takes focus on show.
|
||
for (name, want) in [("OptOut", false), ("OptIn", true), ("Silent", true)] {
|
||
s.run(&format!(
|
||
"{name}:Hide(); {name}:ClearFocus(); {name}:Show()"
|
||
))
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.focused_editbox_name().as_deref() == Some(name),
|
||
want,
|
||
"{name}: autoFocus should be {want} (absent = the ctor default, ON)",
|
||
);
|
||
s.run(&format!("{name}:ClearFocus()")).unwrap();
|
||
}
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
// ── TexCoords and font objects ──────────────────────────────────────────────────────────────
|
||
|
||
/// The extracted quad carries the UV rect 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
|
||
);
|
||
}
|
||
|
||
#[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: Base's face through Mid, Mid's white, its own height of 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]));
|
||
}
|
||
|
||
#[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"
|
||
);
|
||
|
||
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]
|
||
));
|
||
}
|
||
|
||
#[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 own <Color> wins over the object'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
|
||
));
|
||
assert!(s
|
||
.extract()
|
||
.into_iter()
|
||
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "B")));
|
||
}
|
||
|
||
/// The chat edit box's shape. The engine assigns the text slot from the direct-child
|
||
/// `<FontString>` at LoadXML; it never searches the regions, so the header stays its own.
|
||
#[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);
|
||
|
||
s.run("EB:SetFocus()").unwrap();
|
||
assert!(s.char_input("h"));
|
||
assert!(s.char_input("i"));
|
||
|
||
// Answer every text measure with a stand-in 30×12, then solve again.
|
||
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();
|
||
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");
|
||
|
||
// The header: its own LEFT+13 anchor and the 30×12 extent, centred on the 100..132 box.
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// Stock `TutorialFrame.xml:15`; drags like `CreateTitleRegion():SetAllPoints()` from Lua.
|
||
#[test]
|
||
fn title_region_element_builds_the_drag_handle_over_the_frame() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="Tut" enableMouse="true">
|
||
<Size><AbsDimension x="200" y="80"/></Size>
|
||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
|
||
<TitleRegion setAllPoints="true"/>
|
||
</Frame>
|
||
<Frame name="Plain" enableMouse="true">
|
||
<Size><AbsDimension x="200" y="80"/></Size>
|
||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="400" y="100"/></Offset></Anchor></Anchors>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
let left = |s: &mut UiScript, f: &str| {
|
||
s.resolve();
|
||
s.eval::<f64>(&format!("return {f}:GetLeft()")).unwrap()
|
||
};
|
||
assert_eq!(left(&mut s, "Tut"), 100.0);
|
||
s.mouse_button(150.0, 150.0, "LeftButton", true);
|
||
s.mouse_move(250.0, 150.0);
|
||
assert_eq!(
|
||
left(&mut s, "Tut"),
|
||
200.0,
|
||
"the element's title region drags the frame"
|
||
);
|
||
s.mouse_button(250.0, 150.0, "LeftButton", false);
|
||
|
||
assert_eq!(left(&mut s, "Plain"), 400.0);
|
||
s.mouse_button(450.0, 150.0, "LeftButton", true);
|
||
s.mouse_move(550.0, 150.0);
|
||
assert_eq!(left(&mut s, "Plain"), 400.0, "no element, no handle");
|
||
s.mouse_button(550.0, 150.0, "LeftButton", false);
|
||
}
|
||
|
||
#[test]
|
||
fn a_loader_built_title_region_emits_no_quad() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="Tut" enableMouse="true">
|
||
<Size><AbsDimension x="200" y="80"/></Size>
|
||
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
|
||
<TitleRegion setAllPoints="true"/>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
assert!(
|
||
quads
|
||
.iter()
|
||
.all(|q| matches!(q.target, crate::order::ZTarget::Frame(_))),
|
||
"a title region is a hit rectangle, never a quad: {quads:#?}"
|
||
);
|
||
}
|
||
|
||
/// The stock micro-button: 29x58, `top="18"` insets (`MainMenuBarMicroButtons.xml:9`).
|
||
#[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));
|
||
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 enables the mouse for `OnEnter`, `OnLeave`, `OnMouseDown`,
|
||
/// `OnMouseUp` and `OnDragStart` (`0x769ef0` calls `0x76af00(2,-1)`), which is how the stock
|
||
/// `GameTimeFrame` hovers; the Lua `SetScript` (`0x7748d0`) never does.
|
||
#[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"
|
||
);
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// `CSimpleButton::LoadXML 0x7788c0` builds the label from `<NormalText>` (`0x778b6c`), clears
|
||
/// its `ownsFontAttrs` (`0x778b7b`) and feeds the node to the Normal-state font (`0x783c30`,
|
||
/// at `0x778baf`): the label takes the name and geometry, the state font the `inherits=`.
|
||
#[test]
|
||
fn a_button_normal_text_is_the_label_names_it_and_fonts_it() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Font name="KtmYellow" font="Fonts\FRIZQT__.TTF" virtual="true">
|
||
<FontHeight><AbsValue val="10"/></FontHeight>
|
||
<Color r="0.9" g="0.8" b="0.1" a="1"/>
|
||
</Font>
|
||
<Button name="KtmHeaderTemplate" virtual="true">
|
||
<NormalText name="$parentText" inherits="KtmYellow" text="Threat">
|
||
<Size><AbsDimension x="70" y="14"/></Size>
|
||
<Anchors><Anchor point="LEFT"/></Anchors>
|
||
</NormalText>
|
||
</Button>
|
||
<Frame name="KtmSelf">
|
||
<Size><AbsDimension x="300" y="100"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
<Frames>
|
||
<Button name="$parentHeaderName" inherits="KtmHeaderTemplate">
|
||
<Size><AbsDimension x="70" y="14"/></Size>
|
||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||
</Button>
|
||
</Frames>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
|
||
// 1 · The global, `$parent` resolved against the instance.
|
||
assert!(
|
||
s.eval::<bool>("return KtmSelfHeaderNameText ~= nil")
|
||
.unwrap(),
|
||
"a named <NormalText> must publish its global"
|
||
);
|
||
// 2 · It is the button's label, not a loose FontString with the same name.
|
||
assert_eq!(
|
||
s.eval::<String>("return KtmSelfHeaderNameText:GetName()")
|
||
.unwrap(),
|
||
"KtmSelfHeaderNameText"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return KtmSelfHeaderName:GetFontString():GetName()")
|
||
.unwrap(),
|
||
"KtmSelfHeaderNameText",
|
||
"the <NormalText> region must BE the button's label"
|
||
);
|
||
// 3 · The element's geometry landed on the label.
|
||
assert_eq!(
|
||
s.eval::<f64>("return KtmSelfHeaderNameText:GetWidth()")
|
||
.unwrap(),
|
||
70.0
|
||
);
|
||
// 4 · Its `inherits=` reached the Normal-state font, read off the painted quad because the
|
||
// state font applies at extract.
|
||
s.resolve();
|
||
let label = s
|
||
.extract()
|
||
.into_iter()
|
||
.find_map(|q| match q.content {
|
||
QuadContent::Text {
|
||
text: Some(t),
|
||
color,
|
||
..
|
||
} if t == "Threat" => Some(color),
|
||
_ => None,
|
||
})
|
||
.expect("the <NormalText> label must paint");
|
||
assert_eq!(
|
||
label,
|
||
Some([0.9, 0.8, 0.1, 1.0]),
|
||
"<NormalText inherits=> must reach the button's Normal-state font"
|
||
);
|
||
}
|
||
|
||
/// Only the `<NormalText>` leg clears the label's `+0x12c` gate (`0x778b7b`), so the label's
|
||
/// `LoadXML 0x770f40` skips its font attributes, `justifyH` among them (`0x7710d3`), and
|
||
/// `0x771480` seats the ctor's CENTER (`0x212`, `0x770dd3`). `<ButtonText>` goes through the
|
||
/// ordinary FontString builder (`0x6f2780`) and seats LEFT. The justify still reaches the
|
||
/// paint and `GetJustifyH()` through the state font (`0x783c30`), after the anchor is set.
|
||
#[test]
|
||
fn a_button_label_element_does_not_own_its_justify() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Font name="GatherFont" font="Fonts\FRIZQT__.TTF" virtual="true">
|
||
<FontHeight><AbsValue val="10"/></FontHeight>
|
||
</Font>
|
||
<Button name="GathererUI_PopupButtonTemplate" virtual="true">
|
||
<Size><AbsDimension x="64" y="12"/></Size>
|
||
<NormalText inherits="GatherFont" justifyH="LEFT"/>
|
||
<HighlightText inherits="GatherFont" justifyH="LEFT"/>
|
||
<DisabledText inherits="GatherFont" justifyH="LEFT"/>
|
||
</Button>
|
||
<Button name="OwnLabelTemplate" virtual="true">
|
||
<Size><AbsDimension x="64" y="12"/></Size>
|
||
<ButtonText inherits="GatherFont" justifyH="LEFT"/>
|
||
</Button>
|
||
<Frame name="GathererUI_Popup">
|
||
<Size><AbsDimension x="120" y="60"/></Size>
|
||
<Anchors>
|
||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||
</Anchors>
|
||
<Frames>
|
||
<Button name="GathererUI_PopupButton1" inherits="GathererUI_PopupButtonTemplate" text="Minimap [on]">
|
||
<Anchors><Anchor point="TOP"/></Anchors>
|
||
</Button>
|
||
<Button name="OwnLabelButton" inherits="OwnLabelTemplate" text="Minimap [on]">
|
||
<Anchors><Anchor point="BOTTOM"/></Anchors>
|
||
</Button>
|
||
</Frames>
|
||
</Frame>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
|
||
// 1 · The label's anchor.
|
||
let (point, rel_point, x, y) = s
|
||
.eval::<(String, String, f64, f64)>(
|
||
"local p, rel, rp, x, y = GathererUI_PopupButton1:GetFontString():GetPoint(1) \
|
||
return p, rp, x, y",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
(point.as_str(), rel_point.as_str(), x, y),
|
||
("CENTER", "CENTER", 0.0, 0.0),
|
||
"<NormalText>'s justifyH is disowned, so 0x771480 seats the ctor's CENTER"
|
||
);
|
||
|
||
// 2 · The justify still reaches the label's getter through the state font.
|
||
assert_eq!(
|
||
s.eval::<String>("return GathererUI_PopupButton1:GetFontString():GetJustifyH()")
|
||
.unwrap(),
|
||
"LEFT",
|
||
"the state font's justify still reaches the label"
|
||
);
|
||
|
||
// 3 · The control: `<ButtonText>` keeps its justify, so it seats LEFT.
|
||
let (point, rel_point) = s
|
||
.eval::<(String, String)>(
|
||
"local p, rel, rp = OwnLabelButton:GetFontString():GetPoint(1) return p, rp",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
(point.as_str(), rel_point.as_str()),
|
||
("LEFT", "LEFT"),
|
||
"<ButtonText> owns its font attributes — the gate is the <NormalText> leg's alone"
|
||
);
|
||
|
||
// 4 · Rows 64 wide centred on x=60 span [28,92]: a 40-wide label centred is [40,80],
|
||
// seated LEFT [28,68].
|
||
s.run(
|
||
"GathererUI_PopupButton1:GetFontString():SetWidth(40) \
|
||
OwnLabelButton:GetFontString():SetWidth(40)",
|
||
)
|
||
.unwrap();
|
||
s.resolve();
|
||
let (cl, cr, ll, lr) = s
|
||
.eval::<(f64, f64, f64, f64)>(
|
||
"local c = GathererUI_PopupButton1:GetFontString() \
|
||
local l = OwnLabelButton:GetFontString() \
|
||
return c:GetLeft(), c:GetRight(), l:GetLeft(), l:GetRight()",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
(cl, cr),
|
||
(40.0, 80.0),
|
||
"the Gatherer row's label is centred on the button, not hugging its left edge"
|
||
);
|
||
assert_eq!(
|
||
(ll, lr),
|
||
(28.0, 68.0),
|
||
"the <ButtonText> row's label still hugs the button's left edge"
|
||
);
|
||
}
|
||
|
||
/// `text=` resolves as a global (`FrameScript_GetText 0x703bf0`) on all three element shapes;
|
||
/// a miss shows the raw value, as the reference's readers do, and a key-shaped miss warns.
|
||
#[test]
|
||
fn a_text_attribute_resolves_through_the_global_strings() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
// GlobalStrings.lua runs before any XML.
|
||
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 Title",
|
||
"No results found.",
|
||
"Delete",
|
||
"Exit Game",
|
||
"NO_SUCH_KEY",
|
||
]
|
||
);
|
||
// Only the key-shaped miss warns.
|
||
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")));
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|
||
|
||
// ── <SimpleHTML> ─────────────────────────────────────────────────────────────────────────
|
||
|
||
/// A direct-child `<FontString>` declares `elementFont[0]` and `<FontStringHeader1>`
|
||
/// `elementFont[1]`, neither a region, and `hyperlinkFormat=` lands at `+0x360`; stock
|
||
/// `ItemTextFrame.xml:214` uses the first.
|
||
#[test]
|
||
fn simplehtml_xml_declares_element_fonts_not_regions() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Font name="ItemTextFontNormal" font="Fonts\MORPHEUS.TTF" justifyH="LEFT">
|
||
<FontHeight val="15"/>
|
||
<Color r="0.18" g="0.12" b="0.06"/>
|
||
</Font>
|
||
<Font name="BookHeader" font="Fonts\SKURRI.TTF">
|
||
<FontHeight val="24"/>
|
||
</Font>
|
||
<SimpleHTML name="PageText" hyperlinkFormat="|H%s|h[%s]|h">
|
||
<Size><AbsDimension x="270" y="304"/></Size>
|
||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||
<FontString inherits="ItemTextFontNormal"/>
|
||
<FontStringHeader1 inherits="BookHeader"/>
|
||
<Scripts><OnLoad>LOADED = this:GetName()</OnLoad></Scripts>
|
||
</SimpleHTML>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
|
||
assert_eq!(s.eval::<String>("return LOADED").unwrap(), "PageText");
|
||
assert_eq!(
|
||
s.eval::<String>("return PageText:GetObjectType()").unwrap(),
|
||
"SimpleHTML"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return PageText:GetHyperlinkFormat()")
|
||
.unwrap(),
|
||
"|H%s|h[%s]|h"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(String, f32)>("local p, h = PageText:GetFont(); return p, h")
|
||
.unwrap(),
|
||
("Fonts\\MORPHEUS.TTF".to_string(), 15.0)
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(String, f32)>("local p, h = PageText:GetFont(\"H1\"); return p, h")
|
||
.unwrap(),
|
||
("Fonts\\SKURRI.TTF".to_string(), 24.0)
|
||
);
|
||
// Neither declaration became a region.
|
||
let regions = {
|
||
let lua = s.lua();
|
||
let model = lua
|
||
.app_data_ref::<crate::script::Model>()
|
||
.expect("model app_data");
|
||
let fh = model.arena.lookup("PageText").expect("frame");
|
||
model.arena.frame(fh).expect("live").regions.len()
|
||
};
|
||
assert_eq!(regions, 0, "the font declarations created no regions");
|
||
|
||
// Two blocks, the H1 in its declared face, at the frame's width.
|
||
s.run(
|
||
r#"PageText:SetText("<HTML><BODY><H1>Title</H1><P align=\"center\">Body</P></BODY></HTML>")"#,
|
||
)
|
||
.unwrap();
|
||
let (kinds, fonts, widths) = {
|
||
let lua = s.lua();
|
||
let model = lua
|
||
.app_data_ref::<crate::script::Model>()
|
||
.expect("model app_data");
|
||
let fh = model.arena.lookup("PageText").expect("frame");
|
||
let blocks = &model.simple_html.get(&fh).expect("state").blocks;
|
||
(
|
||
blocks.len(),
|
||
blocks
|
||
.iter()
|
||
.map(|rh| model.region_data[rh].font_path.clone())
|
||
.collect::<Vec<_>>(),
|
||
blocks
|
||
.iter()
|
||
.map(|rh| model.region_data[rh].size)
|
||
.collect::<Vec<_>>(),
|
||
)
|
||
};
|
||
assert_eq!(kinds, 2);
|
||
assert_eq!(
|
||
fonts,
|
||
[
|
||
Some("Fonts\\SKURRI.TTF".to_string()),
|
||
Some("Fonts\\MORPHEUS.TTF".to_string())
|
||
],
|
||
"a DECLARED header font wins; the P falls through to its own"
|
||
);
|
||
assert_eq!(widths, [Some((270.0, 0.0)); 2]);
|
||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||
}
|
||
|
||
/// `CSimpleButton::LoadXML 0x7788c0` builds any `<...Texture>` child through the `<Layers>`
|
||
/// texture adder (`0x6f26f0`); `SpellBookFrame.xml:36` writes a bare `<NormalTexture/>`.
|
||
#[test]
|
||
fn a_bare_state_texture_element_still_builds_its_region() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(800.0, 600.0);
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Button name="BareBtn">
|
||
<Size><AbsDimension x="32" y="32"/></Size>
|
||
<Anchors><Anchor point="CENTER"/></Anchors>
|
||
<NormalTexture/>
|
||
<DisabledTexture />
|
||
</Button>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
|
||
assert!(
|
||
s.eval::<bool>("return BareBtn:GetNormalTexture() ~= nil")
|
||
.unwrap(),
|
||
"a bare <NormalTexture/> builds its region"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return BareBtn:GetDisabledTexture() ~= nil")
|
||
.unwrap(),
|
||
"a bare <DisabledTexture /> builds its region"
|
||
);
|
||
// Live enough for `SetTexCoord`, with no art of its own.
|
||
s.run("BareBtn:GetNormalTexture():SetTexCoord(0.07, 0.93, 0.07, 0.93)")
|
||
.unwrap();
|
||
assert!(
|
||
s.eval::<bool>("return BareBtn:GetNormalTexture():GetTexture() == nil")
|
||
.unwrap(),
|
||
"blank, not painted"
|
||
);
|
||
}
|
||
}
|
||
|
||
mod layer_blend_tests {
|
||
use crate::framexml::parse;
|
||
use crate::loader::*;
|
||
use crate::script::{QuadContent, UiScript};
|
||
|
||
fn no_files(_: &str) -> Option<Vec<u8>> {
|
||
None
|
||
}
|
||
|
||
#[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
|
||
}
|
||
|
||
/// The instance's own nodes win over the template's.
|
||
#[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);
|
||
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"
|
||
);
|
||
}
|
||
|
||
#[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");
|
||
assert_eq!(
|
||
s.eval::<(String, f32)>("local f, h = Label:GetFont() return f, h")
|
||
.unwrap(),
|
||
("Fonts\\FRIZQT__.TTF".to_string(), 12.0),
|
||
"a FontString that inherits a Font object reports that object's font"
|
||
);
|
||
}
|
||
|
||
/// The reference's font registry compares names with `SStrCmpI` (`0x783870`/`0x7838c7`).
|
||
#[test]
|
||
fn a_font_object_inherits_resolves_whatever_its_case() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Font name="GameFontHighlightSmall" font="Fonts\FRIZQT__.TTF" virtual="true">
|
||
<FontHeight><AbsValue val="10"/></FontHeight>
|
||
</Font>
|
||
<Frame name="CaseHost">
|
||
<Size><AbsDimension x="100" y="30"/></Size>
|
||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||
<Layers><Layer level="ARTWORK">
|
||
<FontString name="CaseLabel" inherits="GameFontHighLightSmall" text="hi"/>
|
||
</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 differently-cased font name must resolve, not warn: {:?}",
|
||
report.warnings
|
||
);
|
||
assert_eq!(
|
||
s.eval::<(String, f32)>("local f, h = CaseLabel:GetFont() return f, h")
|
||
.unwrap(),
|
||
("Fonts\\FRIZQT__.TTF".to_string(), 10.0),
|
||
"the mis-cased inherit found the shipped font"
|
||
);
|
||
}
|
||
|
||
/// Two hops: a FontString inherits a virtual FontString template, which inherits a font object.
|
||
#[test]
|
||
fn a_fontstring_template_carries_the_font_object_it_inherits() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Font name="GameFontHighlight" font="Fonts\FRIZQT__.TTF" virtual="true">
|
||
<FontHeight><AbsValue val="12"/></FontHeight>
|
||
</Font>
|
||
<FontString name="LineTemplate" inherits="GameFontHighlight" virtual="true"
|
||
justifyH="LEFT"/>
|
||
<Frame name="Host3">
|
||
<Size><AbsDimension x="100" y="30"/></Size>
|
||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
|
||
<Layers><Layer level="ARTWORK">
|
||
<FontString name="Line1" inherits="LineTemplate" text="one"/>
|
||
</Layer></Layers>
|
||
</Frame>
|
||
</Ui>"#,
|
||
)
|
||
.unwrap();
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
|
||
assert_eq!(s.eval::<String>("return Line1:GetText()").unwrap(), "one");
|
||
assert_eq!(
|
||
s.eval::<(String, f32)>("local f, h = Line1:GetFont() return f, h")
|
||
.unwrap(),
|
||
("Fonts\\FRIZQT__.TTF".to_string(), 12.0),
|
||
"the font survives BOTH hops — template inheritance must not drop it"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Chunk naming: the file and line a `<Script>` raise reports.
|
||
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")
|
||
}
|
||
|
||
/// An inline block is padded to its line in the file, so a raise reports the file's line.
|
||
#[test]
|
||
fn a_script_raise_names_its_own_file_and_line() {
|
||
let s = UiScript::new().unwrap();
|
||
// `error()` with no level prefixes `chunkname:line:`, as a real raise does.
|
||
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");
|
||
// Backslashes, and the reference's `"%s:<Scripts>"` form (`0x871074`).
|
||
assert!(
|
||
err.contains("Bagnon\\src\\main.xml:"),
|
||
"the raise must name the document, got: {err}"
|
||
);
|
||
// `error("boom")` is line 5 of the literal; unpadded it would read `:2:`.
|
||
assert!(
|
||
err.contains("main.xml:<Scripts>\"]:5:"),
|
||
"the line must be the FILE's line, not the block's, got: {err}"
|
||
);
|
||
}
|
||
|
||
#[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:<Scripts>\"]: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 line comes from the text child's own range, not the element's, which the `<![CDATA[`
|
||
/// opener would offset.
|
||
#[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:<Scripts>\"]:6:"),
|
||
"line 6 is `error(\"cdata boom\")` in the literal above, got: {err}"
|
||
);
|
||
}
|
||
|
||
#[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}"
|
||
);
|
||
}
|
||
|
||
/// The 1.12 hook idiom: an addon captures a handler with `GetScript` and calls it with no
|
||
/// arguments, the frame passed only as `this`; `Loader::compile_handler` falls back to `this`.
|
||
#[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 call passes `self` and sets `this`.
|
||
s.run("SEEN = nil Hooked:Show()").unwrap();
|
||
assert_eq!(s.eval::<String>("return SEEN").unwrap(), "Hooked");
|
||
|
||
// The addon's: the captured script called bare, with only `this` set.
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// An unnamed child that inherits a *named* virtual template stays unnamed: the reference
|
||
/// reads `name` from the instance alone (`PreLoadXML 0x769770`), so the child's regions expand
|
||
/// `$parent` against the real parent frame. ItemRack ships this shape — an unnamed
|
||
/// `<Frame inherits="ItemRackQueueTemplate"/>` under `ItemRackInv0` names its texture
|
||
/// `ItemRackInv0Queue`, the global `Rack.OnEvent` looks up (ItemRack.lua:4015).
|
||
#[test]
|
||
fn an_unnamed_instance_does_not_take_its_template_name() {
|
||
let s = UiScript::new().unwrap();
|
||
let doc = parse(
|
||
r#"<Ui>
|
||
<Frame name="ItemRackQueueTemplate" virtual="true">
|
||
<Layers><Layer level="OVERLAY">
|
||
<Texture name="$parentQueue"/>
|
||
</Layer></Layers>
|
||
</Frame>
|
||
<CheckButton name="ItemRackInvTemplate" virtual="true">
|
||
<Frames>
|
||
<Frame inherits="ItemRackQueueTemplate"/>
|
||
</Frames>
|
||
</CheckButton>
|
||
<CheckButton name="ItemRackInv0" inherits="ItemRackInvTemplate"/>
|
||
</Ui>"#,
|
||
);
|
||
let report = load(&s, &doc, &no_files);
|
||
assert!(report.errors.is_empty(), "{:?}", report.errors);
|
||
assert!(
|
||
s.eval::<bool>("return ItemRackInv0Queue ~= nil").unwrap(),
|
||
"the unnamed child's texture must be named from the real parent, not the template: {:?}",
|
||
report.warnings
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return ItemRackQueueTemplate == nil").unwrap(),
|
||
"a virtual template must not leak a global named after it"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn join_ref_resolves_relative_framexml_paths() {
|
||
use super::join_ref;
|
||
assert_eq!(
|
||
join_ref("Bagnon/src", "templates.xml"),
|
||
"Bagnon/src/templates.xml"
|
||
);
|
||
assert_eq!(
|
||
join_ref("Bagnon/src", "..\\..\\BagBrother\\core\\core.xml"),
|
||
"BagBrother/core/core.xml"
|
||
);
|
||
assert_eq!(join_ref("", "Fonts.xml"), "Fonts.xml");
|
||
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");
|
||
}
|