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>
1218 lines
43 KiB
Rust
1218 lines
43 KiB
Rust
//! 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());
|
||
}
|