Files
benilla-octo/crates/benilla-ui/src/script/tests/anchors.rs
T
SamandClaude Fable 5.1 eebbcc050f NTFS load order, a title-sorted AddOns list, and tooltips at the cursor
- addons load in the order NTFS lists a folder, case-insensitive with an underscore before the letters, the order the vanilla corpus was written against and the one that decides which addon provides a shared library (2178)
- GetNumAddOns and the index form of every AddOn verb walk a title-sorted list that hides the Blizzard addons the server says to hide, and the character-select AddOns list is in title order (2175)
- SetPoint and SetAllPoints raise the reference's own errors for a frame name that does not exist or an omitted relative frame instead of quietly anchoring to the parent, and an explicit nil means the screen root (2176)
- a tooltip owned with ANCHOR_CURSOR follows the mouse, which pfUI's tooltip, xpbar and chat modules, pfQuest's browser and TipBuddy ask for; an omitted or unknown anchor is ANCHOR_LEFT (2176)
- a $parent name handed to CreateFrame, CreateTexture or CreateFontString expands as it does in XML, so FonzAppraiser's thirty dropdowns and pfQuest's search icon get the names they asked for (2176)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:03:49 +07:00

411 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SetPoint + resolve → GetWidth/GetHeight + rect.
use super::common::script;
use crate::layout::Rect;
// Regression: an *explicit* `nil` relativeTo must still consume its argument slot, so the
// relativePoint + offsets that follow line up. The `SetPoint("P", nil, "P", x, y)` form is the
// common FrameXML idiom for "anchor to the screen at an offset"; a leading nil that failed to
// advance the cursor silently dropped the offsets (screen-anchored frames pinned to the corner).
#[test]
fn setpoint_explicit_nil_relative_to_keeps_offsets() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local f = CreateFrame("Frame", "Nil")
f:SetPoint("TOPLEFT", nil, "TOPLEFT", 40, -40)
f:SetWidth(300); f:SetHeight(200)
"#,
)
.unwrap();
s.resolve();
let rect = s
.extract()
.iter()
.find_map(|q| match q.target {
crate::order::ZTarget::Frame(_) => q.rect,
_ => None,
})
.expect("resolved frame rect");
// screen [0,0,600,800], TOPLEFT+(40,-40): left 40, top 560, size 300×200.
assert_eq!(rect, Rect::new(360.0, 40.0, 560.0, 340.0));
}
#[test]
fn setpoint_resolve_size_and_rect() {
let mut s = script();
s.set_screen_size(800.0, 600.0); // screen rect [bottom 0, left 0, top 600, right 800]
s.run(
r#"
local f = CreateFrame("Frame", "Sized")
f:SetPoint("TOPLEFT", 10, -5) -- relativeTo = screen (default), relativePoint = TOPLEFT
f:SetWidth(200); f:SetHeight(50)
"#,
)
.unwrap();
s.resolve();
let (w, h): (f32, f32) = s
.eval("return Sized:GetWidth(), Sized:GetHeight()")
.unwrap();
assert_eq!(w, 200.0);
assert_eq!(h, 50.0);
// Hand-computed (layout.md oracle): TOPLEFT anchored to screen [0,0,600,800] at (10,-5), size
// 200×50 → Rect(bottom 545, left 10, top 595, right 210).
let quads = s.extract();
let frame_rect = quads
.iter()
.find_map(|q| match q.target {
crate::order::ZTarget::Frame(_) => q.rect,
_ => None,
})
.expect("resolved frame rect");
assert_eq!(frame_rect, Rect::new(545.0, 10.0, 595.0, 210.0));
}
#[test]
fn getwidth_falls_back_to_explicit_size_before_resolve() {
let s = script();
// No SetPoint ⇒ unresolvable; GetWidth returns the explicit SetWidth value.
let w: f32 = s
.eval(r#"local f = CreateFrame("Frame"); f:SetWidth(123); return f:GetWidth()"#)
.unwrap();
assert_eq!(w, 123.0);
}
/// A *named* `relativeTo` that does not resolve **RAISES** and abandons the call — the reference's
/// `luaL_error(0x87ccd4, "%s:SetPoint(): Couldn't find region named '%s'")`, with no fallback and
/// no no-op (decision 2176; 2105 pinned the bytes and deferred the change until the addon survey
/// could see engine warnings). The region path is the same registered function, so it raises the
/// same way — and both quote the receiver's own name, `<unnamed>` (`0x84c7f0`) when it has none.
///
/// **This replaces a parent/owner fallback plus a warning.** That fallback is what misdirected
/// ItemTextFrame's scrollbar track onto the parchment; the XML half of the same miss is a
/// different law and keeps its warning (`Loader::apply_anchor`, `0x767800`).
#[test]
fn setpoint_unresolved_name_raises() {
let mut s = script();
s.run(r#"f = CreateFrame("Frame", "Orphan")"#).unwrap();
let e = s
.run(r#"Orphan:SetPoint("TOPLEFT", "NoSuchFrame", "TOPLEFT", 0, 0)"#)
.unwrap_err()
.to_string();
assert!(
e.contains("Orphan:SetPoint(): Couldn't find region named 'NoSuchFrame'"),
"frame path: {e}"
);
let e = s
.run(
r#"t = Orphan:CreateTexture(nil, "ARTWORK")
t:SetPoint("TOPRIGHT", "NoSuchRegion")"#,
)
.unwrap_err()
.to_string();
assert!(
e.contains("<unnamed>:SetPoint(): Couldn't find region named 'NoSuchRegion'"),
"region path (anonymous receiver): {e}"
);
// The anchor was ABANDONED, not applied to the parent: the frame still has none.
let n: i64 = s.eval("return Orphan:GetNumPoints()").unwrap();
assert_eq!(n, 0, "the raise must leave no anchor behind");
// `SetAllPoints` has its own string, and a NUMBER takes the name path there (`lua_isstring`)
// where `SetPoint` would read it as an offset.
let e = s
.run(r#"Orphan:SetAllPoints("NoSuchFrame")"#)
.unwrap_err()
.to_string();
assert!(
e.contains("Orphan:SetAllPoints(): Couldn't find region named 'NoSuchFrame'"),
"{e}"
);
let e = s.run("Orphan:SetAllPoints(42)").unwrap_err().to_string();
assert!(
e.contains("Orphan:SetAllPoints(): Couldn't find region named '42'"),
"a number is a string to `lua_isstring`, so it is looked up as `_G[\"42\"]`: {e}"
);
// A resolvable name still works, and warns about nothing.
s.run(r#"CreateFrame("Frame", "Target"); Orphan:SetPoint("BOTTOMLEFT", "Target", "TOPLEFT")"#)
.unwrap();
assert!(s.take_warnings().is_empty());
}
/// The other four raises of the same ladder, each read out of the reference image.
///
/// * **Usage** `0x87cc28` — an absent `relativeTo` is `TNONE` and fails the `lua_type(L,3)` gate
/// (`0x7a25e6`–`0x7a2620`), so a one-argument `SetPoint` is an error on a 1.12 client. Stock
/// content makes none (349 call sites, arity histogram `{3:5, 4:1, 5:343}`); five sites in the
/// 219-addon vanilla corpus do, all of them in multi-client code.
/// * **Unknown region point** `0x87cd04`, from the 9-entry scan `0x6f1840`.
/// * **trying to anchor to itself** `0x87cca8` / `0x87cd54`.
/// * And the ORDER is observable: the two tag gates run before the point-name scan, so an unknown
/// point with an absent `relativeTo` answers Usage, not "Unknown region point".
#[test]
fn the_other_setpoint_raises() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "Ladder")"#).unwrap();
let e = s
.run(r#"Ladder:SetPoint("CENTER")"#)
.unwrap_err()
.to_string();
assert!(e.contains("Usage: Ladder:SetPoint(\"point\""), "{e}");
let e = s
.run(r#"Ladder:SetPoint("MIDDLE", nil, "MIDDLE", 0, 0)"#)
.unwrap_err()
.to_string();
assert!(e.contains("Ladder:SetPoint(): Unknown region point"), "{e}");
// Unknown point AND absent relativeTo: the tag gate is first, so this is Usage.
let e = s
.run(r#"Ladder:SetPoint("MIDDLE")"#)
.unwrap_err()
.to_string();
assert!(e.contains("Usage:"), "the tag gate runs first: {e}");
let e = s
.run(r#"Ladder:SetPoint("CENTER", Ladder, "CENTER", 0, 0)"#)
.unwrap_err()
.to_string();
assert!(
e.contains("Ladder:SetPoint(): trying to anchor to itself"),
"{e}"
);
}
/// **A trailing explicit `nil` is PRESENT, and absent is absent** — the one thing that would make
/// the Usage raise over-fire. `lua_type(L,3)` distinguishes `LUA_TNIL` (0, the screen-root arm)
/// from `LUA_TNONE` (−1, which fails the gate), so the ladder must see the argument count Lua saw
/// and not a tuple padded or trimmed on the way in.
#[test]
fn a_trailing_nil_relative_to_is_present_where_an_absent_one_is_not() {
let s = script();
s.run(r#"f = CreateFrame("Frame", "Trail")"#).unwrap();
// Present-and-nil: legal, the screen root, no offsets.
s.run(r#"Trail:SetPoint("CENTER", nil)"#).unwrap();
assert_eq!(s.eval::<i64>("return Trail:GetNumPoints()").unwrap(), 1);
// Absent: Usage.
let e = s
.run(r#"Trail:SetPoint("CENTER")"#)
.unwrap_err()
.to_string();
assert!(e.contains("Usage:"), "{e}");
}
/// An explicit `nil` `relativeTo` is the **SCREEN ROOT** (`0x7a2710 mov eax,ds:0xcf0bd8`), not the
/// parent — the arm benilla answered with the parent until 2176. `UIParent.lua:1549` is the one
/// stock site; fourteen corpus sites take it, every one of them meaning "the screen"
/// (`pfUI`'s screenshot caption, `FixCTGroups`' cursor-space plate).
#[test]
fn an_explicit_nil_relative_to_is_the_screen_not_the_parent() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
host = CreateFrame("Frame", "NilHost")
NilHost:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 200, 100)
NilHost:SetWidth(100) NilHost:SetHeight(100)
kid = CreateFrame("Frame", "NilKid", NilHost)
NilKid:SetWidth(10) NilKid:SetHeight(10)
NilKid:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
"#,
)
.unwrap();
s.resolve();
let left: f32 = s.eval("return NilKid:GetLeft()").unwrap();
assert_eq!(
left, 0.0,
"the screen's BOTTOMLEFT, not NilHost's (which is 200)"
);
}
/// **A region whose OWNER has no rect still resolves from its own anchors.**
///
/// `layout.rs`'s region pass used to `continue` when `resolved` held nothing for the owner frame.
/// But the owner's rect is only the *fallback* for axes the region's own anchors do not pin — a
/// region anchored fully to some other frame needs nothing from its owner, and the reference
/// resolves it. Skipping made an ordinary addon shape silently invisible: a bare container frame
/// (`CreateFrame("Frame", n, parent)` with no size and no `SetPoint`) holding a region anchored
/// elsewhere.
///
/// Found by reproducing the director's MapCoords report. Its world-map readout computed the right
/// string every single frame — `Cursor Coords: … Player Coords: …` — and was never given a
/// position, with no error raised anywhere. Three of its four frames are this shape.
///
/// The control matters as much as the subject: a SIZED owner must be unaffected, or the fix has
/// traded one wrong layout for another.
#[test]
fn a_region_resolves_even_when_its_owner_frame_has_no_rect() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
host = CreateFrame("Frame", "AnchorHost")
host:SetWidth(400) host:SetHeight(200)
host:SetPoint("BOTTOMLEFT", 100, 50)
-- the shape that was invisible: owner with NO size and NO SetPoint
bare = CreateFrame("Frame", "BareOwner")
mark = bare:CreateTexture("BareMark", "ARTWORK")
mark:SetWidth(20) mark:SetHeight(10)
mark:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
-- control: identical region, owner that DOES resolve
sized = CreateFrame("Frame", "SizedOwner")
sized:SetWidth(10) sized:SetHeight(10) sized:SetPoint("CENTER", 0, 0)
ctl = sized:CreateTexture("SizedMark", "ARTWORK")
ctl:SetWidth(20) ctl:SetHeight(10)
ctl:SetPoint("BOTTOMLEFT", host, "BOTTOMLEFT", 5, 7)
"#,
)
.unwrap();
s.resolve();
// The owner genuinely has no rect — that part is correct and must stay true.
assert_eq!(
s.eval::<Option<f32>>("return BareOwner:GetLeft()").unwrap(),
None,
"an unpositioned frame has no rect; the fix must not invent one for it"
);
// ...and its region resolves anyway, from its own anchor: host's BOTTOMLEFT (100,50) + (5,7).
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetLeft()").unwrap(),
Some(105.0),
"a fully-anchored region needs nothing from its owner"
);
assert_eq!(
s.eval::<Option<f32>>("return BareMark:GetBottom()")
.unwrap(),
Some(57.0)
);
// The control is unmoved.
assert_eq!(
s.eval::<Option<f32>>("return SizedMark:GetLeft()").unwrap(),
Some(105.0),
"a sized owner's region must be unaffected by the fix"
);
}
/// **…but a region the owner's rect is the ONLY candidate for stays unresolved with it.**
///
/// The other half of the law above, and the half that shipped wrong: an unpinned axis on an
/// unpositioned owner has nothing to fall back to, so standing a zero rect in for the missing owner
/// puts the region at the **screen origin** instead of nowhere. That is not a degenerate rect, it is
/// a wrong position — and a template whose textures chain off each other turns it into real,
/// visible geometry a few links down.
///
/// Reported as B264 (carni, 2026-08-13): opening the social pane drew a stray dropdown capsule at
/// the bottom of the screen next to the action bar. `FriendsDropDown` carries no anchors —
/// *exactly* as the reference's own `FriendsDropDown` does (`FriendsFrame.xml` l.598), and the
/// reference draws nothing — so every texture of `UIDropDownMenuTemplate` hung off a phantom rect
/// at (0,0). The shape below is that template's first two textures verbatim.
#[test]
fn an_unanchored_owners_region_chain_resolves_nowhere() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
-- The dropdown host: a child frame with no anchors and no size, as declared.
bare = CreateFrame("Frame", "StrayHost")
-- $parentLeft: anchored to the OWNER (no relativeTo = the owner frame), which has no rect.
cap = bare:CreateTexture("StrayLeft", "ARTWORK")
cap:SetWidth(25) cap:SetHeight(64)
cap:SetPoint("TOPLEFT", 0, 0)
-- $parentMiddle: the sibling chain that turned a zero rect into 115x64 of visible capsule.
mid = bare:CreateTexture("StrayMiddle", "ARTWORK")
mid:SetWidth(115) mid:SetHeight(64)
mid:SetPoint("LEFT", cap, "RIGHT")
"#,
)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<Option<f32>>("return StrayHost:GetLeft()").unwrap(),
None,
"the premise: an unanchored frame has no rect"
);
assert_eq!(
s.eval::<Option<f32>>("return StrayLeft:GetLeft()").unwrap(),
None,
"a region with nothing but its unpositioned owner to derive from has no rect either — \
standing in a zero rect here is what put the capsule at the screen origin"
);
assert_eq!(
s.eval::<Option<f32>>("return StrayMiddle:GetLeft()")
.unwrap(),
None,
"and the chain off it stays unresolved — this is the link that was actually visible"
);
}
/// **Geometry answers inside the same call stack that moved it** — the seam that killed 97 addons.
///
/// A handler creates a frame, anchors it, shows it and then measures it, all before any resolve
/// pass can run. `Dewdrop-2.0.lua` — embedded in ~65 corpus addons — does exactly this in its menu
/// `Open`: `local left = frame:GetLeft()` (l.1942) then `curX - left - width/2` (l.1960). Our
/// getters read a cache filled by the per-frame `resolve()`, so `left` was nil and the arithmetic
/// died. **97 of the 108 addons that drew and then raised on being touched died on that one line**,
/// with no missing verb anywhere.
///
/// The readers settle the graph on demand now. The second half of the test is the one that keeps
/// it honest: a *further* move inside the same stack must also be visible, or the fix is just a
/// one-shot warm-up.
#[test]
fn geometry_answers_within_the_call_stack_that_moved_it() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Dewdrop's shape, in one eval — no resolve() between the writes and the read.
let (left, bottom): (Option<f32>, Option<f32>) = s
.eval(
r#"
local f = CreateFrame("Frame", "MenuLike", UIParent)
f:SetWidth(100) f:SetHeight(50)
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 40, 60)
f:Show()
return f:GetLeft(), f:GetBottom()
"#,
)
.unwrap();
assert_eq!(
(left, bottom),
(Some(40.0), Some(60.0)),
"a frame must report its geometry in the stack that anchored it"
);
// ...and a LATER move in the same stack is visible too — not a stale first answer.
let moved: Option<f32> = s
.eval(
r#"
MenuLike:ClearAllPoints()
MenuLike:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 200, 60)
return MenuLike:GetLeft()
"#,
)
.unwrap();
assert_eq!(moved, Some(200.0), "the settle must re-run after each move");
// Width/height and the centre reader take the same path.
assert_eq!(
s.eval::<(f32, f32)>("return MenuLike:GetWidth(), MenuLike:GetHeight()")
.unwrap(),
(100.0, 50.0)
);
assert_eq!(
s.eval::<(Option<f32>, Option<f32>)>("return MenuLike:GetCenter()")
.unwrap(),
(Some(250.0), Some(85.0))
);
}