Files
benilla-octo/crates/benilla-ui/src/script/tests/movable.rs
T
SamandClaude Fable 5.1 5af3a15d0e plate text settles, mobs stop sinking, and select goes with Lua 5.1
- a nameplate's name and level stop popping a pixel every other step as the plate slides, and the whole plate no longer jumps 19 px to the top of the screen on a rounding tie; the chat bubble takes the same fix (2172)
- creatures and other players stop sinking through the ground while the world is still streaming in, where a Stormwind patroller had been 31 yd under its street: a body our world cannot yet answer for holds the server's pose (2174)
- select and the string metatable are gone, as in 1.12's Lua 5.0: pfUI's heal prediction parses its messages with the right format, BuffCheck2's own select wins its fallback, and a method call on a string raises the reference's own error (2171)
- the camera's water collision comes back out for now: with the pivot a hair under the plane, a surface swimmer's camera snapped in and out against the waterline, so the boom ignores water again (2173)

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

618 lines
24 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.
//! 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:SetWidth(200); Panel:SetHeight(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:SetWidth(200); Fixed:SetHeight(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:SetWidth(50); Other:SetHeight(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:SetWidth(800); Back:SetHeight(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:SetWidth(100); Scaled:SetHeight(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:SetWidth(50); F:SetHeight(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)
);
}
/// **`StartSizing(grip)` moves the gripped edges and plants the opposite ones.**
///
/// `0x776830`, verified in wow-re's ledger; the reference's own caller is
/// `FloatingChatFrame.lua:600`. Four corpus addons reach it through ONE line —
/// `FuBar_Panel.lua:980`, replicated into FuBar_CorkFu, FuBar_FuXPFu, FuBar_SpellStatusFu and oRA2
/// (1207: one library, not four votes).
///
/// What the ledger does NOT record is which edges a grip moves — it has the verb as pure
/// orchestration with no inline math. Taken here as the plain meaning of an anchor point, which is
/// how the reference's caller uses it. This test is where that reading is pinned, so an RE pass
/// that contradicts it fails here first.
#[test]
fn start_sizing_moves_the_gripped_edge_and_plants_the_other() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
f = CreateFrame("Frame", "Sizer", UIParent)
f:SetResizable(true)
f:SetWidth(200) f:SetHeight(100)
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 100, 50)
"#,
)
.unwrap();
s.resolve();
// Grip the RIGHT edge and drag 40 right: width grows, the left edge stays planted.
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(340.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 240.0);
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 100.0);
s.run("Sizer:StopMovingOrSizing()").unwrap();
// Grip the LEFT edge and drag 30 right: width SHRINKS and the left edge follows the cursor,
// so the right edge is the one that stays put.
let right_before = s.eval::<f32>("return Sizer:GetRight()").unwrap();
s.run("Sizer:StartSizing(\"LEFT\")").unwrap();
s.mouse_move(370.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 210.0);
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 130.0);
assert_eq!(
s.eval::<f32>("return Sizer:GetRight()").unwrap(),
right_before,
"the ungripped edge must not move"
);
// StopMovingOrSizing ends it: further motion changes nothing.
s.run("Sizer:StopMovingOrSizing()").unwrap();
s.mouse_move(500.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 210.0);
// A frame that is not resizable refuses, like the movable family's siblings.
s.run("g = CreateFrame(\"Frame\", \"NotSizer\", UIParent)")
.unwrap();
assert!(
s.run("NotSizer:StartSizing(\"RIGHT\")").is_err(),
"StartSizing must refuse a frame that is not resizable"
);
}
/// **`CreateTitleRegion` / `GetTitleRegion` — the object half** (wow-re
/// `widget-api-batch-benilla.md` Q6, `0x773910` / `0x773820`).
///
/// Four details, each one a coin-flip a reimplementation loses, and each one asserted.
#[test]
fn a_title_region_is_a_plain_region_and_creating_it_twice_is_destructive() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
TFrame = CreateFrame("Frame", "TFrame")
TFrame:SetPoint("BOTTOMLEFT", 100, 100)
TFrame:SetWidth(200); TFrame:SetHeight(80)
"#,
)
.unwrap();
// GetTitleRegion answers ONE value and it is nil — not zero values, which is the asymmetry
// Q6 flags against `GetBackdrop`.
assert_eq!(s.arity("TFrame:GetTitleRegion()").unwrap(), 1);
assert!(s
.eval::<Option<bool>>("return TFrame:GetTitleRegion() ~= nil and true or nil")
.unwrap()
.is_none());
// It is a plain REGION — not a Texture wearing a hat.
s.run("TR = TFrame:CreateTitleRegion()").unwrap();
assert_eq!(
s.eval::<String>("return TR:GetObjectType()").unwrap(),
"Region"
);
assert_eq!(
s.eval::<i64>("return TR:IsObjectType('Region')").unwrap(),
1
);
assert!(s
.eval::<Option<i64>>("return TR:IsObjectType('Texture')")
.unwrap()
.is_none());
assert!(s
.eval::<bool>("return TFrame:GetTitleRegion() == TR")
.unwrap());
// **It takes NO argument**: `CreateTitleRegion(frame)` is the no-arg call, which is exactly
// what `CustomNameplates/options.lua:73` writes.
assert!(s
.eval::<bool>("return TFrame:CreateTitleRegion(TFrame) == TR")
.unwrap());
// **Idempotent, and DESTRUCTIVELY so.** A second call returns the same object after clearing
// its anchors — so it silently wipes the points a caller already set.
s.run("TR:SetAllPoints(TFrame)").unwrap();
s.resolve();
assert_eq!(s.eval::<i64>("return TR:GetNumPoints()").unwrap(), 2);
assert!(s
.eval::<bool>("return TFrame:CreateTitleRegion() == TR")
.unwrap());
assert_eq!(
s.eval::<i64>("return TR:GetNumPoints()").unwrap(),
0,
"the second call ran ClearAllPoints on the region it returned"
);
// **It answers the 19 Region methods and NOTHING else** — 1250 §5's named divergence, closed.
// Q6: no Show/Hide, no scripts, no textures. Ours used to share one metatable with Texture and
// FontString, so a title region answered `SetTexture` where the reference raises.
for name in crate::script::REGION_MAP_METHODS {
assert_eq!(
s.eval::<String>(&format!("return type(TR.{name})"))
.unwrap(),
"function",
"a title region answers {name}"
);
}
for absent in [
"SetTexture",
"GetTexture",
"SetTexCoord",
"SetVertexColor",
"SetDrawLayer",
"Show",
"Hide",
"IsShown",
"IsVisible",
"SetText",
"GetText",
"SetAlpha",
] {
assert_eq!(
s.eval::<String>(&format!("return type(TR.{absent})"))
.unwrap(),
"nil",
"a title region must NOT answer {absent} — the reference raises there"
);
}
// It never draws: a title region is a hit rectangle, not a visual.
s.run("TR:SetAllPoints(TFrame)").unwrap();
s.resolve();
let region_quads: Vec<_> = s
.extract()
.into_iter()
.filter(|q| matches!(q.target, crate::script::ZTarget::Region(_)))
.collect();
assert!(
region_quads.is_empty(),
"a title region emits no quad of its own (the frame's own quad is not one): {region_quads:?}"
);
// The full region kinds are untouched by the split — asserted LAST, because creating a real
// texture would otherwise put a legitimate quad into the check above.
s.run("TTex = TFrame:CreateTexture(nil, 'ARTWORK')")
.unwrap();
assert_eq!(
s.eval::<String>("return type(TTex.SetTexture)").unwrap(),
"function",
"a Texture still answers its own verbs"
);
}
/// **The drag half — and the three things that make it not just `StartMoving`.**
///
/// A mouse-down inside the title region starts a move that (1) SWALLOWS `OnMouseDown`, (2) ends on
/// release where a scripted move persists, and (3) does not consult `SetMovable`.
#[test]
fn a_title_region_drag_swallows_the_press_and_ends_on_release() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
downs = 0
TP = CreateFrame("Frame", "TP")
TP:SetPoint("BOTTOMLEFT", 100, 100)
TP:SetWidth(200); TP:SetHeight(80)
TP:EnableMouse(true)
TP:SetScript("OnMouseDown", function() downs = downs + 1 end)
TP:CreateTitleRegion():SetAllPoints(TP)
"#,
)
.unwrap();
s.resolve();
let corner = |s: &mut UiScript| {
s.resolve();
s.eval::<f64>("return TP:GetLeft()").unwrap()
};
assert_eq!(corner(&mut s), 100.0);
// Press inside the title region: the move starts and OnMouseDown never runs.
s.mouse_button(150.0, 150.0, "LeftButton", true);
assert_eq!(
s.eval::<i64>("return downs").unwrap(),
0,
"a title-region hit swallows OnMouseDown — a miss would fall through to it"
);
s.mouse_move(250.0, 150.0);
assert_eq!(corner(&mut s), 200.0, "the frame followed the cursor");
// **Release ends it** — mode 2 auto-cancels. Moving the cursor afterwards must not drag it.
s.mouse_button(250.0, 150.0, "LeftButton", false);
s.mouse_move(400.0, 150.0);
assert_eq!(
corner(&mut s),
200.0,
"the move ended at the release; a scripted StartMoving would still be running"
);
// **No `SetMovable` gate on this path** — TP was never made movable, and it dragged. Q6 marks
// the no-gate reading VERIFIED (the movable bit is simply not read on
// 0x7662c0->0x765320->0x7652b0->0x768430); a title region on a non-movable frame is the case
// that tells it apart from `StartMoving`, which raises "Frame %s is not movable".
assert!(!s.eval::<bool>("return TP:IsMovable()").unwrap());
}
/// A region's rect getters answer in its OWNER's units — screen ÷ the owner's effective scale —
/// exactly as the frame getters do (decision 1985): a texture inside a frame scaled to 0.5 that
/// covers the frame answers the frame's own width, not half of it.
#[test]
fn region_getters_answer_in_the_owners_units_under_scale() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"f = CreateFrame("Frame", "ScaledOwner") f:SetWidth(200) f:SetHeight(100)
f:SetPoint("BOTTOMLEFT", 100, 50) f:SetScale(0.5)
t = f:CreateTexture("ScaledTex") t:SetAllPoints(f)"#,
)
.unwrap();
s.resolve();
let (fl, fr, tl, tr, cx) = s
.eval::<(f64, f64, f64, f64, f64)>(
"local cx = t:GetCenter() return f:GetLeft(), f:GetRight(), t:GetLeft(), t:GetRight(), cx",
)
.unwrap();
assert!(
(fl - tl).abs() < 1e-3 && (fr - tr).abs() < 1e-3,
"frame {fl}..{fr} vs region {tl}..{tr}"
);
assert!(
(fr - fl - 200.0).abs() < 1e-3,
"the owner's own width, {}",
fr - fl
);
assert!((cx - (fl + fr) * 0.5).abs() < 1e-3);
}