the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect - both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list - the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand - the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -214,7 +214,7 @@ fn shapeshift_and_container_cooldowns_keep_their_anchors_too() {
|
||||
.eval::<bool>("local st = GetShapeshiftFormCooldown(1); return math.abs(st - 50) < 1e-3")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("local st = C_Container.GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
|
||||
.eval::<bool>("local st = GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
|
||||
.unwrap());
|
||||
|
||||
// Re-push both with the SAME raw triples 5 s later: the anchors hold at 50.
|
||||
@@ -225,6 +225,6 @@ fn shapeshift_and_container_cooldowns_keep_their_anchors_too() {
|
||||
.eval::<bool>("local st = GetShapeshiftFormCooldown(1); return math.abs(st - 50) < 1e-3")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("local st = C_Container.GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
|
||||
.eval::<bool>("local st = GetContainerItemCooldown(0, 1); return math.abs(st - 50) < 1e-3")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
//! `CreateFrame(kind, name, parent, inherits)` — the **runtime** template path
|
||||
//! ([`crate::loader::apply_template`]).
|
||||
//!
|
||||
//! `local b = CreateFrame("Button", "MyButton", UIParent, "UIPanelButtonTemplate")` is the single
|
||||
//! most common line in the addon corpus, and until the loader's post-`CreateFrame` steps were
|
||||
//! factored into `Loader::decorate` the fourth argument was dropped on the floor: the addon got a
|
||||
//! bare frame with no art, no regions, no scripts, and a warning on a channel it never reads.
|
||||
//!
|
||||
//! The load-bearing property every test here turns on is that **the caller's own name wins** — a
|
||||
//! template instantiated as `Mine` publishes `MineTexture`, never `ThatTemplateTexture`, because
|
||||
//! `getglobal("MineTexture")` is what the addon's next line calls.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// Register templates by loading a small FrameXML document — the same door an addon's own `.xml`
|
||||
/// comes through, so nothing here is a test-only shortcut into the registry.
|
||||
fn register(s: &UiScript, xml: &str) {
|
||||
let doc = crate::framexml::parse(xml).expect("valid FrameXML");
|
||||
let report = crate::loader::load(s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"fixture document failed to load: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole shape at once: a virtual template's `<Size>`, `<Anchors>`, `<Layers>` texture and
|
||||
/// `<Scripts><OnLoad>` all reach a frame built by `CreateFrame`'s fourth argument — and the region
|
||||
/// is named against **the instance**, not the template.
|
||||
#[test]
|
||||
fn a_runtime_template_brings_size_anchors_regions_and_a_fired_onload() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
register(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Frame name="Host"/>
|
||||
<Frame name="ProbeTemplate" virtual="true">
|
||||
<Size><AbsDimension x="160" y="40"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="$parent" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="7" y="-3"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="$parentTexture" file="Interface\Probe\Art"/>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>ProbeLoadedAs = self:GetName(); ProbeLoadWidth = self:GetWidth()</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
|
||||
s.run(r#"Mine = CreateFrame("Frame", "Mine", Host, "ProbeTemplate")"#)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32)>("return Mine:GetWidth(), Mine:GetHeight()")
|
||||
.unwrap(),
|
||||
(160.0, 40.0),
|
||||
"the template's <Size>"
|
||||
);
|
||||
|
||||
// The template's own <Anchors> substitute `$parent` against the frame's PARENT (rf27) — the
|
||||
// instance's enclosing frame, not the instance itself.
|
||||
let (point, rel, rel_point, x, y): (String, String, String, f32, f32) = s
|
||||
.eval(
|
||||
"local p, r, rp, ox, oy = Mine:GetPoint(1) \
|
||||
return p, r:GetName(), rp, ox, oy",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(point.as_str(), rel.as_str(), rel_point.as_str(), x, y),
|
||||
("TOPLEFT", "Host", "BOTTOMLEFT", 7.0, -3.0)
|
||||
);
|
||||
|
||||
// The region carries the INSTANCE's name. This is the whole point: `MineTexture` is what an
|
||||
// addon reaches for, and `ProbeTemplateTexture` would be useless to it.
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return getglobal("MineTexture") ~= nil"#)
|
||||
.unwrap(),
|
||||
"the template's $parentTexture resolved against the caller's name"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return getglobal("ProbeTemplateTexture") == nil"#)
|
||||
.unwrap(),
|
||||
"nothing may be named after the template"
|
||||
);
|
||||
|
||||
// OnLoad fired inside CreateFrame, after the frame was decorated — the reference's ordering,
|
||||
// and what `local f = CreateFrame(...); f:Foo()` on the next line assumes.
|
||||
assert_eq!(s.eval::<String>("return ProbeLoadedAs").unwrap(), "Mine");
|
||||
assert_eq!(s.eval::<f32>("return ProbeLoadWidth").unwrap(), 160.0);
|
||||
|
||||
assert!(s.take_errors().is_empty());
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// A template's nested `<Frames>` child is named against the caller too, and is really parented to
|
||||
/// the new frame — the `$parent` chain composes through `CreateFrame` exactly as through XML.
|
||||
#[test]
|
||||
fn a_nested_frames_child_is_named_against_the_caller() {
|
||||
let mut s = script();
|
||||
register(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Frame name="NestTemplate" virtual="true">
|
||||
<Frames>
|
||||
<Frame name="$parentInner">
|
||||
<Size><AbsDimension x="11" y="12"/></Size>
|
||||
<Frames>
|
||||
<Frame name="$parentDeep"/>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Frames>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
s.run(r#"Outer = CreateFrame("Frame", "Outer", nil, "NestTemplate")"#)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OuterInner:GetParent():GetName()")
|
||||
.unwrap(),
|
||||
"Outer"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32)>("return OuterInner:GetWidth(), OuterInner:GetHeight()")
|
||||
.unwrap(),
|
||||
(11.0, 12.0)
|
||||
);
|
||||
// Two levels deep, so the chain is composing rather than substituting once.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return OuterInnerDeep:GetParent():GetName()")
|
||||
.unwrap(),
|
||||
"OuterInner"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return getglobal("NestTemplateInner") == nil"#)
|
||||
.unwrap());
|
||||
assert!(s.take_errors().is_empty());
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// `inherits="A, B"` is a LIST, and the runtime path resolves it through the very same
|
||||
/// [`crate::framexml::expand`] the XML path uses — including chains, splice order and the cycle
|
||||
/// guard, none of which is written twice.
|
||||
#[test]
|
||||
fn an_inherits_list_picks_up_every_template_in_it() {
|
||||
let mut s = script();
|
||||
register(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Frame name="TemplRoot" virtual="true" alpha="0.25">
|
||||
<Size><AbsDimension x="50" y="60"/></Size>
|
||||
</Frame>
|
||||
<Frame name="TemplA" virtual="true" inherits="TemplRoot"/>
|
||||
<Frame name="TemplB" virtual="true">
|
||||
<Layers>
|
||||
<Layer level="ARTWORK">
|
||||
<Texture name="$parentBTex" file="Interface\Probe\B"/>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
);
|
||||
s.run(r#"Both = CreateFrame("Frame", "Both", nil, "TemplA, TemplB")"#)
|
||||
.unwrap();
|
||||
|
||||
// From A (itself inheriting TemplRoot — the chain resolves).
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32)>("return Both:GetWidth(), Both:GetHeight()")
|
||||
.unwrap(),
|
||||
(50.0, 60.0)
|
||||
);
|
||||
assert_eq!(s.eval::<f32>("return Both:GetAlpha()").unwrap(), 0.25);
|
||||
// From B.
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return getglobal("BothBTex") ~= nil"#)
|
||||
.unwrap());
|
||||
assert!(s.take_errors().is_empty());
|
||||
assert!(s.take_warnings().is_empty());
|
||||
}
|
||||
|
||||
/// An unusable template name never takes the frame with it — the reference creates the frame
|
||||
/// anyway, and an addon's next line already assumes it did. Both flavours of "unusable" are here:
|
||||
/// a name nothing declared, and a name declared **without `virtual="true"`** (only virtual
|
||||
/// elements are ever registered as templates, so from the registry's side the two look alike).
|
||||
#[test]
|
||||
fn an_unusable_template_still_returns_a_usable_frame_and_says_so() {
|
||||
let mut s = script();
|
||||
register(&s, r#"<Ui><Frame name="PlainFrame"/></Ui>"#);
|
||||
|
||||
for (frame, template) in [("Orphan", "NoSuchTemplate"), ("Plainer", "PlainFrame")] {
|
||||
s.run(&format!(
|
||||
r#"{frame} = CreateFrame("Button", "{frame}", nil, "{template}")
|
||||
{frame}:SetWidth(5)
|
||||
{frame}:Show()"#
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("{frame} must be usable: {e}"));
|
||||
assert_eq!(
|
||||
s.eval::<String>(&format!("return {frame}:GetName()"))
|
||||
.unwrap(),
|
||||
frame
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<f32>(&format!("return {frame}:GetWidth()"))
|
||||
.unwrap(),
|
||||
5.0
|
||||
);
|
||||
|
||||
let warnings = s.take_warnings();
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains(template) && w.contains(frame)),
|
||||
"a warning must name both the template and the frame it was asked for: {warnings:?}"
|
||||
);
|
||||
assert!(s.take_errors().is_empty(), "a bad template is not an error");
|
||||
}
|
||||
}
|
||||
|
||||
/// The two shape mismatches, neither of which may panic or drop the frame.
|
||||
///
|
||||
/// A **region** template (a virtual `<Texture>`) asked for as a frame: its frame-shaped content
|
||||
/// still lands, its region-only content cannot, and the message says which.
|
||||
///
|
||||
/// A **kind that disagrees with the template's tag**: the frame keeps the kind `CreateFrame` was
|
||||
/// given — no template can retype a frame that already exists — so the `<Button>`-only parts of a
|
||||
/// Button template simply do not apply to a Frame, and, crucially, are not *attempted* (calling
|
||||
/// `SetNormalTexture` on a plain Frame would be an error, not a warning).
|
||||
#[test]
|
||||
fn a_region_template_or_a_mismatched_kind_is_named_never_fatal() {
|
||||
let mut s = script();
|
||||
register(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Texture name="RegionTemplate" virtual="true" file="Interface\Probe\Art">
|
||||
<Size><AbsDimension x="33" y="44"/></Size>
|
||||
</Texture>
|
||||
<Button name="ProbeButtonTemplate" virtual="true">
|
||||
<Size><AbsDimension x="90" y="22"/></Size>
|
||||
<NormalTexture file="Interface\Probe\Normal"/>
|
||||
<ButtonText name="$parentText"/>
|
||||
</Button>
|
||||
</Ui>"#,
|
||||
);
|
||||
|
||||
// 1 · a region template on a frame.
|
||||
s.run(r#"FromRegion = CreateFrame("Frame", "FromRegion", nil, "RegionTemplate")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32)>("return FromRegion:GetWidth(), FromRegion:GetHeight()")
|
||||
.unwrap(),
|
||||
(33.0, 44.0),
|
||||
"the frame-shaped half of a region template still applies"
|
||||
);
|
||||
let warnings = s.take_warnings();
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("RegionTemplate") && w.contains("REGION template")),
|
||||
"the region/frame mismatch must be named: {warnings:?}"
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
|
||||
// 2 · a Button template asked for as a Frame.
|
||||
s.run(r#"AsFrame = CreateFrame("Frame", "AsFrame", nil, "ProbeButtonTemplate")"#)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return AsFrame:GetWidth()").unwrap(),
|
||||
90.0,
|
||||
"the generic half of the template still applies"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return getglobal("AsFrameText") == nil"#)
|
||||
.unwrap(),
|
||||
"a Frame has no ButtonText slot, so nothing was invented for it"
|
||||
);
|
||||
let warnings = s.take_warnings();
|
||||
assert!(
|
||||
warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("ProbeButtonTemplate") && w.contains("<Button>")),
|
||||
"the kind mismatch must be named: {warnings:?}"
|
||||
);
|
||||
assert!(
|
||||
s.take_errors().is_empty(),
|
||||
"the Button-only steps are SKIPPED, not attempted and failed"
|
||||
);
|
||||
|
||||
// 3 · the same template, asked for as the kind it was written as — the control.
|
||||
s.run(r#"AsButton = CreateFrame("Button", "AsButton", nil, "ProbeButtonTemplate")"#)
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<f32>("return AsButton:GetWidth()").unwrap(), 90.0);
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return getglobal("AsButtonText") ~= nil"#)
|
||||
.unwrap(),
|
||||
"the <ButtonText name=\"$parentText\"> label, named against the caller"
|
||||
);
|
||||
assert!(s.take_errors().is_empty());
|
||||
assert!(
|
||||
s.take_warnings().is_empty(),
|
||||
"a matching kind has nothing to report"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`<ScrollChild>` gives a ScrollFrame its range** — the loader element that was missing, and
|
||||
/// the whole remaining distance for an addon's scrolling list (decision 1205).
|
||||
///
|
||||
/// wow-5875-re `rf28-typed-widget-loadxml.md`: the single child is instantiated via the same
|
||||
/// RF-0026 path `<Frames>` uses, then stored as the scroll child. Without it a `ScrollFrame` has
|
||||
/// nothing to pan, so `GetVerticalScrollRange()` is 0, `SetVerticalScroll` clamps to 0, and
|
||||
/// `OnVerticalScroll` fires with 0 forever — the list never moves and nothing errors, which is why
|
||||
/// no instrument could see it.
|
||||
#[test]
|
||||
fn a_scroll_child_element_gives_the_frame_a_real_scroll_range() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
register(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<ScrollFrame name="Roller">
|
||||
<Size><AbsDimension x="200" y="100"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
<ScrollChild>
|
||||
<Frame name="$parentChild">
|
||||
<Size><AbsDimension x="200" y="400"/></Size>
|
||||
</Frame>
|
||||
</ScrollChild>
|
||||
</ScrollFrame>
|
||||
</Ui>"#,
|
||||
);
|
||||
s.resolve();
|
||||
|
||||
// The child exists, is named against the FRAME (rf27's `$parent`), and is the scroll child.
|
||||
assert!(s.eval::<bool>("return RollerChild ~= nil").unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return Roller:GetScrollChild() == RollerChild")
|
||||
.unwrap());
|
||||
// ...and the range is the overhang: a 400-tall child in a 100-tall window scrolls 300.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Roller:GetVerticalScrollRange()")
|
||||
.unwrap(),
|
||||
300.0,
|
||||
"no child means range 0, which is the silent failure this element fixes"
|
||||
);
|
||||
// The scroll actually takes, rather than clamping to zero.
|
||||
s.run("Roller:SetVerticalScroll(120)").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return Roller:GetVerticalScroll()").unwrap(),
|
||||
120.0
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// An empty `<ScrollChild>` is an error — the reference's own behaviour (`rf28`), and the honest
|
||||
/// one: a ScrollFrame that declares a child and has none is a typo, not a design.
|
||||
#[test]
|
||||
fn an_empty_scroll_child_is_reported() {
|
||||
let s = script();
|
||||
let doc = crate::framexml::parse(
|
||||
r#"<Ui><ScrollFrame name="Hollow"><ScrollChild/></ScrollFrame></Ui>"#,
|
||||
)
|
||||
.expect("valid FrameXML");
|
||||
let report = crate::loader::load(&s, &doc, &|_| None);
|
||||
assert!(
|
||||
report
|
||||
.errors
|
||||
.iter()
|
||||
.any(|e| e.contains("<ScrollChild> is empty")),
|
||||
"errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
// ...and the frame still exists — the client logs and carries on (decision 0068).
|
||||
assert!(s.eval::<bool>("return Hollow ~= nil").unwrap());
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
//! Font objects as first-class Lua objects ([`crate::script::font`]).
|
||||
//!
|
||||
//! Every test here is driven **the way the 218-addon corpus drives it** — the bare-global argument,
|
||||
//! the no-arg `CreateFontString`, `CreateFont` — rather than the way our own shipped XML does,
|
||||
//! because the string-name form our XML uses was the only one we accepted and it is the form
|
||||
//! almost nobody writes (6 corpus sites against 3,180).
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{FontObject, JustifyH, Outline, QuadContent, UiScript};
|
||||
|
||||
/// Load a FrameXML fragment through the real loader, asserting it reported no errors.
|
||||
fn load(s: &UiScript, xml: &str) {
|
||||
let doc = crate::framexml::parse(xml).expect("valid FrameXML");
|
||||
let report = crate::loader::load(s, &doc, &|_| None);
|
||||
assert!(
|
||||
report.errors.is_empty(),
|
||||
"loader errors: {:?}",
|
||||
report.errors
|
||||
);
|
||||
}
|
||||
|
||||
/// The resolved `(face, height, colour)` of the one text quad on screen.
|
||||
fn painted(s: &UiScript, text: &str) -> (Option<String>, Option<f32>, Option<[f32; 4]>) {
|
||||
s.extract()
|
||||
.into_iter()
|
||||
.find_map(|q| match q.content {
|
||||
QuadContent::Text {
|
||||
text: Some(ref t),
|
||||
ref font,
|
||||
font_height,
|
||||
color,
|
||||
..
|
||||
} if t == text => Some((font.clone(), font_height, color)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no text quad reading {text:?}"))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Publication
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A `<Font name="X">` in FrameXML becomes the **global `X`**, an object of type `Font`.
|
||||
///
|
||||
/// This is the whole gap: we registered the resolved record and never named it, so every
|
||||
/// `SetFontObject(GameFontNormal)` in the ecosystem was handed nil.
|
||||
#[test]
|
||||
fn a_declared_font_is_published_as_a_global_object() {
|
||||
let s = script();
|
||||
load(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF" justifyH="LEFT">
|
||||
<FontHeight><AbsValue val="12"/></FontHeight>
|
||||
<Color r="1" g="0.82" b="0"/>
|
||||
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
|
||||
</Font>
|
||||
</Ui>"#,
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameFontNormal:GetObjectType()")
|
||||
.unwrap(),
|
||||
"Font"
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return GameFontNormal:IsObjectType('Font')")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameFontNormal:GetName()").unwrap(),
|
||||
"GameFontNormal"
|
||||
);
|
||||
// Object identity is stable: two reads of the global are the same object, which is what makes
|
||||
// `if fs:GetFontObject() == GameFontNormal` work at all.
|
||||
assert!(s
|
||||
.eval::<bool>("return GameFontNormal == GameFontNormal")
|
||||
.unwrap());
|
||||
|
||||
// The declared paint reads back through the FontInstance getters.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return GameFontNormal:GetFont()")
|
||||
.unwrap(),
|
||||
("Fonts\\FRIZQT__.TTF".to_string(), 12.0, String::new())
|
||||
);
|
||||
let (r, g, b, _) = s
|
||||
.eval::<(f32, f32, f32, f32)>("return GameFontNormal:GetTextColor()")
|
||||
.unwrap();
|
||||
assert_eq!((r, g, b), (1.0, 0.82, 0.0));
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32)>("return GameFontNormal:GetShadowOffset()")
|
||||
.unwrap(),
|
||||
(1.0, -1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return GameFontNormal:GetShadowColor()")
|
||||
.unwrap(),
|
||||
(0.0, 0.0, 0.0, 1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GameFontNormal:GetJustifyH()")
|
||||
.unwrap(),
|
||||
"LEFT"
|
||||
);
|
||||
}
|
||||
|
||||
/// **`Tablet-2.0.lua:289`, verbatim.** `_, headerSize = GameTooltipHeaderText:GetFont()` — the
|
||||
/// single most-called font-object read in the corpus (268 sites across the four embedded Ace
|
||||
/// libraries). Until the global existed, Tablet fell to its hardcoded 14/12 guard branch.
|
||||
#[test]
|
||||
fn tablet_reads_the_tooltip_header_size_off_the_global() {
|
||||
let s = script();
|
||||
load(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Font name="GameTooltipText" font="Fonts\FRIZQT__.TTF">
|
||||
<FontHeight><AbsValue val="12"/></FontHeight>
|
||||
</Font>
|
||||
<Font name="GameTooltipHeaderText" inherits="GameTooltipText">
|
||||
<FontHeight><AbsValue val="14"/></FontHeight>
|
||||
</Font>
|
||||
</Ui>"#,
|
||||
);
|
||||
let (header, normal) = s
|
||||
.eval::<(f32, f32)>(
|
||||
r#"
|
||||
local headerSize, normalSize
|
||||
if GameTooltipHeaderText then
|
||||
_, headerSize = GameTooltipHeaderText:GetFont()
|
||||
else
|
||||
headerSize = 14
|
||||
end
|
||||
if GameTooltipText then
|
||||
_, normalSize = GameTooltipText:GetFont()
|
||||
else
|
||||
normalSize = 12
|
||||
end
|
||||
return headerSize, normalSize
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((header, normal), (14.0, 12.0));
|
||||
}
|
||||
|
||||
/// **Requirement 5: publishing must not change what `inherits=` resolves to.** The chain is still
|
||||
/// flattened once, at load, and the published object carries the *flattened* values — the derived
|
||||
/// font sees the parent's face and its own height override.
|
||||
#[test]
|
||||
fn an_inheriting_font_still_flattens_to_the_same_values() {
|
||||
let s = script();
|
||||
load(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Font name="MasterFont" font="Fonts\FRIZQT__.TTF" justifyH="CENTER">
|
||||
<FontHeight><AbsValue val="10"/></FontHeight>
|
||||
<Color r="1" g="1" b="1"/>
|
||||
<Shadow><Offset><AbsDimension x="1" y="-1"/></Offset><Color r="0" g="0" b="0"/></Shadow>
|
||||
</Font>
|
||||
<Font name="DerivedFont" inherits="MasterFont" outline="NORMAL">
|
||||
<FontHeight><AbsValue val="18"/></FontHeight>
|
||||
</Font>
|
||||
</Ui>"#,
|
||||
);
|
||||
// The Rust-side record — unchanged by publication.
|
||||
let derived = s.font_object("DerivedFont").expect("registered");
|
||||
assert_eq!(derived.font.as_deref(), Some("Fonts\\FRIZQT__.TTF"));
|
||||
assert_eq!(derived.height, Some(18.0));
|
||||
assert_eq!(derived.color, Some([1.0, 1.0, 1.0, 1.0]));
|
||||
assert_eq!(derived.outline, Outline::Normal);
|
||||
assert_eq!(derived.justify_h, Some(JustifyH::Center));
|
||||
assert!(derived.shadow.is_some(), "the shadow inherits too");
|
||||
// …and the same values through the published object.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return DerivedFont:GetFont()")
|
||||
.unwrap(),
|
||||
(
|
||||
"Fonts\\FRIZQT__.TTF".to_string(),
|
||||
18.0,
|
||||
"OUTLINE".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// SetFontObject / GetFontObject
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// **`Gratuity-2.0.lua:47-59`, verbatim** — the block that took five corpus addons down at load
|
||||
/// with `bad argument #2: error converting Lua nil to String`.
|
||||
///
|
||||
/// Three separate things have to hold for it: `CreateFontString()` with **no arguments**,
|
||||
/// `SetFontObject` taking the **object**, and `AddFontStrings` existing at all (fixing only the
|
||||
/// first two moves the death one line down).
|
||||
#[test]
|
||||
fn gratuity_builds_its_thirty_line_scan_tooltip() {
|
||||
let s = script();
|
||||
load(
|
||||
&s,
|
||||
r#"<Ui>
|
||||
<Font name="GameFontNormal" font="Fonts\FRIZQT__.TTF">
|
||||
<FontHeight><AbsValue val="12"/></FontHeight>
|
||||
<Color r="1" g="0.82" b="0"/>
|
||||
</Font>
|
||||
</Ui>"#,
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
vars = { Llines = {}, Rlines = {} }
|
||||
local tt = CreateFrame("GameTooltip")
|
||||
vars.tooltip = tt
|
||||
tt:SetOwner(tt, "ANCHOR_NONE")
|
||||
for i = 1, 30 do
|
||||
vars.Llines[i], vars.Rlines[i] = tt:CreateFontString(), tt:CreateFontString()
|
||||
vars.Llines[i]:SetFontObject(GameFontNormal)
|
||||
vars.Rlines[i]:SetFontObject(GameFontNormal)
|
||||
tt:AddFontStrings(vars.Llines[i], vars.Rlines[i])
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.expect("Gratuity's CreateTooltip must not raise");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// The font object actually landed on the lines it made.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32)>("return vars.Llines[7]:GetFont()")
|
||||
.unwrap(),
|
||||
("Fonts\\FRIZQT__.TTF".to_string(), 12.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return vars.Rlines[30]:GetFontObject():GetName()")
|
||||
.unwrap(),
|
||||
"GameFontNormal"
|
||||
);
|
||||
// And Gratuity's own next move — `ClearLines` over the grown stack — still works.
|
||||
s.run("vars.tooltip:ClearLines()").unwrap();
|
||||
}
|
||||
|
||||
/// The object form and the string form must resolve to **the same paint**. Both are kept: the
|
||||
/// object is what 3,180 of 3,186 corpus sites pass, the string is what our own `assets/ui` and 6
|
||||
/// corpus sites pass.
|
||||
#[test]
|
||||
fn set_font_object_takes_the_object_or_the_name() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.register_font_object(
|
||||
"GameFontHighlightSmall",
|
||||
FontObject {
|
||||
font: Some("Fonts\\ARIALN.TTF".into()),
|
||||
height: Some(11.0),
|
||||
color: Some([0.25, 0.5, 0.75, 1.0]),
|
||||
outline: Outline::Thick,
|
||||
justify_h: None,
|
||||
justify_v: None,
|
||||
shadow: None,
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "TwoWays")
|
||||
f:SetWidth(200); f:SetHeight(40); f:SetPoint("CENTER")
|
||||
byObject = f:CreateFontString(nil, "ARTWORK")
|
||||
byObject:SetText("obj")
|
||||
byObject:SetFontObject(GameFontHighlightSmall)
|
||||
byName = f:CreateFontString(nil, "ARTWORK")
|
||||
byName:SetText("str")
|
||||
byName:SetFontObject("GameFontHighlightSmall")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "obj"), painted(&s, "str"));
|
||||
assert_eq!(
|
||||
painted(&s, "obj"),
|
||||
(
|
||||
Some("Fonts\\ARIALN.TTF".into()),
|
||||
Some(11.0),
|
||||
Some([0.25, 0.5, 0.75, 1.0])
|
||||
)
|
||||
);
|
||||
// …and both name the same object back.
|
||||
assert!(s
|
||||
.eval::<bool>("return byObject:GetFontObject() == byName:GetFontObject()")
|
||||
.unwrap());
|
||||
|
||||
// nil is the THIRD form the reference's own usage string names
|
||||
// (`SetFontObject(font or "font" or nil)`, `.rdata 0x87c5cc`): it severs the link and leaves
|
||||
// the paint standing.
|
||||
s.run("byName:SetFontObject(nil)").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return byName:GetFontObject() == nil")
|
||||
.unwrap());
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
painted(&s, "str").1,
|
||||
Some(11.0),
|
||||
"unlinking must not repaint"
|
||||
);
|
||||
// A frame or an unknown name is still an ERROR, never a silent no-op (1203/1205/1211's class).
|
||||
assert!(s.run("byName:SetFontObject(f)").is_err());
|
||||
assert!(s.run("byName:SetFontObject('NoSuchFont')").is_err());
|
||||
}
|
||||
|
||||
/// `GetFontObject` returns the **object**, not a name — because `Dewdrop-2.0.lua:2181` indexes the
|
||||
/// result immediately: `button.text:SetTextColor(button.text:GetFontObject():GetTextColor())`.
|
||||
/// 65 sites across 62 corpus addons do exactly this.
|
||||
#[test]
|
||||
fn dewdrop_recolors_a_row_from_its_own_font_object() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.register_font_object(
|
||||
"GameFontHighlightSmall",
|
||||
FontObject {
|
||||
font: Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
height: Some(10.0),
|
||||
color: Some([1.0, 1.0, 1.0, 1.0]),
|
||||
outline: Outline::None,
|
||||
justify_h: None,
|
||||
justify_v: None,
|
||||
shadow: None,
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "DdRow")
|
||||
f:SetWidth(120); f:SetHeight(16); f:SetPoint("CENTER")
|
||||
button = { text = f:CreateFontString(nil, "ARTWORK") }
|
||||
button.text:SetText("row")
|
||||
button.text:SetFontObject(GameFontHighlightSmall)
|
||||
button.text:SetTextColor(button.text:GetFontObject():GetTextColor())
|
||||
"#,
|
||||
)
|
||||
.expect("Dewdrop's row recolor must not raise");
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "row").2, Some([1.0, 1.0, 1.0, 1.0]));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// Mutability — the decision, pinned
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// **The mutability decision.** A font object is a shared record with a **live** link: mutating it
|
||||
/// re-paints every FontString that inherits it, which is why addons mutate one at all. What a
|
||||
/// FontString set *for itself* since its own `SetFontObject` survives that re-paint — our
|
||||
/// `FONTINSTANCE+0x038 explicitlySetMask`.
|
||||
///
|
||||
/// If this ever regresses to a one-shot copy, `inherited` stops moving and the test says so; if the
|
||||
/// mask is dropped, `overridden` loses its red.
|
||||
#[test]
|
||||
fn mutating_a_font_object_repaints_everything_that_inherits_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.register_font_object(
|
||||
"ThemeFont",
|
||||
FontObject {
|
||||
font: Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
height: Some(12.0),
|
||||
color: Some([1.0, 1.0, 1.0, 1.0]),
|
||||
outline: Outline::None,
|
||||
justify_h: None,
|
||||
justify_v: None,
|
||||
shadow: None,
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "ThemeHost")
|
||||
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
|
||||
a = f:CreateFontString(nil, "ARTWORK"); a:SetText("inherited")
|
||||
a:SetFontObject(ThemeFont)
|
||||
b = f:CreateFontString(nil, "ARTWORK"); b:SetText("overridden")
|
||||
b:SetFontObject(ThemeFont)
|
||||
b:SetTextColor(1, 0, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "inherited").1, Some(12.0));
|
||||
assert_eq!(painted(&s, "overridden").2, Some([1.0, 0.0, 0.0, 1.0]));
|
||||
|
||||
// The mutation the whole feature exists for.
|
||||
s.run(
|
||||
r#"
|
||||
ThemeFont:SetFont("Fonts\\MORPHEUS.TTF", 20)
|
||||
ThemeFont:SetTextColor(0, 1, 0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Both followed the face and size…
|
||||
assert_eq!(
|
||||
painted(&s, "inherited"),
|
||||
(
|
||||
Some("Fonts\\MORPHEUS.TTF".into()),
|
||||
Some(20.0),
|
||||
Some([0.0, 1.0, 0.0, 1.0])
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
painted(&s, "overridden").0,
|
||||
Some("Fonts\\MORPHEUS.TTF".into())
|
||||
);
|
||||
assert_eq!(painted(&s, "overridden").1, Some(20.0));
|
||||
// …but the one that had set its own colour kept it.
|
||||
assert_eq!(
|
||||
painted(&s, "overridden").2,
|
||||
Some([1.0, 0.0, 0.0, 1.0]),
|
||||
"an explicitly-set property must survive a font-object mutation"
|
||||
);
|
||||
|
||||
// §5-verified: severance SURVIVES a re-point. The reference's inheritMask bit (`+0x2c`,
|
||||
// FontString `+0xd4`) is cleared by the local setter and never restored, so "a FontString that
|
||||
// set its own colour stays severed even across a later SetFontObject". Our first cut reset the
|
||||
// mask here and this assertion was its inverse.
|
||||
s.run("b:SetFontObject(ThemeFont)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
painted(&s, "overridden").2,
|
||||
Some([1.0, 0.0, 0.0, 1.0]),
|
||||
"a re-point must not restore inheritance of a severed property"
|
||||
);
|
||||
// Dewdrop still works, because it re-reads the colour off the new object explicitly every
|
||||
// refresh (`Dewdrop-2.0.lua:2181`) rather than relying on inheritance.
|
||||
s.run("b:SetTextColor(b:GetFontObject():GetTextColor())")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "overridden").2, Some([0.0, 1.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// A mutation of a font object nobody inherits touches nothing, and a FontString that inherits a
|
||||
/// *different* object is not caught in the sweep.
|
||||
#[test]
|
||||
fn propagation_is_scoped_to_the_object_that_changed() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
for (name, face) in [("FontOne", "Fonts\\ONE.TTF"), ("FontTwo", "Fonts\\TWO.TTF")] {
|
||||
s.register_font_object(
|
||||
name,
|
||||
FontObject {
|
||||
font: Some(face.into()),
|
||||
height: Some(12.0),
|
||||
color: Some([1.0, 1.0, 1.0, 1.0]),
|
||||
..FontObject::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "ScopeHost")
|
||||
f:SetWidth(200); f:SetHeight(60); f:SetPoint("CENTER")
|
||||
one = f:CreateFontString(nil, "ARTWORK"); one:SetText("one"); one:SetFontObject(FontOne)
|
||||
two = f:CreateFontString(nil, "ARTWORK"); two:SetText("two"); two:SetFontObject(FontTwo)
|
||||
FontOne:SetFont("Fonts\\CHANGED.TTF", 30)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "one").0, Some("Fonts\\CHANGED.TTF".into()));
|
||||
assert_eq!(painted(&s, "two").0, Some("Fonts\\TWO.TTF".into()));
|
||||
assert_eq!(painted(&s, "two").1, Some(12.0));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
// CreateFont
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// **`_Nameplates.lua:149` + `:129` + `:212`, end to end** — mint a font object at runtime, set it
|
||||
/// up, then paint a FontString with it. Plus `!OmniCC/main.lua:40-41`'s half: the name is published
|
||||
/// as a global even when the return value is thrown away, and `SetFont`'s return is the font-file
|
||||
/// validity probe OmniCC uses it as.
|
||||
#[test]
|
||||
fn create_font_mints_publishes_and_paints() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Nameplate = { Font = CreateFont("_NameplatesNameplateFont") }
|
||||
Nameplate.Font:SetFont("Fonts\\SKURRI.TTF", 10)
|
||||
Nameplate.Font:SetTextColor(0, 0.5, 1)
|
||||
|
||||
f = CreateFrame("Frame", "PlateHost")
|
||||
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
|
||||
Name = f:CreateFontString(nil, "ARTWORK")
|
||||
Name:SetText("plate")
|
||||
Name:SetFontObject(Nameplate.Font)
|
||||
"#,
|
||||
)
|
||||
.expect("_Nameplates' font setup must not raise");
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
painted(&s, "plate"),
|
||||
(
|
||||
Some("Fonts\\SKURRI.TTF".into()),
|
||||
Some(10.0),
|
||||
Some([0.0, 0.5, 1.0, 1.0])
|
||||
)
|
||||
);
|
||||
|
||||
// The return value and the published global are the SAME object — OmniCC reads only the global.
|
||||
assert!(s
|
||||
.eval::<bool>("return _NameplatesNameplateFont == Nameplate.Font")
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return _NameplatesNameplateFont:GetObjectType()")
|
||||
.unwrap(),
|
||||
"Font"
|
||||
);
|
||||
|
||||
// `!OmniCC/main.lua:40-41` verbatim: create, discard the return, use the global, and branch on
|
||||
// SetFont's return to detect a saved font path that is no longer usable.
|
||||
let reverted = s
|
||||
.eval::<bool>(
|
||||
r#"
|
||||
local reverted = false
|
||||
if not OmniCCFont then
|
||||
CreateFont("OmniCCFont")
|
||||
if not OmniCCFont:SetFont("", 20) then
|
||||
reverted = true
|
||||
OmniCCFont:SetFont("Fonts\\FRIZQT__.TTF", 20)
|
||||
end
|
||||
end
|
||||
return reverted
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(reverted, "SetFont must report an unusable path as false");
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return OmniCCFont:GetFont()")
|
||||
.unwrap(),
|
||||
("Fonts\\FRIZQT__.TTF".to_string(), 20.0, String::new())
|
||||
);
|
||||
|
||||
// A name that already names a font object hands the existing one back UNCHANGED — the
|
||||
// non-destructive reading (see `create_font`'s doc). The alternative would let
|
||||
// `CreateFont("GameFontNormal")` blank the shipped registry entry the whole UI inherits.
|
||||
s.run("again = CreateFont('_NameplatesNameplateFont')")
|
||||
.unwrap();
|
||||
assert!(s.eval::<bool>("return again == Nameplate.Font").unwrap());
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return again:GetFont()")
|
||||
.unwrap(),
|
||||
("Fonts\\SKURRI.TTF".to_string(), 10.0, String::new())
|
||||
);
|
||||
|
||||
// A nameless CreateFont is an error: the name IS the publication. The EMPTY name is not —
|
||||
// the reference's `lua_isstring` gate takes it (unlike the XML path), so we do too.
|
||||
assert!(s.run("CreateFont()").is_err());
|
||||
s.run("CreateFont('')").unwrap();
|
||||
}
|
||||
|
||||
/// **`FonzAppraiser/mods/gui/gui.lua:27-30`** — `CreateFont` then `CopyFontObject(<a shipped
|
||||
/// object>)` then override the face. The corpus's only Font-on-Font call.
|
||||
#[test]
|
||||
fn fonz_appraiser_copies_a_shipped_object_then_overrides_the_face() {
|
||||
let s = script();
|
||||
s.register_font_object(
|
||||
"GameFontHighlightSmall",
|
||||
FontObject {
|
||||
font: Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
height: Some(10.0),
|
||||
color: Some([1.0, 1.0, 1.0, 1.0]),
|
||||
outline: Outline::None,
|
||||
justify_h: Some(JustifyH::Right),
|
||||
justify_v: None,
|
||||
shadow: None,
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
small_number_font = CreateFont("FonzAppraiser_NumberFontNormalSmall")
|
||||
small_number_font:CopyFontObject(GameFontHighlightSmall)
|
||||
small_number_font:SetFont("Fonts\\ARIALN.TTF", 12)
|
||||
"#,
|
||||
)
|
||||
.expect("FonzAppraiser's font setup must not raise");
|
||||
// The copy brought the colour and justification over; the SetFont replaced the face and size.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return small_number_font:GetFont()")
|
||||
.unwrap(),
|
||||
("Fonts\\ARIALN.TTF".to_string(), 12.0, String::new())
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return small_number_font:GetTextColor()")
|
||||
.unwrap(),
|
||||
(1.0, 1.0, 1.0, 1.0)
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return small_number_font:GetJustifyH()")
|
||||
.unwrap(),
|
||||
"RIGHT"
|
||||
);
|
||||
// …and the object it copied FROM is untouched.
|
||||
assert_eq!(
|
||||
s.eval::<(String, f32, String)>("return GameFontHighlightSmall:GetFont()")
|
||||
.unwrap(),
|
||||
("Fonts\\FRIZQT__.TTF".to_string(), 10.0, String::new())
|
||||
);
|
||||
}
|
||||
|
||||
/// The `Spacing` pair is **deliberately absent** (we model no line spacing, and a stored-but-never
|
||||
/// drawn setter is this codebase's recurring silent-drop bug). It must fail LOUDLY rather than
|
||||
/// quietly accept a number nobody honours — this test is the tripwire on that choice, and it flips
|
||||
/// the day spacing becomes real.
|
||||
#[test]
|
||||
fn the_unmodelled_spacing_pair_fails_loudly() {
|
||||
let s = script();
|
||||
s.register_font_object("SomeFont", FontObject::default());
|
||||
assert!(s.run("SomeFont:SetSpacing(4)").is_err());
|
||||
assert!(s.eval::<bool>("return SomeFont.SetSpacing == nil").unwrap());
|
||||
}
|
||||
|
||||
/// A button's per-state label fonts take the object or the name too (the corpus splits 5 to 4
|
||||
/// across the trio), and — because they are stored as NAMES and re-resolved at every extract — a
|
||||
/// later mutation of that font object reaches the label with no further call.
|
||||
#[test]
|
||||
fn button_state_fonts_take_the_object_and_follow_its_mutation() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.register_font_object(
|
||||
"GameFontNormal",
|
||||
FontObject {
|
||||
font: Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
height: Some(12.0),
|
||||
color: Some([1.0, 0.82, 0.0, 1.0]),
|
||||
..FontObject::default()
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
b = CreateFrame("Button", "StateFontButton")
|
||||
b:SetWidth(80); b:SetHeight(22); b:SetPoint("CENTER")
|
||||
b:SetText("go")
|
||||
b:SetTextFontObject(GameFontNormal)
|
||||
"#,
|
||||
)
|
||||
.expect("the object form must be accepted");
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "go").1, Some(12.0));
|
||||
|
||||
s.run("GameFontNormal:SetFont('Fonts\\\\MORPHEUS.TTF', 22)")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
painted(&s, "go"),
|
||||
(
|
||||
Some("Fonts\\MORPHEUS.TTF".into()),
|
||||
Some(22.0),
|
||||
Some([1.0, 0.82, 0.0, 1.0])
|
||||
)
|
||||
);
|
||||
|
||||
// The string form still works alongside it.
|
||||
s.run("b:SetHighlightFontObject('GameFontNormal')").unwrap();
|
||||
}
|
||||
|
||||
/// **A `CreateFont` object holds nothing, so pointing a FontString at it copies nothing.**
|
||||
/// §5-verified: the reference gates every merge on the source's own has-a-value mask, and a fresh
|
||||
/// font has `mask == 0` — the FontString keeps exactly what it had, no blanking and no fallback to
|
||||
/// a default. Our first cut wrote face/height through unconditionally and would have wiped it.
|
||||
#[test]
|
||||
fn an_empty_font_object_copies_nothing_onto_a_fontstring() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.register_font_object(
|
||||
"DressedFont",
|
||||
FontObject {
|
||||
font: Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
height: Some(14.0),
|
||||
color: Some([0.2, 0.4, 0.6, 1.0]),
|
||||
..FontObject::default()
|
||||
},
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
f = CreateFrame("Frame", "EmptyFontHost")
|
||||
f:SetWidth(120); f:SetHeight(20); f:SetPoint("CENTER")
|
||||
fs = f:CreateFontString(nil, "ARTWORK")
|
||||
fs:SetText("keep")
|
||||
fs:SetFontObject(DressedFont)
|
||||
blank = CreateFont("ABlankFont")
|
||||
fs:SetFontObject(blank)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
painted(&s, "keep"),
|
||||
(
|
||||
Some("Fonts\\FRIZQT__.TTF".into()),
|
||||
Some(14.0),
|
||||
Some([0.2, 0.4, 0.6, 1.0])
|
||||
),
|
||||
"an unset property must copy nothing, not blank the region"
|
||||
);
|
||||
// The link still moved, so once the blank font is dressed the string follows it.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return fs:GetFontObject():GetName()")
|
||||
.unwrap(),
|
||||
"ABlankFont"
|
||||
);
|
||||
s.run("blank:SetFont('Fonts\\\\MORPHEUS.TTF', 22)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(painted(&s, "keep").0, Some("Fonts\\MORPHEUS.TTF".into()));
|
||||
}
|
||||
|
||||
/// `SetFont` returns **1 or nil**, not a boolean — the reference's exact return shape, and what
|
||||
/// `!OmniCC/main.lua:41` branches on.
|
||||
#[test]
|
||||
fn set_font_returns_one_or_nil() {
|
||||
let s = script();
|
||||
s.run("f = CreateFont('ReturnShapeFont')").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<f32>("return f:SetFont('Fonts\\\\FRIZQT__.TTF', 12)")
|
||||
.unwrap(),
|
||||
1.0
|
||||
);
|
||||
assert!(s.eval::<bool>("return f:SetFont('', 12) == nil").unwrap());
|
||||
}
|
||||
@@ -261,3 +261,365 @@ fn hit_rect_insets_default_to_zero() {
|
||||
assert_eq!((l, r, t, b), (0.0, 0.0, 0.0, 0.0), "no inset by default");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `EnableMouseWheel`/`IsMouseWheelEnabled` round-trip, and the two kinds born wheel-enabled
|
||||
/// (decision 1198).
|
||||
///
|
||||
/// The flag is real and settable; **the dispatch is deliberately not gated on it yet** — see
|
||||
/// `object::frame_state`'s note for the 44 shipped `OnMouseWheel` sites that declare no
|
||||
/// `enableMouseWheel` and the concrete condition for flipping it. This test pins the flag's own
|
||||
/// behaviour so that flip is a one-line change with a test already standing behind it.
|
||||
#[test]
|
||||
fn the_mouse_wheel_flag_round_trips_and_the_scrolling_kinds_are_born_enabled() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
Plain = CreateFrame("Frame", "WheelPlain")
|
||||
Scroll = CreateFrame("ScrollFrame", "WheelScroll")
|
||||
Msg = CreateFrame("ScrollingMessageFrame", "WheelMsg")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// A plain frame is born wheel-deaf, like WoW's own default.
|
||||
assert!(!s
|
||||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||||
.unwrap());
|
||||
// ...and the two kinds whose ctor takes the wheel are born enabled, the same by-construction
|
||||
// argument `mouse_enabled` already makes for a Button.
|
||||
assert!(s
|
||||
.eval::<bool>("return Scroll:IsMouseWheelEnabled()")
|
||||
.unwrap());
|
||||
assert!(s.eval::<bool>("return Msg:IsMouseWheelEnabled()").unwrap());
|
||||
|
||||
s.run("Plain:EnableMouseWheel(true)").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||||
.unwrap());
|
||||
s.run("Plain:EnableMouseWheel(false)").unwrap();
|
||||
assert!(!s
|
||||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||||
.unwrap());
|
||||
// Lua truthiness, like every other flag binding: `EnableMouseWheel(1)` is on.
|
||||
s.run("Plain:EnableMouseWheel(1)").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return Plain:IsMouseWheelEnabled()")
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A closed-vocabulary attribute survives stray whitespace (decision 1204).
|
||||
///
|
||||
/// The corpus case: `zBar.xml:146` — a shipped, working 1.12 addon — declares
|
||||
/// `frameStrata="BACKGROUND "` with a trailing space. The real client took it; we refused, and the
|
||||
/// refusal took the frame's whole `<Frames>` subtree with it, so the addon never loaded at all.
|
||||
///
|
||||
/// The three parsers are covered together because the next one to meet a stray space should not
|
||||
/// need its own bug report.
|
||||
#[test]
|
||||
fn an_enum_attribute_tolerates_the_whitespace_a_real_addon_ships() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
Padded = CreateFrame(" Frame ", "PaddedKind")
|
||||
Padded:SetFrameStrata("BACKGROUND ")
|
||||
Trailing = Padded:CreateTexture("PaddedTex", " OVERLAY")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Padded:GetFrameStrata()").unwrap(),
|
||||
"BACKGROUND",
|
||||
"the frame took the strata, and reports it in the canonical spelling"
|
||||
);
|
||||
// ...and the padded frame KIND resolved too — an unknown kind raises from CreateFrame, so
|
||||
// reaching this line at all is the assertion.
|
||||
assert!(s.eval::<bool>("return PaddedKind ~= nil").unwrap());
|
||||
assert!(s.eval::<bool>("return PaddedTex ~= nil").unwrap());
|
||||
}
|
||||
|
||||
// ── OnDoubleClick — the corpus's biggest script gap (250 sites / 85 addons) ───────────────────
|
||||
//
|
||||
// Every rule below is byte-verified, from the §5 cross-check this work dispatched into wow-re
|
||||
// (`system/ui/scratch/button-doubleclick-law.md`): the interval is a hardcoded **300 ms**
|
||||
// (`0x77937b cmp ecx, 0x12c`), the fire site is the mouse-**UP** dispatcher `0x7792d0` alone, the
|
||||
// double leg **replaces** the second `OnClick` (`0x77939d jmp` past `call [edx+0x94]`), a completed
|
||||
// double **zeroes** the stamp so clicks pair up, the detector is armed only when the frame carries
|
||||
// an `OnDoubleClick` script (`[+0x4d4] != 0`), and it carries **no button identity** — what
|
||||
// normally confines it to the left button is `RegisterForClicks`, not the detector.
|
||||
|
||||
/// Two fast clicks fire `OnDoubleClick(self, button)` — and the second `OnClick` does **not** fire,
|
||||
/// because the two legs are exclusive. (This was the interim implementation's mistake: it fired
|
||||
/// both, on the press edge, at 500 ms. All three were corrected at the bytes.)
|
||||
#[test]
|
||||
fn a_second_fast_click_fires_on_double_click_instead_of_the_second_on_click() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
clicks, doubles, dblbtn = 0, 0, nil
|
||||
local b = CreateFrame("Button", "DblB")
|
||||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
|
||||
b:SetScript("OnClick", function(self, button) clicks = clicks + 1 end)
|
||||
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
// Click one. Note the press alone does nothing: the fire site is the mouse-UP dispatcher.
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return clicks").unwrap(),
|
||||
0,
|
||||
"the default RegisterForClicks is LeftButtonUp — a press fires no click at all"
|
||||
);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return doubles").unwrap(),
|
||||
0,
|
||||
"one click is not a double click"
|
||||
);
|
||||
|
||||
// Click two, inside the 300 ms.
|
||||
s.tick(0.1);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return doubles").unwrap(),
|
||||
1,
|
||||
"the second click inside the interval is a double click"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return dblbtn").unwrap(),
|
||||
"LeftButton",
|
||||
"OnDoubleClick(self, button) — the same single button-name arg OnClick gets"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return clicks").unwrap(),
|
||||
1,
|
||||
"…and it REPLACED the second OnClick — the two legs are exclusive"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The gate that keeps rapid clicking from breaking every button in the UI.** The binary's chain
|
||||
/// tests `[+0x4d4] != 0`, so a widget with no `OnDoubleClick` script takes the single leg every
|
||||
/// time. Without that test, the exclusivity above would silently swallow every second `OnClick` on
|
||||
/// every frame in the game.
|
||||
#[test]
|
||||
fn a_frame_with_no_double_click_handler_keeps_every_rapid_on_click() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
clicks = 0
|
||||
local b = CreateFrame("Button", "NoDbl")
|
||||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
|
||||
b:SetScript("OnClick", function() clicks = clicks + 1 end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
for _ in 0..4 {
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return clicks").unwrap(),
|
||||
4,
|
||||
"no OnDoubleClick script ⇒ four rapid clicks are four clicks"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The boundary: too slow, a different frame, and the PAIRING rule (a completed double zeroes the
|
||||
/// stamp, so four rapid clicks read Click · Double · Click · Double — there is no triple-click and
|
||||
/// no run of doubles).
|
||||
#[test]
|
||||
fn the_double_click_boundary_is_time_and_frame_and_clicks_pair_up() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
clicks, doubles = 0, 0
|
||||
local function watch(f)
|
||||
f:SetScript("OnClick", function() clicks = clicks + 1 end)
|
||||
f:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
|
||||
end
|
||||
local a = CreateFrame("Button", "DblA")
|
||||
a:SetPoint("BOTTOMLEFT", 0, 0); a:SetSize(100, 100); a:EnableMouse(true); watch(a)
|
||||
local b = CreateFrame("Button", "DblBB")
|
||||
b:SetPoint("BOTTOMLEFT", 300, 0); b:SetSize(100, 100); b:EnableMouse(true); watch(b)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let click = |s: &mut crate::script::UiScript, x: f32| {
|
||||
s.mouse_button(x, 50.0, "LeftButton", true);
|
||||
s.mouse_button(x, 50.0, "LeftButton", false);
|
||||
};
|
||||
|
||||
// (1) TOO SLOW — 0.35 s > 300 ms, so the second click is a fresh first half.
|
||||
click(&mut s, 50.0);
|
||||
s.tick(0.35);
|
||||
click(&mut s, 50.0);
|
||||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||||
|
||||
// (2) A DIFFERENT FRAME — fast, but the timestamp lives on the WIDGET, so B has its own.
|
||||
s.run("clicks, doubles = 0, 0").unwrap();
|
||||
s.tick(1.0);
|
||||
click(&mut s, 50.0);
|
||||
click(&mut s, 350.0);
|
||||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return clicks").unwrap(), 2);
|
||||
|
||||
// (3) PAIRING — four rapid clicks on one frame are Click · Double · Click · Double.
|
||||
s.run("clicks, doubles = 0, 0").unwrap();
|
||||
s.tick(1.0);
|
||||
for _ in 0..4 {
|
||||
click(&mut s, 50.0);
|
||||
}
|
||||
assert_eq!(
|
||||
(
|
||||
s.eval::<i64>("return clicks").unwrap(),
|
||||
s.eval::<i64>("return doubles").unwrap()
|
||||
),
|
||||
(2, 2),
|
||||
"clicks pair up — the completed double zeroes the stamp, so there is no triple-click"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The detector carries no button identity** — the surprising half of the law, and the one an
|
||||
/// implementation keyed by mouse button would get wrong. `[CButton+0x334]` is a bare timestamp, so
|
||||
/// a widget registered for two `…Up` types completes a double click across them, with `arg1` = the
|
||||
/// button of the SECOND click. A stock button never sees this only because `RegisterForClicks`
|
||||
/// defaults to `{"LeftButtonUp"}`.
|
||||
#[test]
|
||||
fn a_multi_registered_button_pairs_a_left_click_with_a_right_one() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
doubles, dblbtn = 0, nil
|
||||
local b = CreateFrame("Button", "MixDbl")
|
||||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
|
||||
b:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||
b:SetScript("OnDoubleClick", function(self, button) doubles = doubles + 1 dblbtn = button end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
s.tick(0.1);
|
||||
s.mouse_button(50.0, 50.0, "RightButton", true);
|
||||
s.mouse_button(50.0, 50.0, "RightButton", false);
|
||||
assert_eq!(s.eval::<i64>("return doubles").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return dblbtn").unwrap(),
|
||||
"RightButton",
|
||||
"arg1 is the button of the completing click, not of the one that armed it"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Nothing clears a half-finished double click** — not hide, not disable, not the cursor leaving
|
||||
/// the window. `[+0x334]` has three writers image-wide and none of them is any of those, so this
|
||||
/// pins the *absence* of the tidy-up that looks like it belongs in
|
||||
/// [`crate::script::UiScript::pointer_left_window`].
|
||||
#[test]
|
||||
fn a_half_finished_double_click_survives_the_cursor_leaving_the_window() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
doubles = 0
|
||||
local b = CreateFrame("Button", "LeaveDbl")
|
||||
b:SetPoint("BOTTOMLEFT", 0, 0); b:SetSize(200, 200); b:EnableMouse(true)
|
||||
b:SetScript("OnDoubleClick", function() doubles = doubles + 1 end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
s.pointer_left_window();
|
||||
s.tick(0.1);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return doubles").unwrap(),
|
||||
1,
|
||||
"the pointer left and came back inside 300 ms — the reference still pairs them"
|
||||
);
|
||||
}
|
||||
|
||||
/// The load-bearing regression guard for the whole job: accepting `OnDoubleClick` in
|
||||
/// `SCRIPT_KINDS` without wiring the detector would make this test's `SetScript` succeed and its
|
||||
/// handler never run — the silent-capability trap. Asserting the *fire* is what makes the row real.
|
||||
#[test]
|
||||
fn set_script_on_double_click_is_accepted_because_something_fires_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
ran = false
|
||||
Btn = CreateFrame("Button", "DblAccepted")
|
||||
Btn:SetPoint("BOTTOMLEFT", 0, 0); Btn:SetSize(100, 100); Btn:EnableMouse(true)
|
||||
Btn:SetScript("OnDoubleClick", function() ran = true end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>(r#"return Btn:GetScript("OnDoubleClick") ~= nil"#)
|
||||
.unwrap(),
|
||||
"SetScript stored it"
|
||||
);
|
||||
s.resolve();
|
||||
for _ in 0..2 {
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", true);
|
||||
s.mouse_button(50.0, 50.0, "LeftButton", false);
|
||||
}
|
||||
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
|
||||
}
|
||||
|
||||
/// The script names that deliberately still raise, and why each is out (the reasons live at
|
||||
/// `object::events_regions::set_script`). This is the other half of the rule "a name is accepted
|
||||
/// only once something fires it": a future widening has to delete a line here, which is exactly
|
||||
/// the moment to check something fires it.
|
||||
#[test]
|
||||
fn the_unfired_script_kinds_still_raise_rather_than_silently_accepting() {
|
||||
let s = script();
|
||||
s.run(r#"Raiser = CreateFrame("EditBox", "RaiserBox")"#)
|
||||
.unwrap();
|
||||
for name in [
|
||||
// No keyboard index / `EnableKeyboard` yet (wow-re `scripts-auto-enable.md` kinds 0/1).
|
||||
"OnKeyDown",
|
||||
"OnKeyUp",
|
||||
"OnChar",
|
||||
// Caret geometry is host-side; its four float args would all be zero.
|
||||
"OnCursorChanged",
|
||||
// 2.0's secure-frame system — no such slot exists in any 1.12 resolver.
|
||||
"OnAttributeChanged",
|
||||
// Real 1.12 slots we do not fire, and zero corpus call sites.
|
||||
"OnHorizontalScroll",
|
||||
"OnHyperlinkEnter",
|
||||
"OnMessageScrollChanged",
|
||||
"OnUpdateModel",
|
||||
"OnAnimFinished",
|
||||
"OnInputLanguageChanged",
|
||||
] {
|
||||
let err = s
|
||||
.run(&format!(r#"Raiser:SetScript("{name}", function() end)"#))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains(name),
|
||||
"{name} must raise by name, got {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,21 @@ mod backdrop;
|
||||
mod button;
|
||||
mod common;
|
||||
mod cooldown;
|
||||
mod create_frame_template;
|
||||
mod end_to_end;
|
||||
mod events;
|
||||
mod font_object;
|
||||
mod frame_api;
|
||||
mod input;
|
||||
mod layout_gate;
|
||||
mod measure;
|
||||
mod minimap;
|
||||
mod movable;
|
||||
mod object_model;
|
||||
mod reference_surface;
|
||||
mod regions;
|
||||
mod scrollframe;
|
||||
mod size_changed;
|
||||
mod slider;
|
||||
mod statusbar;
|
||||
mod stdlib;
|
||||
@@ -29,5 +34,6 @@ mod tooltip;
|
||||
mod tooltip_item;
|
||||
mod tooltip_spell;
|
||||
mod tooltip_unit;
|
||||
mod toplevel;
|
||||
mod visibility;
|
||||
mod worldmap;
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
//! The movable-frame family — `SetMovable`/`StartMoving`/`StopMovingOrSizing`/`SetUserPlaced`/
|
||||
//! `SetResizable` (the mechanism and its wow-re addresses are in `script::object::movable`).
|
||||
//!
|
||||
//! Every test here drives the PRODUCTION path: the Lua bindings, and the real
|
||||
//! `mouse_button`/`mouse_move` entry points, so the drag gesture that fires `OnDragStart` is the
|
||||
//! same one an addon gets.
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A frame wired the canonical way, laid out at (100, 100) 200×80 on an 800×600 screen.
|
||||
fn movable_panel() -> UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
starts, stops = 0, 0
|
||||
Panel = CreateFrame("Frame", "MovePanel")
|
||||
Panel:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
Panel:SetSize(200, 80)
|
||||
Panel:EnableMouse(true)
|
||||
Panel:SetMovable(true)
|
||||
Panel:RegisterForDrag("LeftButton")
|
||||
Panel:SetScript("OnDragStart", function() starts = starts + 1; this:StartMoving() end)
|
||||
Panel:SetScript("OnDragStop", function() stops = stops + 1; this:StopMovingOrSizing() end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
/// The frame's resolved bottom-left corner, after a resolve.
|
||||
fn corner(s: &mut UiScript) -> (f32, f32) {
|
||||
s.resolve();
|
||||
let left = s.eval::<f64>("return MovePanel:GetLeft()").unwrap() as f32;
|
||||
let bottom = s.eval::<f64>("return MovePanel:GetBottom()").unwrap() as f32;
|
||||
(left, bottom)
|
||||
}
|
||||
|
||||
/// The whole point of the family: the four-line addon idiom actually moves the frame, and the
|
||||
/// position it was dragged to SURVIVES the stop — nothing snaps it back to the `SetPoint` it was
|
||||
/// born with, and nothing keeps moving it after the button is up.
|
||||
#[test]
|
||||
fn the_canonical_addon_idiom_moves_a_frame_and_the_position_survives_the_stop() {
|
||||
let mut s = movable_panel();
|
||||
assert_eq!(corner(&mut s), (100.0, 100.0), "born where SetPoint put it");
|
||||
|
||||
s.mouse_button(150.0, 140.0, "LeftButton", true); // press inside the panel
|
||||
s.mouse_move(152.0, 140.0); // 2 px — under the drag threshold, nothing yet
|
||||
assert_eq!(s.eval::<i64>("return starts").unwrap(), 0);
|
||||
assert_eq!(corner(&mut s), (100.0, 100.0));
|
||||
|
||||
s.mouse_move(160.0, 140.0); // crosses the threshold ⇒ OnDragStart ⇒ StartMoving
|
||||
assert_eq!(s.eval::<i64>("return starts").unwrap(), 1);
|
||||
s.mouse_move(260.0, 190.0); // +100, +50 from the grab
|
||||
assert_eq!(
|
||||
corner(&mut s),
|
||||
(200.0, 150.0),
|
||||
"the frame follows the cursor delta since StartMoving"
|
||||
);
|
||||
|
||||
s.mouse_button(260.0, 190.0, "LeftButton", false); // OnDragStop ⇒ StopMovingOrSizing
|
||||
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
|
||||
assert_eq!(corner(&mut s), (200.0, 150.0), "the position survives");
|
||||
|
||||
// And the move is really over: further cursor motion moves nothing.
|
||||
s.mouse_move(500.0, 400.0);
|
||||
assert_eq!(corner(&mut s), (200.0, 150.0), "no longer following");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
|
||||
// GetPoint reads back the frame's OWN point with the dragged offsets — the reference writes
|
||||
// the anchors in place rather than rewriting them, so what an addon saves is what it set.
|
||||
let (point, x, y) = s
|
||||
.eval::<(String, f64, f64)>("local p, _, _, x, y = MovePanel:GetPoint() return p, x, y")
|
||||
.unwrap();
|
||||
assert_eq!((point.as_str(), x, y), ("BOTTOMLEFT", 200.0, 150.0));
|
||||
}
|
||||
|
||||
/// A moving frame follows SUCCESSIVE moves, each one applying only that step's delta (the pump
|
||||
/// re-centers its sample — a bug that integrated from the grab instead would double the second
|
||||
/// step, and one that forgot to re-center would freeze after the first).
|
||||
#[test]
|
||||
fn a_moving_frame_follows_successive_mouse_moves() {
|
||||
let mut s = movable_panel();
|
||||
s.mouse_button(150.0, 140.0, "LeftButton", true);
|
||||
s.mouse_move(160.0, 140.0); // the move that STARTS the drag
|
||||
assert_eq!(
|
||||
corner(&mut s),
|
||||
(100.0, 100.0),
|
||||
"the frame follows from where StartMoving was called, so the starting move moves nothing"
|
||||
);
|
||||
for (step, want) in [
|
||||
((170.0, 140.0), (110.0, 100.0)),
|
||||
((170.0, 160.0), (110.0, 120.0)),
|
||||
((120.0, 160.0), (60.0, 120.0)), // back left of where it started
|
||||
((120.0, 160.0), (60.0, 120.0)), // a zero-delta move changes nothing
|
||||
] {
|
||||
s.mouse_move(step.0, step.1);
|
||||
assert_eq!(corner(&mut s), want, "after moving to {step:?}");
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `StartMoving` on a frame that is not `SetMovable(true)` RAISES and moves nothing — the
|
||||
/// reference's own guard (`0x776700`'s movable-bit test). The error is a real Lua error, so an
|
||||
/// addon's `pcall` sees it.
|
||||
#[test]
|
||||
fn start_moving_on_a_frame_that_is_not_movable_raises_and_moves_nothing() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Fixed = CreateFrame("Frame", "FixedPanel")
|
||||
Fixed:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
Fixed:SetSize(200, 80)
|
||||
ok, err = pcall(function() Fixed:StartMoving() end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(!s.eval::<bool>("return FixedPanel:IsMovable()").unwrap());
|
||||
assert!(!s.eval::<bool>("return ok").unwrap(), "StartMoving raised");
|
||||
assert!(
|
||||
s.eval::<String>("return tostring(err)")
|
||||
.unwrap()
|
||||
.contains("not movable"),
|
||||
"the refusal names why"
|
||||
);
|
||||
|
||||
// Nothing is in flight, so the cursor moves nothing.
|
||||
s.mouse_move(400.0, 400.0);
|
||||
s.resolve();
|
||||
let left = s.eval::<f64>("return FixedPanel:GetLeft()").unwrap();
|
||||
assert_eq!(left, 100.0, "a refused StartMoving started no move");
|
||||
|
||||
// The same frame, once made movable, moves — proving the guard was the only thing stopping it.
|
||||
s.run("FixedPanel:SetMovable(true) FixedPanel:StartMoving()")
|
||||
.unwrap();
|
||||
s.mouse_move(450.0, 400.0);
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f64>("return FixedPanel:GetLeft()").unwrap(), 150.0);
|
||||
}
|
||||
|
||||
/// `StopMovingOrSizing` with nothing moving is harmless — the double call, the OnDragStop that
|
||||
/// never had a StartMoving, the addon that also wires it to OnMouseUp. And it only stops the
|
||||
/// frame that is actually in the drag slot (the reference's `[root+0xcfc] == self` compare), so
|
||||
/// one frame's stop cannot end another's move.
|
||||
#[test]
|
||||
fn stop_moving_or_sizing_is_harmless_with_nothing_moving_and_stops_only_its_own_frame() {
|
||||
let mut s = movable_panel();
|
||||
s.run(
|
||||
r#"
|
||||
Other = CreateFrame("Frame", "OtherPanel")
|
||||
Other:SetPoint("BOTTOMLEFT", 400, 400)
|
||||
Other:SetSize(50, 50)
|
||||
MovePanel:StopMovingOrSizing() -- nothing is moving
|
||||
MovePanel:StopMovingOrSizing() -- twice
|
||||
OtherPanel:StopMovingOrSizing()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
assert_eq!(corner(&mut s), (100.0, 100.0), "nothing moved");
|
||||
|
||||
// Now start a real move and have the OTHER frame try to stop it.
|
||||
s.run("MovePanel:StartMoving()").unwrap();
|
||||
s.mouse_move(50.0, 50.0); // cursor_pos was (0,0) ⇒ +50, +50
|
||||
assert_eq!(corner(&mut s), (150.0, 150.0));
|
||||
s.run("OtherPanel:StopMovingOrSizing()").unwrap();
|
||||
s.mouse_move(60.0, 50.0);
|
||||
assert_eq!(
|
||||
corner(&mut s),
|
||||
(160.0, 150.0),
|
||||
"another frame's stop does not end this move"
|
||||
);
|
||||
s.run("MovePanel:StopMovingOrSizing()").unwrap();
|
||||
s.mouse_move(200.0, 200.0);
|
||||
assert_eq!(corner(&mut s), (160.0, 150.0), "its own stop does");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A frame stretched between two anchors — no explicit size at all — moves RIGIDLY and keeps its
|
||||
/// derived size. This is the case a single-point rewrite gets wrong (it would drop the opposing
|
||||
/// anchor and leave the frame under-constrained, i.e. gone), and the case that decides between
|
||||
/// translating one anchor and translating the set.
|
||||
#[test]
|
||||
fn a_frame_stretched_between_two_anchors_moves_rigidly() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Back = CreateFrame("Frame", "StretchBack")
|
||||
Back:SetPoint("BOTTOMLEFT", 0, 0); Back:SetSize(800, 600)
|
||||
Stretch = CreateFrame("Frame", "StretchPanel", Back)
|
||||
Stretch:SetPoint("BOTTOMLEFT", Back, "BOTTOMLEFT", 100, 100)
|
||||
Stretch:SetPoint("TOPRIGHT", Back, "BOTTOMLEFT", 300, 200)
|
||||
Stretch:SetMovable(true)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let size = s
|
||||
.eval::<(f64, f64)>("return StretchPanel:GetWidth(), StretchPanel:GetHeight()")
|
||||
.unwrap();
|
||||
assert_eq!(size, (200.0, 100.0), "size derived from the two anchors");
|
||||
|
||||
s.run("StretchPanel:StartMoving()").unwrap();
|
||||
s.mouse_move(30.0, 40.0); // cursor_pos starts at (0,0)
|
||||
s.run("StretchPanel:StopMovingOrSizing()").unwrap();
|
||||
s.resolve();
|
||||
let left = s.eval::<f64>("return StretchPanel:GetLeft()").unwrap();
|
||||
let bottom = s.eval::<f64>("return StretchPanel:GetBottom()").unwrap();
|
||||
let size_after = s
|
||||
.eval::<(f64, f64)>("return StretchPanel:GetWidth(), StretchPanel:GetHeight()")
|
||||
.unwrap();
|
||||
assert_eq!((left, bottom), (130.0, 140.0), "both anchors translated");
|
||||
assert_eq!(size_after, (200.0, 100.0), "and the frame kept its size");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// A SCALED frame moves with the cursor 1:1 on screen — the anchor offsets it writes are in local
|
||||
/// units, so they carry the inverse of the frame's own scale (`geo_768710`'s `dx/scale`).
|
||||
#[test]
|
||||
fn a_scaled_frame_tracks_the_cursor_one_to_one_on_screen() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Scaled = CreateFrame("Frame", "ScaledPanel")
|
||||
Scaled:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
Scaled:SetSize(100, 100)
|
||||
Scaled:SetScale(2)
|
||||
Scaled:SetMovable(true)
|
||||
Scaled:StartMoving()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// Local 100 at scale 2 ⇒ 200 screen px.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ScaledPanel:GetLeft()").unwrap(),
|
||||
100.0
|
||||
);
|
||||
s.mouse_move(40.0, 0.0);
|
||||
s.resolve();
|
||||
// GetLeft reports LOCAL units: 40 screen px is 20 local, on top of the local 100.
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return ScaledPanel:GetLeft()").unwrap(),
|
||||
120.0
|
||||
);
|
||||
let x = s
|
||||
.eval::<f64>("local _, _, _, x = ScaledPanel:GetPoint() return x")
|
||||
.unwrap();
|
||||
assert_eq!(x, 120.0, "the anchor offset is local too");
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The flags: default off, round-trip through their setters, and `SetUserPlaced`'s guard — the
|
||||
/// reference refuses it unless the frame is movable OR resizable (`776adb: test ah,0x3`).
|
||||
/// `StartMoving` sets the bit itself, which is the reference's drag-start doing it, not us.
|
||||
#[test]
|
||||
fn the_three_flags_default_off_round_trip_and_user_placed_is_guarded() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
F = CreateFrame("Frame", "FlagPanel")
|
||||
F:SetPoint("BOTTOMLEFT", 10, 10); F:SetSize(50, 50)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let all = |s: &mut UiScript| {
|
||||
s.eval::<(bool, bool, bool)>(
|
||||
"return FlagPanel:IsMovable(), FlagPanel:IsResizable(), FlagPanel:IsUserPlaced()",
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
all(&mut s),
|
||||
(false, false, false),
|
||||
"no frame is born flagged"
|
||||
);
|
||||
|
||||
// SetUserPlaced before either flag: refused, exactly like the reference.
|
||||
assert!(
|
||||
!s.eval::<bool>("return (pcall(function() FlagPanel:SetUserPlaced(true) end))")
|
||||
.unwrap(),
|
||||
"SetUserPlaced on a frame that is neither movable nor resizable raises"
|
||||
);
|
||||
assert!(!s.eval::<bool>("return FlagPanel:IsUserPlaced()").unwrap());
|
||||
|
||||
s.run("FlagPanel:SetResizable(true) FlagPanel:SetUserPlaced(true)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
all(&mut s),
|
||||
(false, true, true),
|
||||
"resizable satisfies it too"
|
||||
);
|
||||
s.run("FlagPanel:SetUserPlaced(false) FlagPanel:SetResizable(false) FlagPanel:SetMovable(1)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
all(&mut s),
|
||||
(true, false, false),
|
||||
"truthy 1 sets, false clears"
|
||||
);
|
||||
|
||||
// The drag start sets userPlaced itself (`0x7652b0`).
|
||||
s.resolve();
|
||||
s.run("FlagPanel:StartMoving()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return FlagPanel:IsUserPlaced()").unwrap(),
|
||||
"StartMoving sets the userPlaced bit"
|
||||
);
|
||||
s.run("FlagPanel:StopMovingOrSizing()").unwrap();
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `movable="true"` / `resizable="true"` in XML land on the real methods — they were a warn-once
|
||||
/// gap in the loader until this family existed, which left every reference window authored
|
||||
/// movable undraggable while `SetMovable` worked fine from Lua.
|
||||
#[test]
|
||||
fn the_xml_movable_and_resizable_attributes_reach_the_methods() {
|
||||
let s = script();
|
||||
let doc = crate::framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="XmlMovable" movable="true" resizable="true">
|
||||
<Size><AbsDimension x="100" y="50"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="10" y="10"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.expect("valid FrameXML");
|
||||
let report = crate::loader::load(&s, &doc, &|_| None);
|
||||
assert!(
|
||||
!report
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("SetMovable") || w.contains("SetResizable")),
|
||||
"no gap warning any more: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(bool, bool)>("return XmlMovable:IsMovable(), XmlMovable:IsResizable()")
|
||||
.unwrap(),
|
||||
(true, true)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! **The 1.12 surface is the contract** (decisions 1188 §2, 1189) — enforced here rather than
|
||||
//! trusted to memory.
|
||||
//!
|
||||
//! benilla targets the 1.12.1 API. Anything beyond it is a *listed, justified* exception, and the
|
||||
//! point of this file is that the list cannot go unknown: every global our VM exposes must either
|
||||
//! be one 1.12 has, or appear below with a reason. A session that adds an Era-shaped verb has to
|
||||
//! delete or extend an assertion that says why not — which is exactly what did not happen when
|
||||
//! 1187 shipped eight Era globals on Era's authority and 1189 had to take them back out.
|
||||
//!
|
||||
//! **Why a superset is not free.** Lua branches on presence. An addon writing `if strmatch then`
|
||||
//! takes a path we cannot honour, and the failure surfaces far from the cause. Extra functions are
|
||||
//! harmless only if nothing feature-detects, which is not true of this ecosystem.
|
||||
//!
|
||||
//! The reference side is `reference/1.12-globals.tsv` — the running 1.12.1 client's own in-world
|
||||
//! `_G` (see `reference/README.md`). This test reads the `engine` and `lua` rows: the surface a VM
|
||||
//! is responsible for. It deliberately does **not** assert the converse — that we have everything
|
||||
//! 1.12 has — because that is a multi-year backlog, not a regression gate;
|
||||
//! `scripts/api-coverage.sh` is where that number is read.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// `reference/1.12-globals.tsv`, as `name -> origin`.
|
||||
fn reference() -> Vec<(String, String)> {
|
||||
let path = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../reference/1.12-globals.tsv"
|
||||
);
|
||||
let text = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
panic!("reading {path}: {e} — regenerate with scripts/gen-reference-globals.py")
|
||||
});
|
||||
text.lines()
|
||||
.filter(|l| !l.starts_with('#'))
|
||||
.filter_map(|l| {
|
||||
let mut f = l.split('\t');
|
||||
Some((f.next()?.to_string(), f.nth(1)?.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Names benilla exposes that 1.12 does not, each with the reason it is allowed to stay.
|
||||
///
|
||||
/// **`Benilla*` and `__benilla_*` are covered by prefix**, not enumerated: the first is our host
|
||||
/// bridge (verbs only our own transcribed FrameXML calls — a paperdoll model's facing, an item's
|
||||
/// stat block) and the second is the tick's pushed state. Neither is an API-target claim, and
|
||||
/// neither is reachable by accident from an addon that means to call a WoW function.
|
||||
fn allowed_beyond_1_12() -> HashSet<&'static str> {
|
||||
[
|
||||
// ── our Lua runtime is 5.1 where 1.12's is 5.0 ────────────────────────────────────────
|
||||
// `select` is load-bearing and stays on purpose — our transcribed FrameXML uses it in 16
|
||||
// files as the 5.1 spelling of 5.0's implicit `arg` table.
|
||||
//
|
||||
// `_G` is the last one inherited rather than chosen: 1.12's base library does not export
|
||||
// it (an addon reaches the globals with `getfenv(0)`, which is what AceLibrary does), but
|
||||
// our own `getglobal`/`setglobal` are written over it. Closing it means rewriting those
|
||||
// against the registry first.
|
||||
//
|
||||
// **This list has been shrinking as the dialect got measured**: `coroutine` left in 1194
|
||||
// with the 5.1-only members of `string`/`table`/`math`; `print` and `_VERSION` left in
|
||||
// 1197, when the RE dispatch read the base library's 36-entry array and neither was in it.
|
||||
// The list only ever covered globals — the members needed `dump_globals --members` before
|
||||
// anyone could see them at all.
|
||||
"_G",
|
||||
"select",
|
||||
// ── WoW API past 1.12 — 1188 phase 5's list, and the reason that phase exists ─────────
|
||||
// Every one of these predates 1189 and is used by our own transcribed FrameXML today.
|
||||
// Resolving each means either replacing it with its 1.12 equivalent (`UnitPower` →
|
||||
// `UnitMana`, which 1.12 has and we do not) or recording why it stays.
|
||||
"CancelUnitBuff",
|
||||
"GetCursorInfo",
|
||||
"GetGossipQuestInfo",
|
||||
"GetInventoryItemID",
|
||||
"GetNumGossipQuests",
|
||||
"GetPlayerFacing",
|
||||
"GetTradePartnerName",
|
||||
"IsGossipOptionCoded",
|
||||
"SelectGossipQuest",
|
||||
"SubmitChatInput",
|
||||
"UnitAura",
|
||||
"UnitIsAFK",
|
||||
"UnitIsDND",
|
||||
"strconcat",
|
||||
"strjoin",
|
||||
"strsplit",
|
||||
"strtrim",
|
||||
"tostringall",
|
||||
"wipe",
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every global benilla's VM exposes is one 1.12 has, or a listed exception.
|
||||
///
|
||||
/// The failure message is the point: it names what is new and tells you the two ways out, so the
|
||||
/// next session cannot resolve it by deleting a bare assertion.
|
||||
#[test]
|
||||
fn our_globals_stay_inside_the_1_12_surface() {
|
||||
let reference = reference();
|
||||
let known: HashSet<&str> = reference.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(
|
||||
known.len() > 19_000,
|
||||
"the reference table looks truncated ({} names) — regenerate it",
|
||||
known.len()
|
||||
);
|
||||
let allowed = allowed_beyond_1_12();
|
||||
|
||||
let ours: Vec<String> = script()
|
||||
.eval(
|
||||
"local out = {} \
|
||||
for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \
|
||||
return out",
|
||||
)
|
||||
.expect("dump _G");
|
||||
|
||||
let mut unlisted: Vec<&str> = ours
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|n| !known.contains(n))
|
||||
.filter(|n| !allowed.contains(n))
|
||||
.filter(|n| !n.starts_with("Benilla") && !n.starts_with("__benilla_"))
|
||||
.collect();
|
||||
unlisted.sort_unstable();
|
||||
|
||||
assert!(
|
||||
unlisted.is_empty(),
|
||||
"benilla exposes {} global(s) the 1.12.1 client does not, and they are not listed as \
|
||||
exceptions:\n {}\n\n\
|
||||
1.12 is the target (decision 1188). Either give it its 1.12 spelling, or add it to \
|
||||
`allowed_beyond_1_12` in this file WITH the reason it has to stay — an unexplained \
|
||||
superset is what 1189 had to roll back.",
|
||||
unlisted.len(),
|
||||
unlisted.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
/// The exception list is exact — no entry outlives the global it excuses.
|
||||
///
|
||||
/// Without this, a removed global leaves its excuse behind, and the list slowly stops describing
|
||||
/// the code. That is how a "listed, justified exception" decays into residue nobody can explain.
|
||||
#[test]
|
||||
fn the_exception_list_has_no_dead_entries() {
|
||||
let known: HashSet<String> = reference().into_iter().map(|(n, _)| n).collect();
|
||||
let ours: HashSet<String> = script()
|
||||
.eval::<Vec<String>>(
|
||||
"local out = {} \
|
||||
for k in pairs(_G) do if type(k) == 'string' then table.insert(out, k) end end \
|
||||
return out",
|
||||
)
|
||||
.expect("dump _G")
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut dead: Vec<&str> = allowed_beyond_1_12()
|
||||
.into_iter()
|
||||
.filter(|n| !ours.contains(*n))
|
||||
.collect();
|
||||
dead.sort_unstable();
|
||||
assert!(
|
||||
dead.is_empty(),
|
||||
"these are excused as beyond-1.12 but benilla no longer exposes them — drop them from \
|
||||
`allowed_beyond_1_12`:\n {}",
|
||||
dead.join(" ")
|
||||
);
|
||||
|
||||
// An entry that 1.12 turns out to have is a different bug: the excuse is wrong, not stale.
|
||||
let mut wrong: Vec<&str> = allowed_beyond_1_12()
|
||||
.into_iter()
|
||||
.filter(|n| known.contains(*n))
|
||||
.collect();
|
||||
wrong.sort_unstable();
|
||||
assert!(
|
||||
wrong.is_empty(),
|
||||
"these are excused as beyond-1.12 but the 1.12.1 client DOES have them — they need no \
|
||||
excuse:\n {}",
|
||||
wrong.join(" ")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! `OnSizeChanged` — the base-map layout script (`0x76a0d0` `+0x120`), fired from the resolve pass.
|
||||
//!
|
||||
//! Two things are being pinned here, and they are different in kind. The **gate** is byte-verified:
|
||||
//! `ApplyRect 0x76b580` fires iff `|Δwidth| ≥ ε ∨ |Δheight| ≥ ε` with `ε = _DAT_008029d4`
|
||||
//! ([`crate::layout::SIZE_EPS`]), which is why a frame that merely MOVES fires nothing. The
|
||||
//! **seam** is ours: the client applies rects one at a time and fires per application, benilla
|
||||
//! solves the whole graph to a fixpoint, so we fire on the entry-vs-convergence diff — one fire per
|
||||
//! resolve per frame whose size actually moved, never one per solver round.
|
||||
|
||||
use super::common::script;
|
||||
|
||||
/// The size moves ⇒ `OnSizeChanged(self, width, height)` fires with the NEW size; a pure move fires
|
||||
/// nothing (the byte-verified gate is on width/height, not on the rect).
|
||||
#[test]
|
||||
fn a_resize_fires_on_size_changed_and_a_move_does_not() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
fires, w, h = 0, nil, nil
|
||||
Panel = CreateFrame("Frame", "SizedPanel")
|
||||
Panel:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
Panel:SetSize(200, 50)
|
||||
Panel:SetScript("OnSizeChanged", function(self, aw, ah)
|
||||
fires = fires + 1 w = aw h = ah
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The FIRST layout is a change too: the client's cached rect starts zeroed, so its first
|
||||
// ApplyRect is 0×0 → 200×50 and does fire.
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
assert_eq!(s.eval::<f64>("return w").unwrap(), 200.0);
|
||||
assert_eq!(s.eval::<f64>("return h").unwrap(), 50.0);
|
||||
|
||||
// A second resolve with nothing touched fires nothing — the size did not move.
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
|
||||
// A MOVE is not a resize.
|
||||
s.run(r#"Panel:SetPoint("BOTTOMLEFT", 400, 300)"#).unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fires").unwrap(),
|
||||
1,
|
||||
"the rect moved but width/height did not — ApplyRect's gate is on the size alone"
|
||||
);
|
||||
|
||||
// A real resize does.
|
||||
s.run("Panel:SetHeight(120)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
|
||||
assert_eq!(s.eval::<f64>("return w").unwrap(), 200.0);
|
||||
assert_eq!(s.eval::<f64>("return h").unwrap(), 120.0);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// It fires for a size the frame did not set on itself — the case the corpus actually uses it for
|
||||
/// (`MoveAnything`, `CleanMinimap`, `FuBar`, `Outfitter`, `AckisRecipeList` all watch a frame whose
|
||||
/// size is decided by something else). Here the child is anchored to two edges of a parent that
|
||||
/// resizes, so its own `LayoutInput` never changes at all and only the SOLVE knows it moved.
|
||||
#[test]
|
||||
fn an_anchor_driven_resize_fires_it_too() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
fires, w = 0, nil
|
||||
Parent = CreateFrame("Frame", "SizeParent")
|
||||
Parent:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
Parent:SetSize(300, 200)
|
||||
Child = CreateFrame("Frame", "SizeChild", Parent)
|
||||
Child:SetPoint("BOTTOMLEFT", Parent, "BOTTOMLEFT", 0, 0)
|
||||
Child:SetPoint("TOPRIGHT", Parent, "TOPRIGHT", 0, 0)
|
||||
Child:SetScript("OnSizeChanged", function(self, aw, ah) fires = fires + 1 w = aw end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<f64>("return w").unwrap(), 300.0);
|
||||
let first = s.eval::<i64>("return fires").unwrap();
|
||||
|
||||
s.run("Parent:SetWidth(500)").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fires").unwrap(),
|
||||
first + 1,
|
||||
"the child's own inputs never changed — only the parent's rect did"
|
||||
);
|
||||
assert_eq!(s.eval::<f64>("return w").unwrap(), 500.0);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The loop guard.** A handler that resizes its own frame is the classic `OnSizeChanged` hazard,
|
||||
/// and it must not spin the engine. The drain takes ONE batch per resolve
|
||||
/// ([`crate::script::event::fire_size_changes`]), so a handler that settles on a constant size
|
||||
/// settles — one extra fire for the size it forced, then silence — and one that never settles costs
|
||||
/// one fire per resolve rather than hanging inside a single call.
|
||||
#[test]
|
||||
fn a_handler_that_resizes_its_own_frame_settles_instead_of_spinning() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
fires = 0
|
||||
Sq = CreateFrame("Frame", "SquarePanel")
|
||||
Sq:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
Sq:SetSize(200, 50)
|
||||
-- The idiom: "keep me square". It writes back into the very input that fired it.
|
||||
Sq:SetScript("OnSizeChanged", function(self, aw, ah)
|
||||
fires = fires + 1
|
||||
if ah ~= aw then self:SetHeight(aw) end
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Resolve returns (does not hang) even though the handler dirties the layout from inside it.
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 1);
|
||||
// The next resolve sees the handler's own 200×200 and fires once for it…
|
||||
s.resolve();
|
||||
assert_eq!(s.eval::<i64>("return fires").unwrap(), 2);
|
||||
assert_eq!(s.eval::<f64>("return Sq:GetHeight()").unwrap(), 200.0);
|
||||
// …and then it is stable: no further fires, however many passes run.
|
||||
for _ in 0..5 {
|
||||
s.resolve();
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return fires").unwrap(),
|
||||
2,
|
||||
"a settling handler settles — the fixpoint is reached, not re-entered forever"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The regression guard that makes the `SCRIPT_KINDS` row real: `SetScript("OnSizeChanged", …)`
|
||||
/// stopped raising, and what replaced the error is a handler that actually runs — not a stored
|
||||
/// closure nobody ever calls.
|
||||
#[test]
|
||||
fn set_script_on_size_changed_is_accepted_because_the_resolve_pass_fires_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
ran = false
|
||||
Accepted = CreateFrame("Frame", "SizeAccepted")
|
||||
Accepted:SetPoint("BOTTOMLEFT", 0, 0)
|
||||
Accepted:SetSize(10, 10)
|
||||
Accepted:SetScript("OnSizeChanged", function() ran = true end)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>(r#"return Accepted:GetScript("OnSizeChanged") ~= nil"#)
|
||||
.unwrap());
|
||||
s.resolve();
|
||||
assert!(s.eval::<bool>("return ran").unwrap(), "…and it FIRED");
|
||||
}
|
||||
@@ -126,3 +126,88 @@ fn gettime_starts_at_zero_and_tracks_tick() {
|
||||
let t = s.eval::<f64>("return GetTime()").unwrap();
|
||||
assert!((t - 0.5).abs() < 1e-6, "two 0.25s ticks = 0.5 (got {t})");
|
||||
}
|
||||
|
||||
/// The rest of the bare globals Blizzard's own restricted scope enumerates (decision 1187).
|
||||
///
|
||||
/// The list is not from memory: `Blizzard_RestrictedAddOnEnvironment/RestrictedEnvironment.lua`
|
||||
/// copies exactly these out of the engine, which is what distinguishes them from the table helpers
|
||||
/// (`tContains`, `tInvert`, …) that FrameXML defines in Lua and we must therefore NOT supply.
|
||||
///
|
||||
/// The measured cost of their absence: Bagnon + BagBrother produced 94 load failures, 81 of them
|
||||
/// `Cannot find a library instance of "…"`, because `strmatch` was nil so every LibStub-registering
|
||||
/// library aborted before it could register.
|
||||
#[test]
|
||||
fn the_rest_of_the_bare_globals() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r#"
|
||||
-- string family
|
||||
assert(strbyte("A") == 65 and strchar(65) == "A")
|
||||
|
||||
-- and the Era-only names 1187 added are GONE (decision 1189): the 5.0 client has no
|
||||
-- string.match/gmatch, and claiming otherwise misleads an addon that feature-detects.
|
||||
assert(strmatch == nil and gmatch == nil and strrev == nil)
|
||||
assert(strlenutf8 == nil and strcmputf8i == nil)
|
||||
assert(securecall == nil and hooksecurefunc == nil and issecure == nil)
|
||||
|
||||
-- math family
|
||||
assert(exp(0) == 1 and log(1) == 0 and log10(100) == 2)
|
||||
assert(frexp(8) == 0.5 and ldexp(0.5, 4) == 8)
|
||||
|
||||
-- the trig globals are DEGREE-based, inverses included — same family as the verified
|
||||
-- sin/cos, and what every addon rotation helper assumes.
|
||||
assert(math.abs(tan(45) - 1) < 1e-9)
|
||||
assert(math.abs(asin(1) - 90) < 1e-9)
|
||||
assert(math.abs(acos(1)) < 1e-9)
|
||||
assert(math.abs(atan(1) - 45) < 1e-9)
|
||||
assert(math.abs(atan2(1, 0) - 90) < 1e-9)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// **The Lua 5.0 dialect a vanilla addon is written in runs on our 5.1 VM** — measured, because
|
||||
/// this question has been answered three different ways from memory.
|
||||
///
|
||||
/// 1.12 runs Lua 5.0 (byte-confirmed in wow-5875-re: `0x811b30 = "Lua: Lua 5.0 Copyright..."`);
|
||||
/// we run 5.1 via mlua's `lua51`. Decision 1188 called that "the deepest divergence and it is
|
||||
/// unresolved" and told the next session to test it; 1189 replied that 0068 had already closed it.
|
||||
/// Meanwhile five of our own transcribed FrameXML files carried the opposite claim in a comment —
|
||||
/// *"`LUA_COMPAT_VARARG` isn't shipped"* — each citing the others as precedent, so a false fact
|
||||
/// propagated by citation without anyone re-running it.
|
||||
///
|
||||
/// It is shipped. This asserts the five idioms vanilla addon code actually uses, so the next
|
||||
/// session reads a result instead of a recollection. **The implicit `arg` table is the one that
|
||||
/// matters**: it is the difference between a vanilla addon's vararg functions working and every
|
||||
/// one of them erroring at runtime, which is 1188's stated redirect-the-arc risk.
|
||||
#[test]
|
||||
fn the_lua_5_0_dialect_vanilla_addons_are_written_in_runs_here() {
|
||||
let s = script();
|
||||
s.run(
|
||||
r##"
|
||||
-- The implicit vararg table, 5.0's spelling of what 5.1 does with `...`.
|
||||
local function varargs(...) return arg.n, arg[1], arg[2] end
|
||||
local n, first, second = varargs("a", "b")
|
||||
assert(n == 2 and first == "a" and second == "b")
|
||||
|
||||
-- **The one real edge**, and it is compat mode's rule rather than a gap: `arg` is
|
||||
-- synthesized only for a vararg function that does NOT also mention `...` in its body.
|
||||
-- Use both spellings in one function and `arg` is nil. Vanilla addon code is uniformly
|
||||
-- 5.0 and never mixes them, so this costs an addon nothing — it only means one of OUR
|
||||
-- transcriptions must pick a spelling per function and stay with it.
|
||||
local function mixed(...) return arg == nil and select("#", ...) or -1 end
|
||||
assert(mixed(1, 2, 3) == 3)
|
||||
|
||||
-- `...` alone is the 5.1 spelling and is unaffected.
|
||||
local function modern(...) return select("#", ...) end
|
||||
assert(modern("x", "y") == 2)
|
||||
|
||||
-- 5.0's table/string/math spellings, all of which 5.1 renamed.
|
||||
assert(table.getn({ 1, 2, 3 }) == 3)
|
||||
assert(string.gfind ~= nil) -- 5.1 renamed this to string.gmatch
|
||||
assert(math.mod(7, 3) == 1)
|
||||
assert(7 % 3 == 1) -- and the 5.1 operator 5.0 lacks also works
|
||||
"##,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ fn set_tex_coord_changes_extracted_uv() {
|
||||
}
|
||||
|
||||
/// `SetFontObject("Name")` copies a registered [`FontObject`]'s face/height/color onto the
|
||||
/// FontString; `GetFontObject` reads the name back; a later call re-points it (the resolved paint on
|
||||
/// the extracted quad follows). An unknown name errors.
|
||||
/// FontString; `GetFontObject` hands back the OBJECT (whose `GetName` round-trips); a later call
|
||||
/// re-points it (the resolved paint on the extracted quad follows). An unknown name errors.
|
||||
#[test]
|
||||
fn set_font_object_repoints_fontstring() {
|
||||
let mut s = script();
|
||||
@@ -112,7 +112,8 @@ fn set_font_object_repoints_fontstring() {
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return fs:GetFontObject()").unwrap(),
|
||||
s.eval::<String>("return fs:GetFontObject():GetName()")
|
||||
.unwrap(),
|
||||
"Big"
|
||||
);
|
||||
|
||||
@@ -188,7 +189,16 @@ fn justify_v_defaults_middle_and_overrides() {
|
||||
s.resolve();
|
||||
assert_eq!(justify_v(&s), JustifyV::Bottom);
|
||||
|
||||
// A font object carrying justify_v re-points it.
|
||||
// A font object carrying justify_v applies it — but ONLY on the axis this string has not
|
||||
// already claimed for itself. `SetJustifyV` above severed the V axis, and §5-verified that
|
||||
// severance is permanent: the reference clears the axis's inheritMask bit (`+0x124`, the
|
||||
// per-axis justify mask — wow-re `system/ui/scratch/font-object-lua-surface.md`) and never
|
||||
// restores it, so a later `SetFontObject` cannot take the axis back.
|
||||
//
|
||||
// This assertion used to read `Top`. That was our copy-everything model, not the client's; the
|
||||
// shipped XML path is unaffected either way, because the loader applies `inherits=` BEFORE the
|
||||
// element's own `justifyV=` (`Loader::apply_fontstring_font`), so the explicit attribute still
|
||||
// wins.
|
||||
s.register_font_object(
|
||||
"TopFont",
|
||||
FontObject {
|
||||
@@ -203,5 +213,29 @@ fn justify_v_defaults_middle_and_overrides() {
|
||||
);
|
||||
s.run("fs:SetFontObject('TopFont')").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(justify_v(&s), JustifyV::Top);
|
||||
assert_eq!(justify_v(&s), JustifyV::Bottom, "severance is permanent");
|
||||
|
||||
// A string that never claimed the axis DOES take the object's justification.
|
||||
s.run(
|
||||
r#"
|
||||
fresh = f:CreateFontString(nil, "ARTWORK")
|
||||
fresh:SetText("Fresh")
|
||||
fresh:SetFontObject('TopFont')
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
let fresh = s
|
||||
.extract()
|
||||
.into_iter()
|
||||
.find_map(|q| match q.content {
|
||||
QuadContent::Text {
|
||||
text: Some(ref t),
|
||||
justify_v,
|
||||
..
|
||||
} if t == "Fresh" => Some(justify_v),
|
||||
_ => None,
|
||||
})
|
||||
.expect("a text quad");
|
||||
assert_eq!(fresh, JustifyV::Top);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
name: Some("Arcane Intellect".into()),
|
||||
duration: 1800.0,
|
||||
expiration_time: 100.0, // 90 s left at now = 10
|
||||
helpful: true, // the HELPFUL-default filter indexes buffs
|
||||
helpful: true,
|
||||
..Default::default()
|
||||
}]),
|
||||
);
|
||||
@@ -256,7 +256,8 @@ fn player_buff_hover_is_the_aura_variant() {
|
||||
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetPlayerBuff(1)
|
||||
-- 0, not 1: SetPlayerBuff takes a 1.12 CACHE POSITION (see `script::aura`'s header).
|
||||
tt:SetPlayerBuff(0)
|
||||
assert(tt:NumLines() == 3, "name + description + remaining, got " .. tt:NumLines())
|
||||
assert(TTTextLeft2:GetText() == "Intellect increased by 2.", "the AURA description column")
|
||||
assert(TTTextLeft3:GetText() == "2 minutes remaining", "got " .. TTTextLeft3:GetText())
|
||||
@@ -313,7 +314,7 @@ fn player_buff_hover_names_the_dispel_class_in_gold() {
|
||||
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetPlayerBuff(1)
|
||||
tt:SetPlayerBuff(0)
|
||||
assert(TTTextRight1:GetText() == "Magic",
|
||||
"the dispel class, got " .. tostring(TTTextRight1:GetText()))
|
||||
"#,
|
||||
@@ -458,6 +459,91 @@ fn unit_buff_and_debuff_hover_render_the_aura_variant_without_remaining() {
|
||||
assert!(s.take_errors().is_empty());
|
||||
}
|
||||
|
||||
/// **`SetPlayerBuff` reads a 1.12 cache position, not an Era ordinal** — the index space the whole
|
||||
/// `GetPlayerBuff*` family shares (`script::aura`'s header; pinned by `ref-BuffFrame.lua:105`,
|
||||
/// which passes `GetPlayerBuff`'s return straight in).
|
||||
///
|
||||
/// Every assertion here is a case the previous 1-based, sign-filtered reading got wrong, and got
|
||||
/// wrong *silently* — the plate still rendered, just for the wrong aura:
|
||||
///
|
||||
/// - position 1 is the SECOND aura, not the first again;
|
||||
/// - a debuff is reachable at all (the old sign filter defaulted to helpful, so no position ever
|
||||
/// resolved to one);
|
||||
/// - `-1` clears instead of showing the first buff. `BigWigs/Raids/Naxxramas/Loatheb.lua:260-271`
|
||||
/// feeds an unchecked `GetPlayerBuff(i, "HARMFUL")` in and breaks its scan when line 1 goes nil,
|
||||
/// so "shows something" there is an addon that never stops scanning;
|
||||
/// - a surplus filter argument is ignored (`CT_BuffMod/CT_BuffFrame.lua:151` passes one).
|
||||
#[test]
|
||||
fn player_buff_hover_indexes_the_cache_position_not_a_filtered_ordinal() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
for (id, name, desc) in [
|
||||
(1126, "Mark of the Wild", "Armor increased by 25."),
|
||||
(2457, "Battle Stance", "A balanced combat stance."),
|
||||
(589, "Shadow Word: Pain", "Suffering 12 Shadow damage."),
|
||||
] {
|
||||
s.set_spell_tooltip(
|
||||
id,
|
||||
SpellTooltipView {
|
||||
name: name.into(),
|
||||
aura_description: desc.into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
// The player's cache: two buffs then a debuff, ONE list, insertion-ordered (decision 0257).
|
||||
s.set_auras(
|
||||
"player",
|
||||
Some(vec![
|
||||
AuraState {
|
||||
spell_id: 1126,
|
||||
name: Some("Mark of the Wild".into()),
|
||||
helpful: true,
|
||||
..Default::default()
|
||||
},
|
||||
AuraState {
|
||||
spell_id: 2457,
|
||||
name: Some("Battle Stance".into()),
|
||||
helpful: true,
|
||||
..Default::default()
|
||||
},
|
||||
AuraState {
|
||||
spell_id: 589,
|
||||
name: Some("Shadow Word: Pain".into()),
|
||||
helpful: false,
|
||||
..Default::default()
|
||||
},
|
||||
]),
|
||||
);
|
||||
s.run(
|
||||
r#"
|
||||
local a = CreateFrame("Button", "BF1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
|
||||
local tt = CreateFrame("GameTooltip", "TT")
|
||||
local function at(i, extra)
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT")
|
||||
tt:SetPlayerBuff(i, extra)
|
||||
return TTTextLeft1 and TTTextLeft1:GetText()
|
||||
end
|
||||
assert(at(0) == "Mark of the Wild", "position 0 is the first aura, got " .. tostring(at(0)))
|
||||
assert(at(1) == "Battle Stance", "position 1 is the SECOND, got " .. tostring(at(1)))
|
||||
assert(at(2) == "Shadow Word: Pain", "the debuff shares the index space, got " .. tostring(at(2)))
|
||||
-- The handle really is GetPlayerBuff's return, round-tripped.
|
||||
assert(at(GetPlayerBuff(0, "HARMFUL")) == "Shadow Word: Pain", "round-trip through GetPlayerBuff")
|
||||
-- A surplus filter argument is ignored, as the reference's binding ignores it.
|
||||
assert(at(1, "HELPFUL|HARMFUL") == "Battle Stance", "surplus argument must be ignored")
|
||||
-- Misses CLEAR (Loatheb's scan terminates on a nil first line) rather than leaving a plate.
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetPlayerBuff(3)
|
||||
assert(tt:NumLines() == 0 and not tt:IsShown(), "past the cache: no plate")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetPlayerBuff(-1)
|
||||
assert(tt:NumLines() == 0 and not tt:IsShown(), "-1 must clear, not show the first buff")
|
||||
tt:SetOwner(a, "ANCHOR_RIGHT"); tt:SetPlayerBuff(GetPlayerBuff(9, "HARMFUL"))
|
||||
assert(tt:NumLines() == 0 and not tt:IsShown(), "an unguarded -1 from GetPlayerBuff clears")
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.take_errors().is_empty(), "{:?}", s.take_errors());
|
||||
}
|
||||
|
||||
/// SetAction delegates by payload kind: a SPELL slot renders the spell view; an empty slot shows
|
||||
/// nothing; a miss records the ask.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! The `toplevel` flag and the raise law — `SetToplevel`/`IsToplevel`, `Raise`/`Lower`, and the
|
||||
//! raise itself with its real trigger (Show) and its real gate (occlusion). The mechanism, the byte
|
||||
//! addresses and the clause-by-clause mapping onto our order model are in `script::object::toplevel`.
|
||||
//!
|
||||
//! Every test drives the PRODUCTION path — the Lua bindings, the loader, and the real
|
||||
//! `mouse_button`/`mouse_move` entry points — and asserts on the two things that are actually
|
||||
//! observable: `GetFrameLevel()` (the arithmetic) and the extracted painter order (the pixels).
|
||||
|
||||
use super::common::script;
|
||||
use crate::script::{QuadContent, UiScript};
|
||||
|
||||
/// The order textures actually paint in, by their texture path — the render list `extract` returns
|
||||
/// IS the draw order (`order::traversal`'s ZKey sort).
|
||||
fn painted(s: &mut UiScript) -> Vec<String> {
|
||||
s.resolve();
|
||||
s.extract()
|
||||
.iter()
|
||||
.filter_map(|q| match &q.content {
|
||||
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn level(s: &mut UiScript, frame: &str) -> i64 {
|
||||
s.eval::<i64>(&format!("return {frame}:GetFrameLevel()"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Two overlapping MEDIUM frames: `Board` — already on screen and sitting at frame level **5** —
|
||||
/// and `Dialog`, born at level 0 and hidden.
|
||||
///
|
||||
/// The level split is the point. A frame's link stamp already lifts it within its bucket when it is
|
||||
/// shown (`resequence_to_tail`, decision 0557), so a same-level sibling would prove nothing about
|
||||
/// the raise; **level outranks the link stamp** in the draw key, so nothing but a real level bump
|
||||
/// can get `Dialog` over `Board`. This is the shape of the complaint the law answers: a dialog
|
||||
/// opening behind a window that happens to sit higher in its stratum.
|
||||
fn board_and_dialog() -> UiScript {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Board = CreateFrame("Frame", "Board")
|
||||
Board:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
Board:SetSize(300, 300)
|
||||
Board:SetFrameLevel(5)
|
||||
Board:CreateTexture(nil, "ARTWORK"):SetTexture("Board.blp")
|
||||
|
||||
Dialog = CreateFrame("Frame", "Dialog")
|
||||
Dialog:SetPoint("BOTTOMLEFT", 200, 200) -- overlaps Board's top-right quadrant
|
||||
Dialog:SetSize(300, 300)
|
||||
Dialog:CreateTexture(nil, "ARTWORK"):SetTexture("Dialog.blp")
|
||||
Dialog:Hide()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s
|
||||
}
|
||||
|
||||
// ── The flag ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `SetToplevel`/`IsToplevel` round-trip, including the reference's **default-true** optional
|
||||
/// argument (`0x775440` marshals an optional boolean defaulting to true, unlike `SetMovable`).
|
||||
/// Nothing is born toplevel.
|
||||
#[test]
|
||||
fn the_flag_round_trips_and_defaults_off() {
|
||||
let s = script();
|
||||
s.run(r#"F = CreateFrame("Frame", "F")"#).unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return F:IsToplevel()").unwrap(),
|
||||
"no frame is born toplevel"
|
||||
);
|
||||
s.run("F:SetToplevel(true)").unwrap();
|
||||
assert!(s.eval::<bool>("return F:IsToplevel()").unwrap());
|
||||
s.run("F:SetToplevel(false)").unwrap();
|
||||
assert!(!s.eval::<bool>("return F:IsToplevel()").unwrap());
|
||||
// The omitted argument is TRUE, not false — the reference's `SetToplevel()` turns the bit ON.
|
||||
s.run("F:SetToplevel()").unwrap();
|
||||
assert!(
|
||||
s.eval::<bool>("return F:IsToplevel()").unwrap(),
|
||||
"SetToplevel() with no argument sets the bit"
|
||||
);
|
||||
// Lua truthiness, like every other flag setter here: the corpus writes SetToplevel(1).
|
||||
s.run("F:SetToplevel(nil); F:SetToplevel(1)").unwrap();
|
||||
assert!(s.eval::<bool>("return F:IsToplevel()").unwrap());
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `toplevel="true"` in XML lands on the real method — it was a warn-once gap in the loader until
|
||||
/// this law existed, which left every window authored toplevel (thirteen of our own frames, 82
|
||||
/// corpus addons) unable to come to the front while `SetToplevel` did not exist at all.
|
||||
///
|
||||
/// `enableKeyboard` is deliberately still warned: implementing one clause is not a licence to go
|
||||
/// quiet about the other.
|
||||
#[test]
|
||||
fn the_xml_toplevel_attribute_reaches_the_method_and_enable_keyboard_still_warns() {
|
||||
let s = script();
|
||||
let doc = crate::framexml::parse(
|
||||
r#"<Ui>
|
||||
<Frame name="XmlTop" toplevel="true" enableKeyboard="true">
|
||||
<Size><AbsDimension x="100" y="50"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="10" y="10"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Frame>
|
||||
</Ui>"#,
|
||||
)
|
||||
.expect("valid FrameXML");
|
||||
let report = crate::loader::load(&s, &doc, &|_| None);
|
||||
assert!(
|
||||
!report.warnings.iter().any(|w| w.contains("SetToplevel")),
|
||||
"no gap warning any more: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
assert!(
|
||||
report.warnings.iter().any(|w| w.contains("EnableKeyboard")),
|
||||
"the clause we did NOT build stays loud: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
assert!(s.eval::<bool>("return XmlTop:IsToplevel()").unwrap());
|
||||
}
|
||||
|
||||
// ── The raise on Show ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The control, and the behaviour before this landed: a **non-toplevel** frame is shown and moves
|
||||
/// nowhere. `Dialog` paints under `Board` because its level is lower, exactly as the draw key says.
|
||||
#[test]
|
||||
fn a_non_toplevel_frame_does_not_move_when_shown() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run("Dialog:Show()").unwrap();
|
||||
assert_eq!(level(&mut s, "Dialog"), 0, "no raise, no level change");
|
||||
assert_eq!(level(&mut s, "Board"), 5, "and no compaction either");
|
||||
assert_eq!(
|
||||
painted(&mut s),
|
||||
vec!["Dialog.blp".to_string(), "Board.blp".to_string()],
|
||||
"the dialog opens BEHIND the window it overlaps"
|
||||
);
|
||||
}
|
||||
|
||||
/// The headline: the same frame, marked `toplevel`, comes to the front on **Show** — the trigger
|
||||
/// the law names (`effective_visible_show 0x76ae10` @`0x76aee0`), not on a click.
|
||||
///
|
||||
/// The arithmetic is pinned here because it is the whole mapping onto our order model. Visible
|
||||
/// MEDIUM levels are `{0 (Dialog), 5 (Board)}`; `level_compact 0x764eb0` renumbers those occupied
|
||||
/// levels contiguously into `[0, 2)` — Board 5 → 1 — and the raise then writes
|
||||
/// `level := bucket->count` = **2**. Not a live max-scan (`5 + 1`), not a re-stamp: one above the
|
||||
/// top *occupied* level, counted after the compaction.
|
||||
#[test]
|
||||
fn a_raise_is_top_occupied_level_plus_one_after_compaction() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run("Dialog:SetToplevel(true)").unwrap();
|
||||
s.run("Dialog:Show()").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
level(&mut s, "Board"),
|
||||
1,
|
||||
"compaction renumbered the stratum's occupied levels into [0, count)"
|
||||
);
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
2,
|
||||
"the raise is bucket->count, read AFTER the compaction"
|
||||
);
|
||||
assert_eq!(
|
||||
painted(&mut s),
|
||||
vec!["Board.blp".to_string(), "Dialog.blp".to_string()],
|
||||
"the dialog is now in front"
|
||||
);
|
||||
}
|
||||
|
||||
/// The trigger is the **`effectiveVisible` false→true transition**, not the `Show()` call: a frame
|
||||
/// whose own `shown` bit is already set raises when an ancestor's Show finally makes it visible, and
|
||||
/// a `Show()` that changes no effective visibility raises nothing.
|
||||
///
|
||||
/// The setup order is load-bearing and is worth reading as the law's own proof. `Holder` is hidden
|
||||
/// *before* `Dialog:Show()`, so that Show is not a transition — leave it visible and `Dialog` raises
|
||||
/// there instead, ends up above everything, and the transition under test then correctly declines
|
||||
/// because nothing is left to raise over. (That mis-ordering is what the first draft of this test
|
||||
/// did; the gate was right and the fixture was wrong.)
|
||||
#[test]
|
||||
fn the_trigger_is_the_effective_visibility_transition_not_the_show_call() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Holder = CreateFrame("Frame", "Holder")
|
||||
Holder:SetPoint("BOTTOMLEFT", 200, 200)
|
||||
Holder:SetSize(300, 300)
|
||||
Holder:Hide()
|
||||
Dialog:SetParent(Holder)
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:Show() -- own bit set, parent hidden: NOT effective-visible, no transition
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
0,
|
||||
"a Show that moves no effective visibility raises nothing"
|
||||
);
|
||||
assert_eq!(level(&mut s, "Board"), 5, "and compacts nothing");
|
||||
|
||||
s.run("Holder:Show()").unwrap();
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
2,
|
||||
"Dialog became effective-visible through its parent's Show and raised itself"
|
||||
);
|
||||
assert_eq!(level(&mut s, "Board"), 1, "the compaction ran with it");
|
||||
}
|
||||
|
||||
// ── The gate ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// **Occlusion-gated**: a toplevel frame that overlaps nothing changes nothing at all. The gate sits
|
||||
/// *before* the compaction in `0x7650f0`, so a declined raise leaves the whole stratum's levels
|
||||
/// untouched too — `Board` keeps its authored 5.
|
||||
#[test]
|
||||
fn a_raise_on_a_frame_that_overlaps_nothing_is_a_total_no_op() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Dialog:ClearAllPoints()
|
||||
Dialog:SetPoint("BOTTOMLEFT", 600, 450) -- clear of Board's (100,100)-(400,400)
|
||||
Dialog:SetSize(100, 100)
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(level(&mut s, "Dialog"), 0, "nothing to raise over");
|
||||
assert_eq!(
|
||||
level(&mut s, "Board"),
|
||||
5,
|
||||
"and the gate declined before compaction"
|
||||
);
|
||||
}
|
||||
|
||||
/// The scan runs from the frame's **own level upward** (`for lvl = T->+0xc4; lvl < bucket->count`).
|
||||
/// A toplevel frame whose only overlap is with something *below* it is already in front of it, so
|
||||
/// there is nothing to raise over.
|
||||
#[test]
|
||||
fn the_scan_ignores_frames_below_the_raised_frames_own_level() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Board:SetFrameLevel(3)
|
||||
Dialog:SetFrameLevel(5)
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
5,
|
||||
"the overlap is below it; no raise"
|
||||
);
|
||||
assert_eq!(level(&mut s, "Board"), 3);
|
||||
}
|
||||
|
||||
/// A window overlaps its own children by construction, so the scan excludes the raised frame's
|
||||
/// subtree (`is_descendant 0x767010`). Without that clause every toplevel frame with content in it
|
||||
/// would raise on every trigger.
|
||||
#[test]
|
||||
fn the_scan_excludes_the_raised_frames_own_subtree() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Board:Hide() -- leave the dialog alone on screen
|
||||
Dialog:SetToplevel(true)
|
||||
Inner = CreateFrame("Frame", "Inner", Dialog)
|
||||
Inner:SetAllPoints() -- exactly covers its parent
|
||||
Dialog:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
level(&mut s, "Dialog"),
|
||||
0,
|
||||
"its own child is not an occlusion"
|
||||
);
|
||||
}
|
||||
|
||||
/// A raise **never changes the stratum** — `0x7650f0` never writes `+0xc0`. A LOW toplevel frame
|
||||
/// raises over its LOW neighbours and stays under every MEDIUM frame, however many times it is
|
||||
/// raised. (This is the mechanism that could not have fixed the party-frame-through-loot-window
|
||||
/// bug: wow-re's CLAIM 2, refuted.)
|
||||
#[test]
|
||||
fn a_raise_can_never_lift_a_frame_out_of_its_stratum() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
Med = CreateFrame("Frame", "Med")
|
||||
Med:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
Med:SetSize(300, 300)
|
||||
Med:CreateTexture(nil, "ARTWORK"):SetTexture("Med.blp")
|
||||
|
||||
LowOther = CreateFrame("Frame", "LowOther")
|
||||
LowOther:SetFrameStrata("LOW")
|
||||
LowOther:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
LowOther:SetSize(300, 300)
|
||||
LowOther:SetFrameLevel(4)
|
||||
LowOther:CreateTexture(nil, "ARTWORK"):SetTexture("LowOther.blp")
|
||||
|
||||
LowTop = CreateFrame("Frame", "LowTop")
|
||||
LowTop:SetFrameStrata("LOW")
|
||||
LowTop:SetPoint("BOTTOMLEFT", 150, 150)
|
||||
LowTop:SetSize(300, 300)
|
||||
LowTop:SetToplevel(true)
|
||||
LowTop:CreateTexture(nil, "ARTWORK"):SetTexture("LowTop.blp")
|
||||
LowTop:Hide()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
s.run("LowTop:Show()").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return LowTop:GetFrameStrata()").unwrap(),
|
||||
"LOW",
|
||||
"the raise never writes the stratum"
|
||||
);
|
||||
assert!(
|
||||
level(&mut s, "LowTop") > level(&mut s, "LowOther"),
|
||||
"it did raise, within LOW"
|
||||
);
|
||||
assert_eq!(
|
||||
painted(&mut s),
|
||||
vec![
|
||||
"LowOther.blp".to_string(),
|
||||
"LowTop.blp".to_string(),
|
||||
"Med.blp".to_string(),
|
||||
],
|
||||
"still under every MEDIUM frame — a stratum is not something a raise can cross"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Propagation and compaction ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `propagate = 1` shifts **same-strata** children by the same delta (`0x76a4f0` @`0x76a58a`), so
|
||||
/// the raised subtree keeps its internal order; cross-strata children are untouched
|
||||
/// (`0x76a582: cmp …; jne`).
|
||||
///
|
||||
/// The arithmetic in full. Visible MEDIUM levels before the raise are `{0 Dialog, 1 Kid, 2 Grandkid,
|
||||
/// 5 Board}`; compaction maps them to `{0, 1, 2, 3}` (Board 5 → 3) and reports `count = 4`. The
|
||||
/// raise writes `Dialog := 4`, i.e. `delta = +4`, and pushes that same +4 into Kid (1 → 5) and
|
||||
/// Grandkid (2 → 6) — gaps and order preserved. `Cross`, moved to the DIALOG stratum, keeps its 1.
|
||||
#[test]
|
||||
fn the_raised_subtree_shifts_by_one_delta_and_keeps_its_internal_order() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Kid = CreateFrame("Frame", "Kid", Dialog) -- level 1 (parent + 1)
|
||||
Grandkid = CreateFrame("Frame", "Grandkid", Kid) -- level 2
|
||||
Cross = CreateFrame("Frame", "Cross", Dialog) -- level 1, then a different stratum
|
||||
Cross:SetFrameStrata("DIALOG")
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:Show()
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(level(&mut s, "Board"), 3, "compacted 5 -> 3");
|
||||
assert_eq!(level(&mut s, "Dialog"), 4, "raised to bucket->count");
|
||||
assert_eq!(level(&mut s, "Kid"), 5, "same delta (+4) as its parent");
|
||||
assert_eq!(level(&mut s, "Grandkid"), 6, "and so does the grandchild");
|
||||
assert_eq!(
|
||||
level(&mut s, "Cross"),
|
||||
1,
|
||||
"a cross-strata child is skipped by the propagate"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return Cross:GetFrameStrata()").unwrap(),
|
||||
"DIALOG"
|
||||
);
|
||||
}
|
||||
|
||||
/// Compaction is what keeps `level := top + 1` from ratcheting. Two overlapping toplevel windows
|
||||
/// traded back and forth twenty times settle inside a two-level band instead of climbing one step
|
||||
/// per show — and the frame shown last is on top every single time.
|
||||
///
|
||||
/// Without `level_compact 0x764eb0` this test ends at level 20, and a long session ends at
|
||||
/// `u16::MAX`.
|
||||
#[test]
|
||||
fn compaction_bounds_the_raise_across_repeated_shows() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
s.run(
|
||||
r#"
|
||||
A = CreateFrame("Frame", "A")
|
||||
A:SetPoint("BOTTOMLEFT", 100, 100)
|
||||
A:SetSize(300, 300)
|
||||
A:SetToplevel(true)
|
||||
B = CreateFrame("Frame", "B")
|
||||
B:SetPoint("BOTTOMLEFT", 200, 200)
|
||||
B:SetSize(300, 300)
|
||||
B:SetToplevel(true)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
|
||||
for _ in 0..10 {
|
||||
s.run("A:Hide(); A:Show()").unwrap();
|
||||
assert!(
|
||||
level(&mut s, "A") > level(&mut s, "B"),
|
||||
"the window just shown is in front"
|
||||
);
|
||||
s.run("B:Hide(); B:Show()").unwrap();
|
||||
assert!(level(&mut s, "B") > level(&mut s, "A"));
|
||||
}
|
||||
assert!(
|
||||
level(&mut s, "A") <= 2 && level(&mut s, "B") <= 2,
|
||||
"twenty raises stay inside the band compaction defines, not 20 levels up: A={} B={}",
|
||||
level(&mut s, "A"),
|
||||
level(&mut s, "B")
|
||||
);
|
||||
}
|
||||
|
||||
// ── The Lua verbs ───────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `Frame:Raise()` — the explicit script call (`0x775a50` → `0x76a5b0` → `0x7650f0(force = 1)`).
|
||||
/// Called on a **non-toplevel** frame it acts on the nearest toplevel ancestor, and on nothing at
|
||||
/// all when there is none: the gate lives in the worker, so no call site has to check.
|
||||
#[test]
|
||||
fn lua_raise_acts_on_the_nearest_toplevel_ancestor_and_is_silent_without_one() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:Show()
|
||||
Kid = CreateFrame("Frame", "Kid", Dialog)
|
||||
Loose = CreateFrame("Frame", "Loose")
|
||||
Loose:SetPoint("BOTTOMLEFT", 200, 200)
|
||||
Loose:SetSize(300, 300)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
// Re-lower the dialog under Board so there is something to raise over again.
|
||||
s.run("Board:SetFrameLevel(9); Dialog:SetFrameLevel(0)")
|
||||
.unwrap();
|
||||
s.run("Kid:Raise()").unwrap();
|
||||
assert!(
|
||||
level(&mut s, "Dialog") > level(&mut s, "Board"),
|
||||
"raising a child raised the toplevel window it lives in"
|
||||
);
|
||||
|
||||
// A frame with no toplevel in its chain: a total no-op, and not an error.
|
||||
let board_before = level(&mut s, "Board");
|
||||
let loose_before = level(&mut s, "Loose");
|
||||
s.run("Loose:Raise()").unwrap();
|
||||
assert_eq!(level(&mut s, "Loose"), loose_before);
|
||||
assert_eq!(level(&mut s, "Board"), board_before);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// `Frame:Lower()` is a **verified no-op stub** in build 5875 (`0x7652a0` = `xor eax,eax; ret 4`,
|
||||
/// its frame argument never read). It exists so a caller gets the reference's silence rather than a
|
||||
/// nil-call error — and it must not become "the opposite of Raise".
|
||||
#[test]
|
||||
fn lua_lower_exists_and_does_nothing() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run("Dialog:SetToplevel(true); Dialog:Show()").unwrap();
|
||||
let (d, b) = (level(&mut s, "Dialog"), level(&mut s, "Board"));
|
||||
s.run("Dialog:Lower(); Board:Lower()").unwrap();
|
||||
assert_eq!((level(&mut s, "Dialog"), level(&mut s, "Board")), (d, b));
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The drag-start trigger (`0x7652b0` @`0x7652d7`): grabbing a movable toplevel window brings it
|
||||
/// forward before the drag begins. This was the deferral `object::movable`'s doc carried ("benilla
|
||||
/// has no raise law yet") and it is closed.
|
||||
#[test]
|
||||
fn starting_a_move_raises_the_dragged_window() {
|
||||
let mut s = board_and_dialog();
|
||||
s.run(
|
||||
r#"
|
||||
Dialog:SetToplevel(true)
|
||||
Dialog:SetMovable(true)
|
||||
Dialog:EnableMouse(true)
|
||||
Dialog:Show()
|
||||
Board:SetFrameLevel(9) -- put the dialog back underneath
|
||||
Dialog:SetFrameLevel(0)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(level(&mut s, "Dialog") < level(&mut s, "Board"));
|
||||
|
||||
s.run("Dialog:StartMoving()").unwrap();
|
||||
assert!(
|
||||
level(&mut s, "Dialog") > level(&mut s, "Board"),
|
||||
"the grab brought it to the front"
|
||||
);
|
||||
s.run("Dialog:StopMovingOrSizing()").unwrap();
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
Reference in New Issue
Block a user