Files
benilla-octo/crates/benilla-ui/src/script/tests/tooltip.rs
T
SamandClaude Fable 5 3b6975fdef the pet gets its frame, flooded rooms fill, and macros really fire
- the pet gets its own unit frame beside yours, and an Attack order no longer latches as the standing command
- five rooms that always read bone dry hold their water now, the Deeprun Tram's flooded stretches among them
- macros fire through the client's own chat door, and the macro window fits its tabs and scrolls its icon chooser
- the options window grows a Nameplates page and its first dropdown, and the name-display switches apply live in the world

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:03:43 +07:00

425 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.
//! The GameTooltip widget's engine mechanics (decision 0274): the line stack + named line
//! regions, auto-size from the measure round-trip, the right-flush, SetOwner's anchor law +
//! IsOwned, SetText's implicit show, both AddLine shapes, ClearLines/Hide firing
//! `OnTooltipCleared`, SetMinimumWidth's floor, and FadeOut's ramp-then-hide.
use super::common::script;
use crate::script::*;
/// Answer every pending line-measure with deterministic per-text sizes.
fn measure_all(s: &mut UiScript, sizes: &[(&str, f32, f32)]) {
let reqs = s.fontstrings_needing_measure();
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.filter_map(|r| {
sizes
.iter()
.find(|(t, _, _)| *t == r.text)
.map(|&(_, w, h)| (r.id, w, h, r.key))
})
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
}
/// The full stack: three lines (one double), measured, auto-sized, right-flushed, and the named
/// line globals published.
#[test]
fn line_stack_autosize_and_right_flush() {
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:SetSize(40, 40)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Tough Jerky", 1, 1, 1)
tt:AddDoubleLine("One-Hand", "Sword", 1, 1, 1, 1, 1, 1)
tt:AddLine("5 - 9 Damage")
tt:Show()
assert(tt:NumLines() == 3, "NumLines")
assert(TTTextLeft1 and TTTextRight2, "line globals published")
assert(TTTextLeft1:GetText() == "Tough Jerky", "line 1 text readable by name")
"#,
)
.unwrap();
s.resolve();
measure_all(
&mut s,
&[
("Tough Jerky", 80.0, 14.0),
("One-Hand", 50.0, 12.0),
("Sword", 30.0, 12.0),
("5 - 9 Damage", 70.0, 12.0),
],
);
// maxw = max(80, 50+40+30, 70) = 120 ⇒ width 140; totalh = 14+2+12+2+12 = 42 ⇒ height 62.
// ANCHOR_RIGHT: tooltip BOTTOMLEFT at owner TOPRIGHT (140, 500).
s.run(
r#"
assert(TT:GetWidth() == 140, "auto width, got " .. TT:GetWidth())
assert(TT:GetHeight() == 62, "auto height, got " .. TT:GetHeight())
"#,
)
.unwrap();
let quads = s.extract();
let rect_of = |needle: &str| {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == needle => q.rect,
_ => None,
})
.unwrap_or_else(|| panic!("no quad for {needle}"))
};
let l1 = rect_of("Tough Jerky");
// Frame: left 140, bottom 500 ⇒ text inset TOPLEFT (150, 552).
assert_eq!((l1.left, l1.top), (150.0, 552.0));
let r2 = rect_of("Sword");
// Right-flushed: right edge at frame.right − pad = 280 − 10 = 270.
assert_eq!(r2.right, 270.0);
// Seated on line 2's band (left2 top = 552 − 14 − 2 = 536).
let l2 = rect_of("One-Hand");
assert_eq!(l2.top, 536.0);
}
/// An EMPTY line mid-stack (the corpus' `AddLine("")`; the live shape was an empty-string
/// subtitle from the wire): a ZERO-height row that still charges its 2px slot gap — the ref's
/// empty-line shape. Pre-fix, an empty FontString never measures, so its unpinned bottom edge
/// fell back to the OWNER frame's bottom (the v1 region fallback): the line stretched to the
/// plate's bottom and the anchor chain marched every later line OUT of the plate (the live
/// NPC-tooltip spill — name inside, "Level 20"/"PvP" below the frame under the health bar).
#[test]
fn empty_line_is_a_zero_row_and_the_chain_stays_inside_the_plate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "Slot2")
owner:SetPoint("TOPLEFT", 100, -100); owner:SetSize(40, 40)
local tt = CreateFrame("GameTooltip", "TTE")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Marshal McBride", 0.25, 0.75, 0.25)
tt:AddLine("")
tt:AddLine("Level 20")
tt:AddLine("PvP")
tt:Show()
assert(tt:NumLines() == 4, "NumLines counts the empty line")
"#,
)
.unwrap();
s.resolve();
measure_all(
&mut s,
&[
("Marshal McBride", 90.0, 14.0),
("Level 20", 50.0, 12.0),
("PvP", 24.0, 12.0),
],
);
// Rows 14 + 0 + 12 + 12 with 3 slot gaps ⇒ totalh 44, height 64; maxw 90 ⇒ width 110.
s.run(
r#"
assert(TTE:GetWidth() == 110, "auto width, got " .. tostring(TTE:GetWidth()))
assert(TTE:GetHeight() == 64, "auto height, got " .. tostring(TTE:GetHeight()))
-- The chain stays contiguous through the zero row: Level 20 sits gap+0+gap under the name.
assert(TTETextLeft3:GetTop() == TTETextLeft1:GetBottom() - 4,
"chain contiguous through the empty row, got " .. tostring(TTETextLeft3:GetTop())
.. " vs " .. tostring(TTETextLeft1:GetBottom()))
-- And the tail line lands INSIDE the plate, a full pad above its bottom edge.
assert(TTETextLeft4:GetBottom() == TTE:GetBottom() + 10,
"tail line inside the plate, got " .. tostring(TTETextLeft4:GetBottom())
.. " vs plate bottom " .. tostring(TTE:GetBottom()))
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// SetOwner clears previous content (firing OnTooltipCleared), owns the frame for IsOwned, and
/// Hide drops the owner + content and fires OnTooltipCleared again.
#[test]
fn owner_clear_and_hide_lifecycle() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
cleared = 0
local a = CreateFrame("Button", "A"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local b = CreateFrame("Button", "B"); b:SetPoint("CENTER", 50, 0); b:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT2")
tt:SetScript("OnTooltipCleared", function() cleared = cleared + 1 end)
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:AddLine("first hover")
tt:Show()
assert(tt:IsOwned(a) and not tt:IsOwned(b), "owned by a")
assert(tt:GetOwner() == a, "GetOwner returns the owner wrapper")
tt:SetOwner(b, "ANCHOR_LEFT")
assert(cleared >= 1, "SetOwner cleared the old content")
assert(tt:NumLines() == 0, "content cleared on re-own")
assert(tt:IsOwned(b) and not tt:IsOwned(a), "owner moved")
tt:AddLine("second hover")
tt:Hide()
assert(not tt:IsShown(), "hidden")
assert(not tt:IsOwned(b), "owner dropped on hide")
assert(tt:NumLines() == 0, "content cleared on hide")
assert(cleared >= 2, "hide fired OnTooltipCleared")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// SetText shows the tooltip implicitly (the PaperDoll empty-slot flow calls no Show); AddLine
/// does not (the corpus' `AddLine … Show()`). The colour law: positional `r, g, b` apply only
/// when the r-slot is a number — the corpus' archaic `(text, "", r, g, b)` shape has a string
/// there, so the real 1.12 binding drops the whole colour tail and renders the DEFAULT GOLD
/// (the reference's zone tooltip, director-matched; the trailing numbers never shift into place).
#[test]
fn settext_shows_and_both_addline_shapes() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Button", "A3"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT3")
tt:Hide() -- the XML instance ships hidden="true"; CreateFrame defaults shown
tt:SetOwner(a, "ANCHOR_RIGHT")
assert(not tt:IsShown(), "SetOwner alone does not show")
tt:AddLine("plain")
assert(not tt:IsShown(), "AddLine does not show")
tt:SetText("Head", 1, 1, 1)
assert(tt:IsShown(), "SetText shows")
assert(tt:NumLines() == 1, "SetText replaced the stack")
-- the archaic 1.12 shape: AddLine(text, "", r, g, b) — the "" kills the colour tail
tt:AddLine("Zone Name", "", 1.0, 0.25, 0.5)
-- no colour at all — same default
tt:AddLine("plain gold")
-- the modern shape with wrap flag
tt:AddLine("wrapped tail", 0.2, 0.4, 0.6, 1)
-- a numeric r gates the block ON; missing g/b are UNGATED tonumber reads -> 0.0
tt:AddLine("red only", 1)
-- SetText requires its text: the binding raises its Usage error and adds no line
assert(not pcall(function() tt:SetText() end), "SetText() must error")
"#,
)
.unwrap();
s.resolve();
let quads = s.extract();
let color_of = |needle: &str| {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == needle => *color,
_ => None,
})
.unwrap_or_else(|| panic!("no quad for {needle}"))
};
// The engine default gold 0xffffd200 (both no-colour shapes land on it).
let gold = [1.0, 210.0 / 255.0, 0.0, 1.0];
assert_eq!(color_of("Zone Name"), gold);
assert_eq!(color_of("plain gold"), gold);
assert_eq!(color_of("wrapped tail"), [0.2, 0.4, 0.6, 1.0]);
// The partial tail: r gates the block on, missing g/b read as 0.0 (byte-pinned).
assert_eq!(color_of("red only"), [1.0, 0.0, 0.0, 1.0]);
assert!(s.take_errors().is_empty());
}
/// A tooltip whose lines haven't been measured yet holds its declared size — the gaps alone must
/// never resolve it to a gaps-only rect (the first cut summed LINE_GAP/DOUBLE_GAP for unmeasured
/// rows and collapsed a fresh 4-line tooltip to 60×26; caught during the 0274 call-site
/// migration).
#[test]
fn unmeasured_lines_hold_declared_size() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Button", "A6"); a:SetPoint("TOPLEFT", 100, -100); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT6")
tt:SetSize(120, 32)
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:AddLine("Tough Jerky")
tt:AddDoubleLine("One-Hand", "Sword")
tt:AddLine("5 - 9 Damage")
tt:AddLine("(3.7 damage per second)")
tt:Show()
"#,
)
.unwrap();
// Resolve WITHOUT answering the measure round-trip: the declared 120×32 must hold.
s.resolve();
s.run(
r#"
assert(TT6:GetWidth() == 120, "declared width holds unmeasured, got " .. TT6:GetWidth())
assert(TT6:GetHeight() == 32, "declared height holds unmeasured, got " .. TT6:GetHeight())
"#,
)
.unwrap();
// Then the measures land and the auto-size takes over.
measure_all(
&mut s,
&[
("Tough Jerky", 80.0, 14.0),
("One-Hand", 50.0, 12.0),
("Sword", 30.0, 12.0),
("5 - 9 Damage", 70.0, 12.0),
("(3.7 damage per second)", 110.0, 12.0),
],
);
s.run(r#"assert(TT6:GetWidth() == 140, "auto width after measures, got " .. TT6:GetWidth())"#)
.unwrap();
}
/// SetMinimumWidth floors the auto width (the money row's law), and clears with the content.
#[test]
fn minimum_width_floors_autosize() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local a = CreateFrame("Button", "A4"); a:SetPoint("TOPLEFT", 100, -100); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT4")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:AddLine("tiny")
tt:SetMinimumWidth(90)
tt:Show()
"#,
)
.unwrap();
s.resolve();
measure_all(&mut s, &[("tiny", 20.0, 12.0)]);
// floor 90 beats content 20 ⇒ width 110.
s.run(r#"assert(TT4:GetWidth() == 110, "floored width, got " .. TT4:GetWidth())"#)
.unwrap();
}
/// FadeOut ramps the frame alpha down and hides at the end of the ramp (owner + lines dropped,
/// OnTooltipCleared fired); fresh content mid-fade cancels the ramp at full alpha.
#[test]
fn fadeout_ramps_then_hides() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
cleared = 0
local a = CreateFrame("Button", "A5"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT5")
tt:SetScript("OnTooltipCleared", function() cleared = cleared + 1 end)
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetText("Fading Unit")
tt:FadeOut()
"#,
)
.unwrap();
// Half the ramp: still shown, alpha well below 1.
s.tick(0.25);
s.run(r#"assert(TT5:IsShown(), "still shown mid-fade")"#)
.unwrap();
s.resolve();
let mid_alpha = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == "Fading Unit" => Some(q.alpha),
_ => None,
})
.expect("fading line still draws");
assert!(
mid_alpha > 0.2 && mid_alpha < 0.8,
"mid-fade alpha ~0.5, got {mid_alpha}"
);
// Re-content cancels the fade at full alpha.
s.run(
r#"
TT5:SetText("Fresh Hover")
"#,
)
.unwrap();
s.tick(0.05);
s.resolve();
let fresh_alpha = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == "Fresh Hover" => Some(q.alpha),
_ => None,
})
.expect("fresh line draws");
assert_eq!(fresh_alpha, 1.0, "fresh content restored full alpha");
// Run a full ramp to the end: hidden + cleared.
s.run("TT5:FadeOut()").unwrap();
s.tick(0.6);
s.run(
r#"
assert(not TT5:IsShown(), "hidden at ramp end")
assert(TT5:NumLines() == 0, "content dropped at ramp end")
assert(cleared >= 1, "OnTooltipCleared fired")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// SetOwner's anchor law at the screen edge CLAMPS — the client's geometry-flags-bit4 clamp
/// (`assemble 0x767a20`, wow-re layout.md), carried by every GameTooltip frame **by
/// construction** (decision 0352: no tooltip ever leaves the window). The reproduction is the
/// minimap zone-text hover (MinimapCluster.xml: `ANCHOR_LEFT` on a button at the very top of
/// the screen — plate bottom-right at the owner's top-left seats it wholly ABOVE the window):
/// the reference plate hangs DOWN from the screen top instead, size preserved, X untouched.
#[test]
fn owner_anchored_tooltip_clamps_to_screen() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "ZoneTextBtn")
owner:SetPoint("TOPRIGHT", -100, 0); owner:SetSize(90, 14)
local tt = CreateFrame("GameTooltip", "TTC")
assert(tt:IsClampedToScreen(), "a GameTooltip clamps by construction")
assert(not owner:IsClampedToScreen(), "a plain frame does not")
tt:SetOwner(owner, "ANCHOR_LEFT")
tt:AddLine("Goldshire")
tt:AddLine("Alliance Territory")
tt:Show()
"#,
)
.unwrap();
s.resolve();
measure_all(
&mut s,
&[
("Goldshire", 70.0, 14.0),
("Alliance Territory", 120.0, 12.0),
],
);
// Auto-size: maxw 120 ⇒ width 140; totalh 14+2+12 ⇒ height 48. Unclamped, the plate's
// BOTTOMRIGHT sits at the owner's TOPLEFT (610, 600) — top 648, wholly above the window.
// The clamp shifts it back down: top at the screen top, size preserved, X untouched.
s.run(
r#"
assert(TTC:GetTop() == 600, "top clamped to the screen top, got " .. TTC:GetTop())
assert(TTC:GetBottom() == 552, "size preserved, got " .. TTC:GetBottom())
assert(TTC:GetRight() == 610, "X untouched (inside), got " .. TTC:GetRight())
TTC:SetClampedToScreen(false)
"#,
)
.unwrap();
s.resolve();
// The flag routes: unclamped, the plate returns to the raw anchor law above the window.
s.run(
r#"
assert(not TTC:IsClampedToScreen(), "flag readable")
assert(TTC:GetBottom() == 600, "unclamped: bottom back at the owner's top")
assert(TTC:GetTop() == 648, "unclamped: off the window again")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}