Files
benilla-octo/crates/benilla-ui/src/script/tests/layout_gate.rs
T
SamandClaude Fable 5.1 fed2c96ec8 the mailbox takes typing again, and events carry their real arguments
- the mail window's send tab takes typing again: the subject, body and money boxes were taking no input while the recipient box swallowed every key, and a sent letter clears its subject and item icon instead of putting them straight back (2145)
- seven events fire the way the reference does: UNIT_DYNAMIC_FLAGS fires at all, swapping two identical stacks between bank slots fires PLAYERBANKSLOTS_CHANGED twice, the pet sheet's training points repaint, and ITEM_LOCK_CHANGED loses its invented arguments (2140)
- nine widget methods 1.12 addons probe for are in: ShaguTweaks stops recolouring textures the reference leaves alone, pfUI and _Nameplates stop raising on a tooltip's GetAnchorType, and 63 AceEvent addons get RegisterAllEvents (2142)
- six post-1.12 methods leave the widget surface (SetSize, SetShown, HasFocus, SetFormattedText and two tooltip verbs), so an addon reaching for a later client's API finds what a real 1.12 client gives it (2142, 2146)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:52:50 +07:00

558 lines
21 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 layout change gate: a resolve whose inputs are byte-identical to the last converged one is
//! skipped outright (`script::layout::InputFingerprint`).
//!
//! These assert on `Model::layout_solves` — the count of times the fixpoint actually ran — rather
//! than on the rects, because rect equality alone cannot distinguish "the gate skipped" from "the
//! gate re-solved and got the same answer". The value of the gate is precisely the difference.
use super::common::script;
use crate::script::{Model, UiScript};
/// The mutation epoch (tier 1's input): bumped by every write into the layout read set.
fn epoch(s: &UiScript) -> u64 {
s.lua()
.app_data_ref::<Model>()
.expect("model app_data")
.layout_epoch
}
/// How many times the fixpoint has run.
fn solves(s: &UiScript) -> u64 {
s.lua()
.app_data_ref::<Model>()
.expect("model app_data")
.layout_solves
}
/// A frame anchored to the screen, plus a child hanging off it — enough that a move has to
/// propagate, so a wrongly-skipped resolve would be visible in the child too.
fn setup(s: &UiScript) {
s.run(
r#"
parent = CreateFrame("Frame", "Parent", nil)
parent:SetWidth(100); parent:SetHeight(40)
parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
child = CreateFrame("Frame", "Child", parent)
child:SetWidth(20); child:SetHeight(20)
child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0)
"#,
)
.expect("setup");
}
#[test]
fn an_unchanged_resolve_does_not_run_the_fixpoint() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
let after_first = solves(&s);
assert_eq!(after_first, 1, "the first resolve must run");
// Nothing touched in between: every further resolve is a no-op.
for _ in 0..5 {
s.resolve();
}
assert_eq!(
solves(&s),
after_first,
"resolves with identical inputs must be skipped, not re-run"
);
}
#[test]
fn moving_a_frame_reopens_the_gate_and_propagates() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve(); // settle: the gate is now closed
let before = solves(&s);
let child_left = |s: &UiScript| -> f32 { s.eval::<f32>("return Child:GetLeft()").unwrap() };
let first = child_left(&s);
s.run("parent:SetPoint(\"TOPLEFT\", nil, \"TOPLEFT\", 60, -10)")
.expect("move");
s.resolve();
assert_eq!(solves(&s), before + 1, "a SetPoint must reopen the gate");
assert!(
(child_left(&s) - (first + 50.0)).abs() < 0.001,
"the move must propagate to the child: {} -> {}",
first,
child_left(&s)
);
// And it closes again once the move has settled.
let after = solves(&s);
s.resolve();
assert_eq!(
solves(&s),
after,
"the gate must close again after the move"
);
}
/// Resizing the window moves every top-level frame — the gate must never swallow it.
#[test]
fn a_screen_resize_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.set_screen_size(1024.0, 768.0);
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a screen-size change must reopen the gate"
);
}
/// A region's own `SetPoint` writes `region_data`, not `layout_inputs` — the half of the read set
/// that is easiest to leave out of a change gate, and the one the region sweep consumes.
#[test]
fn moving_a_region_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.run(
r#"
tex = parent:CreateTexture(nil, "ARTWORK")
tex:SetWidth(10); tex:SetHeight(10)
tex:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
"#,
)
.expect("region");
s.resolve();
s.resolve();
let before = solves(&s);
s.run("tex:SetPoint(\"TOPLEFT\", parent, \"TOPLEFT\", 25, 0)")
.expect("move region");
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a region SetPoint must reopen the gate"
);
}
/// Hiding a frame does NOT move any rect (the client resolves hidden frames too — visibility is an
/// extract-time filter), so the gate is right to stay closed. Pinned deliberately: it is the one
/// place where "nothing to re-solve" is surprising, and a future reader tempted to dirty on
/// show/hide should see that the current model does not need it.
#[test]
fn hiding_a_frame_does_not_reopen_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.run("parent:Hide()").expect("hide");
s.resolve();
assert_eq!(
solves(&s),
before,
"visibility does not move rects, so the gate stays closed"
);
}
/// The hover re-enter loop must be FREE while the content is unchanged.
///
/// `ContainerFrameItemButton_OnUpdate` re-runs `OnEnter` every frame while the tooltip is the
/// button's own — faithful, and UNTHROTTLED in 1.12 (its `updateTooltip` throttle is commented
/// out; the reference's `ContainerFrameItemButton_OnUpdate` ships the same loop). So a bag hover clears and
/// rebuilds the SAME tooltip 60×/sec, and the engine must absorb that: identical content re-derives
/// an identical model, so the measure cache re-validates on its content key and the gate stays shut.
///
/// This is the regression gate for the live report (a bag hover cost +10 CPU ms/frame, ~2 full
/// arena solves + a re-shape of every line, every frame): `clear_content` wiped
/// `RegionData::measured` — an invalidation the content-hash key already performs — so every
/// re-enter re-measured every line, and the non-empty measure list forced a second full resolve
/// that the gate could never close over.
///
/// It drives the app's real per-frame order (`ui_script::extract::drive_script`): Lua tick →
/// resolve → measure round-trip → resolve.
#[test]
fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "Slot")
owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40)
tt = CreateFrame("GameTooltip", "TT")
-- One bag-slot OnEnter: the clear (SetOwner) + the content rebuild, verbatim in shape.
function reenter()
TT:SetOwner(Slot, "ANCHOR_RIGHT")
TT:AddLine("Small Shield", 0, 1, 0)
TT:AddDoubleLine("Shield", "Off Hand", 1, 1, 1, 1, 1, 1)
TT:AddLine("85 Armor")
-- A WRAP-flagged line: `clear_content` still drops the wrap-pinned width, which feeds
-- the measure key, so the re-pin at append time has to restore it byte-identically or
-- this line alone re-shapes forever (the real item tooltip's long green "Use:" line).
TT:AddLine("Restores 243 health over 21 sec.", 0, 1, 0, 1)
TT:AddLine("Durability 45 / 45")
TT:Show()
end
"#,
)
.expect("setup");
// The host's font engine: every distinct string has one deterministic size. Counts every
// string it is asked to shape, so the test can assert on shaping work directly.
let sizes: &[(&str, f32, f32)] = &[
("Small Shield", 80.0, 14.0),
("Shield", 50.0, 12.0),
("Off Hand", 45.0, 12.0),
("85 Armor", 60.0, 12.0),
("Restores 243 health over 21 sec.", 118.0, 24.0),
("Durability 45 / 45", 96.0, 12.0),
];
// One frame of `drive_script`: tick (the re-enter) → resolve → measure round-trip → resolve.
// Returns how many strings the font engine was asked to shape this frame.
let frame = |s: &mut UiScript| -> usize {
s.run("reenter()").expect("re-enter");
s.resolve();
let reqs = s.fontstrings_needing_measure();
let shaped = reqs.len();
if !reqs.is_empty() {
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| {
let (w, h) = sizes
.iter()
.find(|(t, _, _)| *t == r.text)
.map(|&(_, w, h)| (w, h))
.unwrap_or_else(|| panic!("unexpected string measured: {:?}", r.text));
(r.id, w, h, r.key)
})
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
}
shaped
};
// Settle: the first frames legitimately shape the five strings and re-solve as the auto-size
// pre-pass converges on the fresh measures.
for _ in 0..4 {
frame(&mut s);
}
// Steady state: the hover is parked on an item whose tooltip has not changed.
let solves_before = solves(&s);
let mut shaped = 0;
for _ in 0..10 {
shaped += frame(&mut s);
}
assert_eq!(
shaped, 0,
"a re-enter with identical content must re-shape NO text: the measure cache keys on \
content, so rebuilding the same lines re-validates it"
);
assert_eq!(
solves(&s),
solves_before,
"a re-enter with identical content must not reopen the layout gate"
);
// …and it must not even reach tier 2. Tier 1 (the mutation epoch) is what makes a quiet frame
// FREE; tier 2 still hashes the whole UI's read set to decide "quiet" (~0.65 ms/frame measured
// with `WOW_UI_COST=1` on `WOW_CAPTURE=ui-tooltip`, at solves=0). The re-enter loop used to
// pay that on every single hover frame, because `clear_content` wiped each line's wrap pin and
// `append_line` re-pinned the same width — a round trip inside one frame, two epoch bumps.
// Idempotent content must be epoch-silent, not merely fingerprint-absorbed.
let epoch_before = epoch(&s);
for _ in 0..10 {
frame(&mut s);
}
assert_eq!(
epoch(&s),
epoch_before,
"a re-enter with identical content must not touch the layout epoch at all — tier 1 has to \
hold, or every hover frame pays the whole-UI fingerprint"
);
// The gate is shut because nothing MOVED — not because the tooltip went stale. Changed content
// must still re-measure exactly its own line, and a measure that resizes the auto-sized plate
// (this one overtakes the double line as the widest) must reopen the gate.
s.run(
r#"function reenter()
TT:SetOwner(Slot, "ANCHOR_RIGHT")
TT:AddLine("Small Shield", 0, 1, 0)
TT:AddDoubleLine("Shield", "Off Hand", 1, 1, 1, 1, 1, 1)
TT:AddLine("85 Armor")
TT:AddLine("Restores 243 health over 21 sec.", 0, 1, 0, 1)
TT:AddLine("Durability 44 / 45")
TT:Show()
end"#,
)
.expect("damage the shield");
// Wider than the 120 the double line contributes, so the plate itself has to grow.
let sizes: &[(&str, f32, f32)] = &[("Durability 44 / 45", 150.0, 12.0)];
s.run("reenter()").expect("re-enter");
s.resolve();
let reqs = s.fontstrings_needing_measure();
assert_eq!(
reqs.len(),
1,
"only the line whose text changed re-measures, got {:?}",
reqs.iter().map(|r| &r.text).collect::<Vec<_>>()
);
assert_eq!(reqs[0].text, "Durability 44 / 45");
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| {
let (w, h) = sizes
.iter()
.find(|(t, _, _)| *t == r.text)
.map(|&(_, w, h)| (w, h))
.expect("known string");
(r.id, w, h, r.key)
})
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
assert!(
solves(&s) > solves_before,
"changed content must reopen the gate"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Tier 1 of the gate (decision 0740): the mutation epoch. **Any CONVERGED resolve closes it**
/// (decision 1385) — the fingerprint is hashed over inputs alone, so a solve cannot outgrow the
/// value it stores — and from then on a quiet frame skips at a `u64` compare without computing
/// the fingerprint at all.
fn tier_one_closed(s: &UiScript) -> bool {
let m = s.lua().app_data_ref::<Model>().expect("model app_data");
m.layout_epoch_resolved == Some(m.layout_epoch)
}
/// **The castbar law** (decision 1385, ledger B283): a region that MOVES every frame — the classic
/// OnUpdate animation idiom, our own `CastingBarSpark:SetPoint`, and every addon that slides a
/// texture — must cost exactly **one** let-through resolve per frame.
///
/// It used to cost three. The fingerprint was hashed over the 0294 SEEDS as well as the inputs, so
/// a solve necessarily outgrew the fingerprint it had just stored: neither that pass nor the
/// settling pass behind it could close tier 1, and the frame paid solve + settle + skip — three
/// whole-roster walks at ~1.0–1.4 ms each. Measured live at the Stormwind pin: **+4.2 ms of CPU
/// per frame for one 32×32 spark**, on a default UI with no addons loaded.
///
/// This is the regression guard for that whole bug class. `solves` is the honest counter — a
/// wasted walk that concludes "nothing moved" still costs the full preamble — so the assertion is
/// on the count, never on a duration (0735: milliseconds are not evidence of a scope regression).
#[test]
fn a_region_moving_every_frame_costs_exactly_one_solve_per_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.run(
r#"
spark = parent:CreateTexture(nil, "OVERLAY")
spark:SetWidth(32); spark:SetHeight(32)
spark:SetPoint("CENTER", parent, "LEFT", 0, 2)
"#,
)
.expect("spark");
s.resolve();
assert!(tier_one_closed(&s), "the setup must settle in one resolve");
// Ten frames of the castbar's own inner loop: one region re-pointed to a NEW offset, then the
// host's per-frame resolve. Nothing else in the model moves.
//
// The offsets start at 1.7, not 0 — a `frame * 1.7` sequence opens with a re-write of the
// seed value, which the setters' bit-exact compare absorbs (no epoch bump, no solve at all).
// That is the *right* answer and `an_idempotent_setter_call_leaves_tier_one_closed` pins it;
// here it would have measured the guard instead of the gate.
for frame in 0..10 {
let before = solves(&s);
s.run(&format!(
r#"spark:SetPoint("CENTER", parent, "LEFT", {}, 2)"#,
f64::from(frame + 1) * 1.7
))
.expect("spark move");
s.resolve();
assert_eq!(
solves(&s) - before,
1,
"frame {frame}: a single moving region must cost ONE solve, not the \
solve+settle+skip trio the seeds-in-the-fingerprint law used to force"
);
assert!(
tier_one_closed(&s),
"frame {frame}: the converged solve must close tier 1, so a second getter in the \
same tick skips at the u64 compare instead of re-walking the whole roster"
);
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The other half of the same law: once it has settled, an untouched frame costs NOTHING — no
/// solve, and (tier 1) not even the fingerprint walk that would decide so.
#[test]
fn a_quiet_frame_after_a_move_costs_no_solve_at_all() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.run(r#"parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 33, -44)"#)
.expect("move");
s.resolve();
let settled = solves(&s);
for _ in 0..5 {
s.resolve();
assert_eq!(solves(&s), settled, "a quiet frame must not solve");
assert!(tier_one_closed(&s), "and must not reopen tier 1");
}
}
#[test]
fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
// ONE resolve closes the epoch (decision 1385): the fingerprint is hashed over inputs alone,
// and the rounds just drove those inputs to their fixpoint, so there is nothing left for a
// settling pass to discover. This used to need two.
s.resolve();
assert!(
tier_one_closed(&s),
"a converged resolve must close the epoch by itself"
);
s.run("parent:SetWidth(150)").expect("resize");
assert!(
!tier_one_closed(&s),
"a real layout write must reopen tier 1"
);
s.resolve();
assert!(tier_one_closed(&s), "and one resolve closes it again");
}
#[test]
fn an_idempotent_setter_call_leaves_tier_one_closed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
assert!(tier_one_closed(&s));
// The classic per-frame OnUpdate idiom: re-assert the exact same geometry every frame. The
// setters' compare-before-write keeps the epoch untouched, so an idle UI never re-enters
// the fingerprint path at all.
s.run(
r#"
parent:SetWidth(100); parent:SetHeight(40)
parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0)
"#,
)
.expect("idempotent re-set");
assert!(
tier_one_closed(&s),
"value-identical setter calls must not dirty the epoch"
);
let before = solves(&s);
s.resolve();
assert_eq!(solves(&s), before, "and the resolve stays skipped");
}
#[test]
fn a_paint_only_write_leaves_tier_one_closed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
// A paint region with NO anchors: its region_data entry is created by a paint setter and
// must stay invisible to the layout gate (the resolve sweep skips anchor-less entries).
s.run(r#"tex = parent:CreateTexture(nil, "ARTWORK")"#)
.expect("region");
s.resolve();
s.resolve();
assert!(tier_one_closed(&s));
s.run(r#"tex:SetTexture(1, 0, 0)"#).expect("paint");
assert!(
tier_one_closed(&s),
"creating/painting an anchor-less region is not a layout change"
);
let before = solves(&s);
s.resolve();
assert_eq!(solves(&s), before);
}
/// **The retarget falsifier** (decision 1625): after a node is re-pointed from target A to target
/// B, the cached graph's EDGES must describe the new shape — moving B must move the node.
///
/// This is the one direction that can be silently wrong. 1625 keeps the cached graph across an
/// anchor retarget by patching one node's edges instead of re-deriving all of them, and a patch
/// that dropped the *new* edge would under-dirty: the node would sit at a stale rect while the
/// thing it is anchored to moved. (The other direction — a leftover edge to the OLD target — only
/// costs a needless re-solve, so it is asserted second and separately.)
///
/// It asserts on RECTS, not on counts, because a wrong edge set is a wrong picture: the failure
/// this guards against is a tooltip left behind at the last button's position.
#[test]
fn a_retargeted_anchor_follows_its_new_target() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
a = CreateFrame("Frame", "A", nil); a:SetWidth(10); a:SetHeight(10)
a:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, 0)
b = CreateFrame("Frame", "B", nil); b:SetWidth(10); b:SetHeight(10)
b:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, 0)
plate = CreateFrame("Frame", "Plate", nil)
plate:SetWidth(10); plate:SetHeight(10)
plate:SetPoint("TOPLEFT", a, "TOPRIGHT", 0, 0)
"#,
)
.expect("setup");
s.resolve();
s.resolve();
// The retarget: the plate now hangs off B, exactly as `GameTooltip:SetOwner` re-points the
// plate at the next button under the cursor.
s.run(r#"plate:SetPoint("TOPLEFT", b, "TOPRIGHT", 0, 0)"#)
.expect("retarget");
s.resolve();
let after_retarget = s.eval::<f64>("return Plate:GetLeft()").expect("plate left");
assert!(
(after_retarget - 110.0).abs() < 0.01,
"the retargeted plate must sit at B's right edge (110), not {after_retarget}"
);
// …and it must keep following B. This is the assertion the edge patch exists to satisfy: the
// graph was NOT re-derived across the retarget, so if the new edge never made it into the
// cached edge set, B moving reaches nothing and the plate stays put.
s.run(r#"b:SetPoint("TOPLEFT", nil, "TOPLEFT", 300, 0)"#)
.expect("move B");
s.resolve();
let after_move = s.eval::<f64>("return Plate:GetLeft()").expect("plate left");
assert!(
(after_move - 310.0).abs() < 0.01,
"moving the NEW target must move the plate (310), not leave it at {after_move} — the \
retarget's edge patch dropped the new edge and the node is under-dirtied (decision 1625)"
);
// …and it must NOT follow the old one any more.
s.run(r#"a:SetPoint("TOPLEFT", nil, "TOPLEFT", 0, -50)"#)
.expect("move A");
s.resolve();
let plate_now = s.eval::<f64>("return Plate:GetLeft()").expect("plate left");
assert!(
(plate_now - 310.0).abs() < 0.01,
"the plate must not follow its OLD target any more (310), got {plate_now}"
);
}