Files
benilla-octo/crates/benilla-ui/src/script/tests/tooltip.rs
T
Sam SungandClaude Opus 5.5 51a2ce5586 Layout writes invalidate as 1.12.1 does; only a reached node re-resolves (#964)
The solver took any node's recomputed rect whenever it resolved, because about 55 layout writes named a node without invalidating it. So a freed region stayed in its dependents' anchors, `SetTexture` re-resolved a failed texture, and a scroll child kept its authored anchors behind a per-solve override.

In 1.12.1 a node re-resolves only when invalidated or a target stores a new rect (`0x768d20`, slot 0 `0x768cf0`). `SetTexture` only dirties the draw (`0x770200`). A destructor frees its dependents' anchors to it (`0x768e20` → `0x767e70`, no invalidate). A scroll child is re-anchored to one TOPLEFT (`0x787100`).

Every write now invalidates exactly where the reference's does, and the solver's guess, the hold and the art latch are gone. The tooltip invalidates only on its relayout (`0x530640`).

Tests bite on the parent; the verifier is green; the stock UI's quads are byte-identical.

Fixes #947
Fixes #949
Fixes #958

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:46:24 +07:00

1218 lines
43 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: the line stack and its auto-size, `SetOwner`'s
//! anchor modes, the implicit and gated shows, and the fade.
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();
}
#[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:SetWidth(40); owner:SetHeight(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);
}
/// The layout re-points each shown right cell by its RIGHT slot alone, `SetPoint(RIGHT, leftCell,
/// LEFT, width, 0)` (`0x53094f`): a point an addon added beside it keeps its offset, and after a
/// `ClearAllPoints` the RIGHT point comes back.
#[test]
fn the_right_column_write_sets_the_right_slot_only() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "SlotR")
owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40)
local tt = CreateFrame("GameTooltip", "TTR")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Tough Jerky", 1, 1, 1)
tt:AddDoubleLine("One-Hand", "Sword", 1, 1, 1, 1, 1, 1)
tt:Show()
"#,
)
.unwrap();
s.resolve();
measure_all(
&mut s,
&[
("Tough Jerky", 80.0, 14.0),
("One-Hand", 50.0, 12.0),
("Sword", 30.0, 12.0),
],
);
// maxw = max(80, 50 + 40 + 30) = 120.
let right_anchor = r#"
local p, rel, rp, x, y = TTRTextRight2:GetPoint(N)
return p, rel == TTRTextLeft2, rp, x, y
"#;
// An addon adds LEFT without clearing; LEFT precedes RIGHT in slot order.
s.run(r#"TTRTextRight2:SetPoint("LEFT", TTRTextLeft2, "RIGHT", 7, 0)"#)
.unwrap();
s.resolve();
s.resolve();
let (p, x): (String, f64) = s
.eval("local p, _, _, x = TTRTextRight2:GetPoint(1); return p, x")
.unwrap();
assert_eq!(
(p.as_str(), x),
("LEFT", 7.0),
"the addon's point keeps its offset"
);
let (p, rel, rp, x, y): (String, bool, String, f64, f64) =
s.eval(&right_anchor.replace('N', "2")).unwrap();
assert_eq!(
(p.as_str(), rel, rp.as_str(), x, y),
("RIGHT", true, "LEFT", 120.0, 0.0)
);
s.run("TTRTextRight2:ClearAllPoints()").unwrap();
s.resolve();
s.resolve();
assert_eq!(
s.eval::<i64>("return TTRTextRight2:GetNumPoints()")
.unwrap(),
1,
"the layout sets RIGHT again after a ClearAllPoints"
);
let (p, rel, rp, x, y): (String, bool, String, f64, f64) =
s.eval(&right_anchor.replace('N', "1")).unwrap();
assert_eq!(
(p.as_str(), rel, rp.as_str(), x, y),
("RIGHT", true, "LEFT", 120.0, 0.0)
);
}
/// An empty line is a one-unit row plus its 2 px gap, as the reference's size getters floor a
/// FontString at one FrameXML unit; the row and the plate read that floor from one constant.
#[test]
fn empty_line_is_a_one_unit_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:SetWidth(40); owner:SetHeight(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 + 1 + 12 + 12 with 3 slot gaps ⇒ totalh 45, height 65; maxw 90 ⇒ width 110.
// The reference's `AddLine("")` returns without adding a line (`0x530270`); ours adds this row.
s.run(
r#"
assert(TTE:GetWidth() == 110, "auto width, got " .. tostring(TTE:GetWidth()))
assert(TTE:GetHeight() == 65, "auto height, got " .. tostring(TTE:GetHeight()))
-- The chain stays contiguous through the blank row: Level 20 sits gap+1+gap under the name.
assert(TTETextLeft3:GetTop() == TTETextLeft1:GetBottom() - 5,
"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());
}
#[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:SetWidth(10); a:SetHeight(10)
local b = CreateFrame("Button", "B"); b:SetPoint("CENTER", 50, 0); b:SetWidth(10); b:SetHeight(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")
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());
}
/// The host reads a tooltip's owner by name; Lua has only `IsOwned(frame)`, and no method of ours
/// answers the owner.
#[test]
fn the_host_reads_the_owner_and_lua_cannot() {
let s = script();
s.run(
r#"
local a = CreateFrame("Button", "OwnA")
local b = CreateFrame("Button", "OwnB")
TTO = CreateFrame("GameTooltip", "TTO")
TTO:SetOwner(a, "ANCHOR_RIGHT")
"#,
)
.unwrap();
assert_eq!(s.tooltip_owner_name("TTO").as_deref(), Some("OwnA"));
s.run(r#"TTO:SetOwner(OwnB, "ANCHOR_LEFT")"#).unwrap();
assert_eq!(s.tooltip_owner_name("TTO").as_deref(), Some("OwnB"));
s.run("TTO:Hide()").unwrap();
assert_eq!(s.tooltip_owner_name("TTO"), None, "the hide un-owns");
assert_eq!(s.tooltip_owner_name("NoSuchTooltip"), None);
assert_eq!(s.tooltip_owner_name("OwnA"), None, "a Button is no tooltip");
for method in ["BenillaGetTooltipOwner", "BenillaSetItemById"] {
assert!(
s.eval::<bool>(&format!("return TTO.{method} == nil"))
.unwrap(),
"GameTooltip:{method} is reachable"
);
}
}
/// `SetText` shows the tooltip and `AddLine` does not. `r, g, b` apply only when the r slot is a
/// number, so `AddLine(text, "", r, g, b)` draws in the default gold, as the reference's zone
/// tooltip does.
#[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:SetWidth(10); a:SetHeight(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 default gold `0xffffd200`, where both no-colour shapes land.
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]);
// A numeric r gates the block on, and a missing g or b reads as 0.
assert_eq!(color_of("red only"), [1.0, 0.0, 0.0, 1.0]);
assert!(s.take_errors().is_empty());
}
/// Until its lines are measured, a tooltip keeps its declared size rather than a gaps-only one.
#[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:SetWidth(10); a:SetHeight(10)
local tt = CreateFrame("GameTooltip", "TT6")
tt:SetWidth(120); tt:SetHeight(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: the declared 120×32 holds.
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();
}
#[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:SetWidth(10); a:SetHeight(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();
}
/// 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:SetWidth(10); a:SetHeight(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(1.5);
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}"
);
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");
// A full ramp ends hidden and cleared.
s.run("TT5:FadeOut()").unwrap();
s.tick(2.1);
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());
}
/// Every GameTooltip clamps to the screen (the geometry-flags bit 4 clamp, `assemble 0x767a20`), so
/// the zone text's `ANCHOR_LEFT` (`Minimap.lua:39`) hangs down from the screen top, not above it.
#[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:SetWidth(90); owner:SetHeight(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 at the owner's TOPLEFT (610, 600) puts its top at 648.
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();
// Unclamped, the plate goes back 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());
}
/// `0x52fa50`'s duration ladder over stand-in templates: the arm edges, the ceiling on every arm
/// but seconds, and the `_P1` plural pick.
#[test]
fn the_duration_ladder_ceils_every_arm_but_seconds() {
// Not the shipped wording: the test is which key is reached and what number fills it.
let table = |key: &str| -> Option<String> {
Some(
match key {
"T_DAYS" => "<%d day>",
"T_DAYS_P1" => "<%d days>",
"T_HOURS" => "<%d hour>",
"T_HOURS_P1" => "<%d hours>",
"T_MIN" => "<%d min>",
"T_MIN_P1" => "<%d mins>",
"T_SEC" => "<%d sec>",
"T_SEC_P1" => "<%d secs>",
_ => return None,
}
.to_string(),
)
};
let d = |ms: u32| crate::script::tooltip::duration_text(ms, "T", true, &table);
// The three arm edges are exact, and the arm below counts in its own unit up to the edge.
assert_eq!(d(86_400_000).as_deref(), Some("<1 day>"), "the day edge");
assert_eq!(
d(86_399_999).as_deref(),
Some("<24 hours>"),
"one ms under a day is still HOURS, and ceil takes it to 24"
);
assert_eq!(d(3_600_000).as_deref(), Some("<1 hour>"), "the hour edge");
assert_eq!(
d(3_599_999).as_deref(),
Some("<60 mins>"),
"the worked example: no '1 hour' until the hour is whole"
);
assert_eq!(d(60_000).as_deref(), Some("<1 min>"), "the minute edge");
assert_eq!(
d(61_000).as_deref(),
Some("<2 mins>"),
"the worked example: 61 s ceils to 2, it does not truncate to 1"
);
// roundUp reaches the top three arms only.
assert_eq!(
d(59_999).as_deref(),
Some("<59 secs>"),
"seconds TRUNCATE — a ceil here would read 60"
);
assert_eq!(d(5_400).as_deref(), Some("<5 secs>"), "5.4 s is 5, not 6");
assert_eq!(
d(400).as_deref(),
Some("<0 secs>"),
"the lapsing second: 0, and PLURAL — the reference's own last reading"
);
assert_eq!(d(0).as_deref(), Some("<0 secs>"), "a fully lapsed aura");
assert_eq!(
d(1_000).as_deref(),
Some("<1 sec>"),
"exactly one is singular"
);
// roundUp = 0 truncates the top arms too.
assert_eq!(
crate::script::tooltip::duration_text(61_000, "T", false, &table).as_deref(),
Some("<1 min>"),
);
// A key the string table lacks yields no line.
assert_eq!(
crate::script::tooltip::duration_text(1_000, "NOPE", true, &table),
None
);
// A family with only the singular falls back to it.
let singular_only = |key: &str| (key == "S_SEC").then(|| "<%d s>".to_string());
assert_eq!(
crate::script::tooltip::duration_text(5_000, "S", true, &singular_only).as_deref(),
Some("<5 s>"),
);
}
/// The solver keeps a line's last measured box, unkeyed, while a re-measure is in flight, and an
/// empty line is never measured, so a pooled cell that goes empty must drop its old box: the item
/// tooltip's set block puts blank spacers on cells that held text.
#[test]
fn an_emptied_pooled_line_drops_its_stale_box_and_the_plate_still_contains_the_chain() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
// Hover one: line 2 carries real text, and gets measured.
s.run(
r#"
local owner = CreateFrame("Button", "SlotR")
owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40)
local tt = CreateFrame("GameTooltip", "TTR")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Marshal McBride")
tt:AddLine("Level 20")
tt:AddLine("PvP")
tt:Show()
"#,
)
.unwrap();
s.resolve();
let sizes = &[
("Marshal McBride", 90.0, 14.0),
("Level 20", 50.0, 12.0),
("PvP", 24.0, 12.0),
];
measure_all(&mut s, sizes);
// Hover two, same pooled cells: line 2 is now the blank spacer, which is never measured.
s.run(
r#"
TTR:ClearLines()
TTR:AddLine("Marshal McBride")
-- WRAPPED, as the set spacer is (`render.rs`'s `addw`): the wrap pin writes a non-zero
-- width into `size`, so only the HEIGHT falls through to the measure cache.
TTR:AddLine("", 1, 0.82, 0, true)
TTR:AddLine("PvP")
"#,
)
.unwrap();
s.resolve();
measure_all(&mut s, sizes);
s.run(
r#"
-- Rows 14 + 1 + 12 with two slot gaps ⇒ totalh 31, height 51: the blank costs its gap
-- and one floored unit, exactly as it does on a cell that never held text.
assert(TTR:GetHeight() == 51, "auto height, got " .. tostring(TTR:GetHeight()))
-- The one that was wrong: the tail line sits a full pad above the plate's bottom edge.
-- Pre-fix it sat 12 BELOW it — line 2's dead "Level 20" box, drawn but never counted.
assert(TTRTextLeft3:GetBottom() == TTR:GetBottom() + 10,
"tail line inside the plate, got " .. tostring(TTRTextLeft3:GetBottom())
.. " vs plate bottom " .. tostring(TTR:GetBottom()))
-- And the chain is contiguous through the blank row, as on a cold cell.
assert(TTRTextLeft3:GetTop() == TTRTextLeft1:GetBottom() - 5,
"chain contiguous through the emptied row, got " .. tostring(TTRTextLeft3:GetTop()))
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// An omitted `anchorType` is mode 0, ANCHOR_LEFT: `0x53120d` zeroes the local, the `lua_isstring`
/// gate at `0x531214` skips the compare chain, and nothing in `[0x531221, 0x53133a)` raises. The
/// core's pre-store of mode 7 (`0x530012`) survives only a NULL owner (`0x530031`). `0x52fe90`
/// returns for mode 8 (`0x52fead`) before its clear (`0x52fec2 call 0x767ed0`), and `SetOwner`
/// always passes the arg that gates mode 7's skip as 1, so only ANCHOR_PRESERVE keeps the points.
#[test]
fn set_owner_defaults_to_anchor_left_and_only_preserve_keeps_the_placement() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Plate = CreateFrame("GameTooltip", "Plate")
Owner = CreateFrame("Frame", "Owner")
Owner:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 300, 300)
Owner:SetWidth(40) Owner:SetHeight(40)
Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
Plate:SetWidth(50) Plate:SetHeight(20)
"#,
)
.unwrap();
s.resolve();
assert_eq!(s.eval::<f32>("return Plate:GetLeft()").unwrap(), 100.0);
// ANCHOR_PRESERVE (mode 8) is the one mode that leaves the plate alone.
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_PRESERVE")"#)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return Plate:GetLeft()").unwrap(),
100.0,
"mode 8 returns before the ClearAllPoints"
);
assert_eq!(s.eval::<i64>("return Plate:GetNumPoints()").unwrap(), 1);
// ANCHOR_NONE (mode 7) clears, which `GameTooltip.lua:73`'s default anchor relies on.
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_NONE")"#).unwrap();
assert_eq!(
s.eval::<i64>("return Plate:GetNumPoints()").unwrap(),
0,
"mode 7 reaches the clear: arg1 is always 1 from SetOwner's core"
);
// No anchor string: mode 0 places the plate's BOTTOMRIGHT at the owner's TOPLEFT.
s.run(r#"Plate:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)"#)
.unwrap();
s.run("Plate:SetOwner(Owner)").unwrap();
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT",
"the omitted anchorType is the binding's zero-initialised local, mode 0"
);
s.resolve();
assert_eq!(
s.eval::<f32>("return Plate:GetLeft()").unwrap(),
250.0,
"the owner's left edge (300) less the plate's own 50 wide"
);
// An unrecognised string falls through the compare chain: the same silent mode 0.
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_SIDEWAYS")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT"
);
}
/// The `SetOwner` core `0x52ffe0` first sets alpha 255 (`0x52fff4`), whatever dimmed the plate.
#[test]
fn set_owner_stamps_full_alpha_even_with_no_fade_running() {
let s = script();
s.run(
r#"
Owner = CreateFrame("Frame", "Owner")
Tip = CreateFrame("GameTooltip", "Tip")
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
Tip:AddLine("Tough Jerky", 1, 1, 1)
Tip:Show()
-- No fade is running: this is an outside party dimming the plate.
Tip:SetAlpha(0.3)
assert(Tip:GetAlpha() < 0.31, "the dimming took")
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
assert(Tip:GetAlpha() > 0.99, "the next SetOwner stamps 255 back, fade or no fade")
"#,
)
.unwrap();
}
/// `Show` (`0x530a80`, the `vtbl+0x88` override) shows only with an owner (`+0x314`) and a line
/// (`+0x31c`); otherwise it calls `0x530a60` (`vtbl+0x84`), the `SetOwner` core with a NULL owner,
/// which hides and un-owns. `QuestieTracker.lua`'s OnEnter can call `Show` with no line.
#[test]
fn show_with_no_lines_self_hides_and_un_owns() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Owner = CreateFrame("Button", "Owner")
Owner:SetPoint("TOPLEFT", nil, "TOPLEFT", 100, -100)
Owner:SetWidth(40) Owner:SetHeight(40)
Tip = CreateFrame("GameTooltip", "Tip")
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
Tip:AddLine("Tough Jerky", 1, 1, 1)
Tip:Show()
assert(Tip:IsShown(), "owner + 1 line: both halves of the gate pass")
assert(Tip:IsOwned(Owner), "and the owner survives a real show")
"#,
)
.unwrap();
// Questie's shape: a re-hover's `SetOwner` clears the lines, nothing is added, `Show` runs.
s.run(
r#"
Tip:SetOwner(Owner, "ANCHOR_RIGHT")
assert(Tip:NumLines() == 0, "SetOwner cleared the last hover's lines")
Tip:Show()
assert(not Tip:IsShown(), "zero lines takes the self-hide leg of 0x530a80")
assert(not Tip:IsOwned(Owner), "and 0x530a60 un-owns: it is the SetOwner core with NULL")
"#,
)
.unwrap();
// A line but no owner is the other half of the gate.
s.run(
r#"
Tip:AddLine("Orphan", 1, 1, 1)
Tip:Show()
assert(not Tip:IsShown(), "lines without an owner is the same self-hide")
"#,
)
.unwrap();
}
/// `lua_isstring 0x6f3510` passes only a string or a number; any other tag jumps the compare chain
/// (`0x53121b je 0x53133a`) to mode 0 and raises nothing. `QuestieNotes.lua` passes a frame here
/// (`SetOwner(this, this)`).
#[test]
fn a_non_string_anchor_is_silently_mode_zero_and_never_raises() {
let s = script();
s.run(
r#"
Plate = CreateFrame("GameTooltip", "Plate")
Owner = CreateFrame("Frame", "Owner")
"#,
)
.unwrap();
// A table, as Questie passes.
s.run("Plate:SetOwner(Owner, Owner)")
.expect("a table anchor takes the isstring gate's jump, it does not raise");
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT"
);
// A boolean and an explicit nil take the same jump.
for arg in ["true", "false", "nil"] {
s.run(&format!(
r#"Plate:SetOwner(Owner, "ANCHOR_RIGHT") Plate:SetOwner(Owner, {arg})"#
))
.unwrap_or_else(|e| panic!("SetOwner(Owner, {arg}) raised: {e}"));
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT",
"{arg} is indistinguishable from absent at this position"
);
}
// A number passes the gate as a string that matches no mode: mode 0 again.
s.run(r#"Plate:SetOwner(Owner, "ANCHOR_RIGHT") Plate:SetOwner(Owner, 5)"#)
.unwrap();
assert_eq!(
s.eval::<String>("return Plate:GetAnchorType()").unwrap(),
"ANCHOR_LEFT"
);
}
/// `ANCHOR_CURSOR`, mode 6, clears at `SetOwner` like mode 7; the per-frame update `0x530b20` then
/// pins the plate's BOTTOM to the screen's BOTTOMLEFT at the cursor, over its effective scale.
#[test]
fn anchor_cursor_follows_the_cursor_every_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
Tip = CreateFrame("GameTooltip", "Tip")
Host = CreateFrame("Frame", "Host")
Tip:SetOwner(Host, "ANCHOR_CURSOR")
Tip:AddLine("Copper Ore")
-- An explicit size, because this VM has no text measurer: the auto-size pre-pass leaves a
-- plate with no measured extents at its declared size, and the placement is what is under
-- test, not the width.
Tip:SetWidth(120) Tip:SetHeight(40)
Tip:Show()
"#,
)
.unwrap();
assert_eq!(
s.eval::<String>("return Tip:GetAnchorType()").unwrap(),
"ANCHOR_CURSOR",
"the mode round-trips; it is no longer recorded as whatever it was placed by"
);
assert!(
!s.warnings().iter().any(|w| w.contains("ANCHOR_CURSOR")),
"a mode we honour is not warned about: {:?}",
s.warnings()
);
s.mouse_move(300.0, 200.0);
s.resolve();
let (left, right, bottom): (f32, f32, f32) = s
.eval("return Tip:GetLeft(), Tip:GetRight(), Tip:GetBottom()")
.unwrap();
assert_eq!(bottom, 200.0, "the plate's BOTTOM sits at the cursor");
assert!(
((left + right) / 2.0 - 300.0).abs() < 0.001,
"centred horizontally on the cursor: {left}..{right}"
);
// It follows the cursor with no second `SetOwner`.
s.mouse_move(120.0, 480.0);
s.resolve();
let (left, right, bottom): (f32, f32, f32) = s
.eval("return Tip:GetLeft(), Tip:GetRight(), Tip:GetBottom()")
.unwrap();
assert_eq!(bottom, 480.0);
assert!(
((left + right) / 2.0 - 120.0).abs() < 0.001,
"{left}..{right}"
);
}
/// The cursor update (`0x530b20`) makes one `SetPoint` for BOTTOM (`0x767c70`) and clears
/// nothing, so a point an addon set after `SetOwner` stays held beside it.
#[test]
fn anchor_cursor_keeps_a_point_set_after_set_owner() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
KeepTip = CreateFrame("GameTooltip", "KeepTip")
KeepHost = CreateFrame("Frame", "KeepHost")
KeepTip:SetOwner(KeepHost, "ANCHOR_CURSOR")
KeepTip:AddLine("Copper Ore")
KeepTip:SetWidth(120) KeepTip:SetHeight(40)
KeepTip:Show()
KeepTip:SetPoint("BOTTOMRIGHT", KeepHost, "BOTTOMRIGHT", 0, 0)
"#,
)
.unwrap();
for (x, y) in [(300.0, 200.0), (120.0, 480.0)] {
s.mouse_move(x, y);
s.resolve();
assert_eq!(
s.eval::<i64>("return KeepTip:GetNumPoints()").unwrap(),
2,
"BOTTOM and the addon's BOTTOMRIGHT"
);
let (p1, p2): (String, String) = s
.eval("return (KeepTip:GetPoint(1)), (KeepTip:GetPoint(2))")
.unwrap();
assert_eq!((p1.as_str(), p2.as_str()), ("BOTTOM", "BOTTOMRIGHT"));
}
}
/// `GetAnchorType` (`0x5313e0`, table `0x854198`) answers one string, `[+0x318]` through the name
/// table `0x531530`; `_Nameplates.lua:479` compares it with what it passed to `SetOwner`.
#[test]
fn tooltip_anchor_type_round_trips_every_reachable_mode() {
let s = script();
s.run(
r#"
AnchorTip = CreateFrame("GameTooltip", "AnchorTip")
AnchorOwner = CreateFrame("Frame", "AnchorOwner")
"#,
)
.unwrap();
// Never nil: a plate nobody has owned answers a string too.
assert_eq!(s.arity("AnchorTip:GetAnchorType()").unwrap(), 1, "arity 1");
assert_eq!(
s.eval::<String>("return type(AnchorTip:GetAnchorType())")
.unwrap(),
"string",
"kind string, never nil"
);
assert_eq!(
s.eval::<String>("return AnchorTip:GetAnchorType()")
.unwrap(),
"ANCHOR_NONE",
"a plate nothing has owned is anchored to nothing"
);
// All nine modes round-trip, ANCHOR_PRESERVE included: the reference stores it like any other.
for mode in [
"ANCHOR_RIGHT",
"ANCHOR_LEFT",
"ANCHOR_TOPRIGHT",
"ANCHOR_TOPLEFT",
"ANCHOR_BOTTOMRIGHT",
"ANCHOR_BOTTOMLEFT",
"ANCHOR_CURSOR",
"ANCHOR_NONE",
"ANCHOR_PRESERVE",
] {
s.run(&format!(r#"AnchorTip:SetOwner(AnchorOwner, "{mode}")"#))
.unwrap();
assert_eq!(
s.eval::<String>("return AnchorTip:GetAnchorType()")
.unwrap(),
mode,
"round trip through SetOwner"
);
}
// Lower case in, the canonical spelling out.
s.run(r#"AnchorTip:SetOwner(AnchorOwner, "anchor_left")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return AnchorTip:GetAnchorType()")
.unwrap(),
"ANCHOR_LEFT"
);
// An unrecognised anchor is mode 0, with no raise (`0x53120d`). Deviation: we also log a
// warning so the unknown token shows up in diagnostics; no addon can see it.
s.run(r#"AnchorTip:SetOwner(AnchorOwner, "ANCHOR_SIDEWAYS")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return AnchorTip:GetAnchorType()")
.unwrap(),
"ANCHOR_LEFT"
);
assert!(
s.warnings().iter().any(|w| w.contains("ANCHOR_SIDEWAYS")),
"the unrecognised anchor is still reported to us: {:?}",
s.warnings()
);
}
/// `FadeOut` (`0x530ae0`) arms `[+0x3b8]` with the 2.0 s at `0x808e7c` on every call
/// (`0x530b10`); the update (`0x530b20`) holds 255 while at least half remains (`0x7ffa24` = 0.5,
/// `0x530c0c`) and below it sets `trunc(255 × left / half)` (`0x530c18`–`0x530c2c`), then hides at
/// zero (`0x530bc8`–`0x530be9`).
#[test]
fn fadeout_holds_a_second_then_ramps_over_the_next_and_every_call_rearms() {
let mut s = script();
s.run(
r#"
FadeHost = CreateFrame("Frame", "FadeHost")
FadeTip = CreateFrame("GameTooltip", "FadeTip")
FadeTip:SetOwner(FadeHost, "ANCHOR_RIGHT")
FadeTip:SetText("Timber Wolf")
FadeTip:FadeOut()
"#,
)
.unwrap();
let alpha = |s: &UiScript| s.eval::<f32>("return FadeTip:GetAlpha()").unwrap();
let shown = |s: &UiScript| s.eval::<bool>("return FadeTip:IsShown() == 1").unwrap();
s.tick(0.5);
assert_eq!(alpha(&s), 1.0, "1.5 s left of 2: the hold");
assert!(shown(&s));
s.tick(1.0);
assert_eq!(
alpha(&s),
127.0 / 255.0,
"0.5 s left: trunc(255 × 0.5 / 1.0)"
);
assert!(shown(&s));
s.tick(0.5);
assert!(!shown(&s), "2.0 s: the fade ends in the hide");
// A second call restarts the countdown from the full duration.
s.run(
r#"
FadeTip:SetOwner(FadeHost, "ANCHOR_RIGHT")
FadeTip:SetText("Timber Wolf")
FadeTip:FadeOut()
"#,
)
.unwrap();
s.tick(1.5);
assert_eq!(alpha(&s), 127.0 / 255.0);
s.run("FadeTip:FadeOut()").unwrap();
s.tick(0.5);
assert_eq!(alpha(&s), 1.0, "re-armed: 1.5 s left, back in the hold");
s.tick(1.0);
assert!(
shown(&s),
"the first arm's end has passed, the second's has not"
);
s.tick(0.5);
assert!(!shown(&s));
assert!(s.take_errors().is_empty());
}
/// `FadeOut` on an `ANCHOR_CURSOR` plate (mode 6) hides it at once through the hide slot
/// (`0x530ae3`–`0x530aff`), which un-owns it; the shown bit is not written, so only visibility
/// drops.
#[test]
fn fadeout_on_a_cursor_anchored_tooltip_hides_at_once() {
let mut s = script();
s.run(
r#"
hid = 0
CurHost = CreateFrame("Frame", "CurHost")
CurTip = CreateFrame("GameTooltip", "CurTip")
CurTip:SetScript("OnHide", function() hid = hid + 1 end)
CurTip:SetOwner(CurHost, "ANCHOR_CURSOR")
CurTip:AddLine("Copper Ore")
CurTip:Show()
assert(CurTip:IsVisible())
hid = 0
CurTip:FadeOut()
assert(not CurTip:IsVisible(), "mode 6 hides at once")
assert(CurTip:IsShown(), "through the hide slot alone: the shown bit stays (0x530aee)")
assert(hid == 1, "OnHide fired in the call")
assert(not CurTip:IsOwned(CurHost), "the hide slot un-owns")
assert(CurTip:NumLines() == 0, "and clears")
assert(CurTip:GetAlpha() == 1, "no fade ran")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// Only `FadeOut`, the show, the `SetOwner` core and the ctor write the fade flag `[+0x3b4]`;
/// `AddLine` (`0x531630`) and `AddDoubleLine` append through `0x5303b0` and leave it.
#[test]
fn a_line_added_mid_fade_keeps_fading() {
let mut s = script();
s.run(
r#"
LateHost = CreateFrame("Frame", "LateHost")
LateTip = CreateFrame("GameTooltip", "LateTip")
LateTip:SetOwner(LateHost, "ANCHOR_RIGHT")
LateTip:SetText("Linen Cloth")
LateTip:FadeOut()
"#,
)
.unwrap();
s.tick(1.5);
s.run(
r#"
LateTip:AddLine("Sell Price: 1c")
LateTip:AddDoubleLine("Vendor", "1c")
assert(math.abs(LateTip:GetAlpha() - 127 / 255) < 1e-6, "the appends leave the ramp, got " .. LateTip:GetAlpha())
"#,
)
.unwrap();
s.tick(0.5);
s.run(r#"assert(not LateTip:IsShown(), "the fade ends on time")"#)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// `SetOwner` (`0x5310d0`) clears the shown bit (`0x531179`) and calls the tooltip's own hide
/// (`0x531183` → `0x530a60`: the core with no owner, which clears and fires `OnTooltipCleared`,
/// then the base hide's `OnHide`), and only then runs the core with the new owner, which clears
/// and fires again.
#[test]
fn set_owner_hides_the_tooltip_first() {
let mut s = script();
s.run(
r#"
log = {}
OwnA = CreateFrame("Frame", "OwnA")
OwnB = CreateFrame("Frame", "OwnB")
ReTip = CreateFrame("GameTooltip", "ReTip")
ReTip:SetScript("OnTooltipCleared", function() table.insert(log, "cleared") end)
ReTip:SetScript("OnHide", function() table.insert(log, "hide") end)
ReTip:SetOwner(OwnA, "ANCHOR_RIGHT")
ReTip:SetText("first hover")
assert(ReTip:IsShown())
log = {}
ReTip:SetOwner(OwnB, "ANCHOR_LEFT")
assert(table.concat(log, ",") == "cleared,hide,cleared", "got " .. table.concat(log, ","))
assert(not ReTip:IsShown(), "hidden until the next content call")
assert(ReTip:IsOwned(OwnB), "owned by the new owner")
assert(ReTip:GetAnchorType() == "ANCHOR_LEFT")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// Every hide un-owns through the core with no owner, which stores mode 7 (`0x530012`), so
/// `GetAnchorType` (`0x5313e0`) answers `"ANCHOR_NONE"` until the next `SetOwner`.
#[test]
fn get_anchor_type_answers_anchor_none_after_a_hide() {
let mut s = script();
s.run(
r#"
NoneHost = CreateFrame("Frame", "NoneHost")
NoneTip = CreateFrame("GameTooltip", "NoneTip")
NoneTip:SetOwner(NoneHost, "ANCHOR_RIGHT")
NoneTip:SetText("Peacebloom")
assert(NoneTip:GetAnchorType() == "ANCHOR_RIGHT", "owned: the name round-trips")
NoneTip:Hide()
assert(NoneTip:GetAnchorType() == "ANCHOR_NONE", "got " .. NoneTip:GetAnchorType())
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The fade's end sets full alpha (`0x530bd8`) before the hide slot (`0x530be9`), so the hide's
/// `OnTooltipCleared` reads 255, not the last step of the ramp.
#[test]
fn the_fade_end_restores_full_alpha_before_its_hide() {
let mut s = script();
s.run(
r#"
seen = nil
EndHost = CreateFrame("Frame", "EndHost")
EndTip = CreateFrame("GameTooltip", "EndTip")
EndTip:SetOwner(EndHost, "ANCHOR_RIGHT")
EndTip:SetText("Silverleaf")
EndTip:SetScript("OnTooltipCleared", function() seen = EndTip:GetAlpha() end)
EndTip:FadeOut()
"#,
)
.unwrap();
s.tick(1.5);
s.run(r#"assert(seen == nil and EndTip:GetAlpha() < 0.5)"#)
.unwrap();
s.tick(0.5);
s.run(r#"assert(not EndTip:IsShown() and seen == 1, "cleared at alpha " .. tostring(seen))"#)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The tooltip is invalidated by its relayout (`0x530640`, ending in `0x7680e0(1)` at `0x530a52`),
/// which only Show and the line writers run (`0x530aad`, `0x52ad59`, ..): a cleared plate keeps its
/// rect through an unrelated resolve, and the next line's relayout resolves it without points.
#[test]
fn only_the_relayout_invalidates_the_tooltip() {
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)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Tough Jerky", 1, 1, 1)
tt:Show()
"#,
)
.unwrap();
s.resolve();
measure_all(&mut s, &[("Tough Jerky", 80.0, 14.0)]);
let left = |s: &mut UiScript| s.eval::<Option<f64>>("return TT:GetLeft()").unwrap();
assert_eq!(left(&mut s), Some(140.0));
s.run(
r#"TT:ClearAllPoints()
local other = CreateFrame("Frame"); other:SetWidth(5); other:SetHeight(5)
other:SetPoint("CENTER", 0, 0)"#,
)
.unwrap();
s.resolve();
s.resolve();
assert_eq!(left(&mut s), Some(140.0), "nothing ran the relayout");
s.run(r#"TT:AddLine("Soulbound")"#).unwrap();
s.resolve();
assert_eq!(
left(&mut s),
None,
"the relayout invalidated a plate with no points"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}