Files
benilla-octo/crates/benilla-ui/src/script/tooltip/verbs.rs
T
gromandClaude Opus 5 ed92574cea tooltip: SetOwner hides the plate, so a rebuild gives the addon its OnShow back
Reported from play with a video: hovering a bag item draws the enhanced tooltip
— MasterTradeSkills' recipe block, AdvancedTradeSkillWindow2's Bags/Bank/Total
— for about nine frames of sixty, then the plate falls back to the bare one and
stays bare while the cursor sits still.

The loop is stock. `ContainerFrameItemButton_OnUpdate` re-enters `..._OnEnter`
every `TOOLTIP_UPDATE_TIME` (`ContainerFrame.lua:693` in his own patch-4.mpq;
pfUI's bag slots are `ContainerFrameItemButtonTemplate`, so they run it too),
and each turn is `SetOwner` + `SetBagItem` — a clear that wipes every line an
addon appended. What puts them back in the reference is a hide/show pair, and
we had neither half: the `SetOwner` binding hides the plate before it even reads
the owner argument (`0x531179` zeroes the content gate at `+0xd0`, `0x531183`
calls `[vtable+0x84]` = `0x530a60`, which is the `SetOwner(NULL)` core plus the
base hide `0x76ad50`), and the content `Set*` re-shows it through the tooltip's
own Show override (`0x530a80`). So every rebuild raises `OnHide` then `OnShow`.
Ours cleared in place, and the base show early-outs on an already-visible frame
(`0x76ae3b`), so the plate never hid and the `OnShow` never came.

That is why exactly two addons lost their lines while others kept theirs:
`LibItemTooltip-1.0`, the tooltip contract of MasterTradeSkills and
Gromissingcrafts, hangs entirely on a scratch child frame of `GameTooltip`
listening for `OnShow`, where pfUI's ItemID and sell-price lines hook the `Set*`
methods themselves and so re-run on every refresh.

The reference also clears twice per `SetOwner` (once inside the hide, once in
the core) and so fires `OnTooltipCleared` twice; that falls out of calling the
same hide, and the existing lifecycle test already allowed for it.

Addresses read off the player's own `WoW.exe` with objdump, not from memory.
The new test is the hover loop in miniature — three turns of `SetOwner` + a
content `Set*` under a `LibItemTooltip`-shaped child probe — and fails on the
old behaviour with one `OnShow` instead of three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-17 15:38:36 -06:00

599 lines
29 KiB
Rust

//! The GameTooltip Lua methods over the parent module's line pool, plus the content builders'.
use mlua::{Lua, Table, Value, Variadic};
use crate::layout::{Anchor, Point};
use crate::script::binding_abi::optional_string;
use crate::script::object::{frame_handle_of, frame_wrapper};
use crate::script::region::region_handle_of;
use crate::script::Model;
use crate::widget::{KindState, TooltipAnchor, TOOLTIP_LINE_GAP, TOOLTIP_PAD};
/// The Lua line that called the binding we are inside, as `debugstack` renders it, for a
/// diagnostic that has to name its own caller.
///
/// `debugstack(2)` skips `debugstack` itself and this binding's frame, so its first line is the
/// call site. Only that line is kept — a whole traceback per warning would bury the log, and the
/// call site is the fact being hunted. An empty string reads as "unknown" rather than pretending.
///
/// Paid only on a diagnostic path. `debugstack` names frames from `lua_getinfo` alone (the
/// reference's own walk, `0x703760`) and searches no table — unlike the traceback mlua used to
/// build, which walked every global and is why `third_party/mlua-sys` is vendored.
fn caller_site(lua: &Lua) -> String {
let Ok(f) = lua.globals().get::<mlua::Function>("debugstack") else {
return String::new();
};
let Ok(stack) = f.call::<Option<String>>(2) else {
return String::new();
};
stack
.unwrap_or_default()
.lines()
.next()
.unwrap_or("")
.trim()
.to_string()
}
use super::{
append_line, bool_arg, clear_content, fire_cleared, full_alpha, hide_tooltip, now,
parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell,
REG_TOOLTIP_METHODS,
};
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// SetOwner(owner, anchorType [, x, y]): the binding `0x5310d0` calls the core `0x52ffe0`,
// which sets full alpha (`0x52fff4`), disarms the fade, stores the owner (`0x53000c`), mode
// and offsets, then applies the anchor (`0x52fe90`). The content clears and OnTooltipCleared
// fires, so a new owner never inherits the last hover's lines or money.
//
// A missing, non-string or unknown anchorType is mode 0, ANCHOR_LEFT, with no error: the
// binding's local starts at 0 (`0x53120d`). The core's pre-store of 7 (`0x530012`) survives
// only a NULL owner, the un-own path (`0x530a60`) of every hide.
//
// The anchor apply returns first for ANCHOR_PRESERVE (8, `0x52fead`), then clears all points
// (`0x767ed0` at `0x52fec2`) for modes 0..7; modes 0..5 then set a point (`0x52ffbc`).
m.set(
"SetOwner",
lua.create_function(
|lua, (this, owner, anchor, x, y): (Table, Table, Value, Option<f32>, Option<f32>)| {
let h = frame_handle_of(lua, &this)?;
// **The plate hides first.** The binding zeroes the content gate (`+0xd0`,
// `0x531179`) and calls the tooltip's own virtual `Hide` (`0x531183`,
// `[vtable+0x84]` = `0x530a60`) on *itself* before it even reads the owner
// argument; that `Hide` is the `SetOwner(NULL)` core (`0x52ffe0`: clear, so
// `OnTooltipCleared`) followed by the base hide (`0x76ad50`, so `OnHide`). The
// content `Set*` that follows then re-shows it through `0x530a80`, and the base
// show raises `OnShow` because the frame really was hidden (`0x76ae3b` early-outs
// on an already-visible one).
//
// That hide/show pair is the **only** signal an addon gets that the plate was
// wiped and wants its lines back, and the hover loop wipes it five times a second:
// `ContainerFrameItemButton_OnUpdate` re-enters `..._OnEnter` every
// `TOOLTIP_UPDATE_TIME` (`ContainerFrame.lua:693`, and pfUI's bag slots inherit
// `ContainerFrameItemButtonTemplate`), which is `SetOwner` + `SetBagItem`. Without
// the hide the rebuild is silent, so an addon's lines live 0.2 s and never come
// back: `LibItemTooltip-1.0` (MasterTradeSkills, Gromissingcrafts) hangs its whole
// contract on a scratch child frame's `OnShow`, and pfQuest and pfUI read the
// tooltip from `OnShow` too.
super::hide_tooltip(lua, h);
let owner_h = frame_handle_of(lua, &owner)?;
// The gate is `lua_isstring` (`0x6f3510`): a string or number is read, and any
// other type counts as absent rather than raising.
let anchor = optional_string(lua, &anchor);
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
full_alpha(&mut model, h);
tip_mut(&mut model, h)?.owner = Some(owner_h);
let owner_id = model.frame_id(owner_h);
// Resolved once: the placement and `GetAnchorType` both read this value. The
// warning on an unknown name is ours; the reference is silent, and Lua sees no
// difference.
let anchor = match anchor
.as_deref()
.map(str::to_ascii_uppercase)
.as_deref()
.unwrap_or("")
{
"ANCHOR_LEFT" => TooltipAnchor::Left,
"ANCHOR_RIGHT" => TooltipAnchor::Right,
"ANCHOR_BOTTOMLEFT" => TooltipAnchor::BottomLeft,
"ANCHOR_BOTTOMRIGHT" => TooltipAnchor::BottomRight,
"ANCHOR_TOPLEFT" => TooltipAnchor::TopLeft,
"ANCHOR_TOPRIGHT" => TooltipAnchor::TopRight,
"ANCHOR_CURSOR" => TooltipAnchor::Cursor,
"ANCHOR_NONE" => TooltipAnchor::None,
"ANCHOR_PRESERVE" => TooltipAnchor::Preserve,
"" => TooltipAnchor::Left,
other => {
// The owner's name is in the message because the anchor string alone
// does not identify the caller. A real session produced 566 of these
// with an anchor no installed addon passes anywhere on disk — every
// `SetOwner` call site in every addon `.lua` and `.xml` passes a valid
// `ANCHOR_*` — and it does not reproduce under the addon harness, so
// static search and the harness both came up empty. The owner frame
// names it: a `pfUI*` owner is the addon, a stock FrameXML name is the
// reference UI off the player's patch chain.
let owner_name = model
.arena
.frame(owner_h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<anonymous>".to_string());
// Name the **calling line**, not just the owner. The owner alone was
// not enough: it said `WorldFrame` for all 283 of a session's warnings
// while a paren-matching scan of every `SetOwner` call in every
// installed addon's `.lua` and `.xml` found only valid `ANCHOR_*`
// strings, and the addon harness has never reproduced it — so the
// caller is reached by a path static search cannot see (a stored
// method reference, a config value, or a wrapper). `debugstack` is the
// reference's own walk (`0x703760`) and is what `event.rs`'s handler
// traceback already uses; one frame of it is enough to end the hunt,
// and it is paid only on the warning path, which is already the
// unusual one.
let site = caller_site(lua);
// The hunt is over, so the terminal gets this **once** per distinct
// `(anchor, owner, site)` — the three the message already carries, so
// the message is the dedup key. The caller is `Shop_ProcessEntries`
// in TurtleWoW's own `Interface\FrameXML\Turtle_ShopUI`, off the
// player's patch chain: not an addon, not ours, nothing the player
// can fix, and our mode-0 fallback already matches the reference. 283
// identical lines a session bury every other warning in the log; the
// first is still the whole diagnostic, and the retained row's `count`
// keeps the true total for `BenillaScriptLogFrame`.
model.record_warning_once(format!(
"SetOwner: unknown anchor '{other}' on owner '{owner_name}' \
from {site} (mode 0, ANCHOR_LEFT)"
));
TooltipAnchor::Left
}
};
tip_mut(&mut model, h)?.anchor = anchor;
let pts = match anchor {
TooltipAnchor::Right => Some((Point::BottomLeft, Point::TopRight)),
TooltipAnchor::Left => Some((Point::BottomRight, Point::TopLeft)),
TooltipAnchor::TopRight => Some((Point::BottomRight, Point::TopRight)),
TooltipAnchor::TopLeft => Some((Point::BottomLeft, Point::TopLeft)),
TooltipAnchor::BottomRight => Some((Point::TopLeft, Point::BottomRight)),
TooltipAnchor::BottomLeft => Some((Point::TopRight, Point::BottomLeft)),
TooltipAnchor::Cursor | TooltipAnchor::None | TooltipAnchor::Preserve => {
None
}
};
// Modes 6 and 7 clear all points and stop. The clear lets
// `GameTooltip_SetDefaultAnchor` (GameTooltip.lua:76-77) set one point with no
// stale owner anchor; mode 6 is then placed per frame by `cursor_anchor`. The
// drop is a named retarget to no targets, not a conservative touch.
if matches!(anchor, TooltipAnchor::Cursor | TooltipAnchor::None) {
let dropped = match model.layout_inputs.get_mut(&h) {
Some(input) if !input.anchors.is_empty() => {
Some(input.anchors.drain(..).map(|a| a.relative_to).collect())
}
_ => None,
};
if let Some(old_targets) = dropped {
let old_targets: Vec<u32> = old_targets;
model.touch_layout_retarget_frame(h, &old_targets, &[]);
}
}
if let Some((own, rel)) = pts {
let new =
Anchor::new(own, owner_id, rel, x.unwrap_or(0.0), y.unwrap_or(0.0));
let input = model.layout_inputs.entry(h).or_default();
// No-op when unchanged, so the bag hover's per-frame SetOwner does not
// dirty the layout.
let same = input.anchors.len() == 1
&& crate::script::object::anchor_bits_eq(&input.anchors[0], &new);
if !same {
// A named retarget: the cached layout graph re-points this edge
// instead of being rebuilt.
let old_targets: Vec<u32> =
input.anchors.iter().map(|a| a.relative_to).collect();
input.anchors = vec![new];
model.touch_layout_retarget_frame(h, &old_targets, &[owner_id]);
}
}
}
fire_cleared(lua, h);
Ok(())
},
)?,
)?;
// GameTooltip:BenillaGetTooltipOwner(): not a 1.12 method (1.12 has only `IsOwned`); the dev
// hover recorder (`benilla-app/src/hover_log.rs`) reads the owner's name through it.
m.set(
"BenillaGetTooltipOwner",
lua.create_function(|lua, this: Table| {
let owner = with_tip(lua, &this, |t| t.owner)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
owner
.filter(|&h| model.arena.frame(h).is_some())
.map(|h| model.frame_id(h))
};
match id {
Some(id) => Ok(Value::Table(frame_wrapper(lua, id)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetAnchorType(): the last SetOwner's mode by name (`0x5313e0`, name table `0x531530`), never
// nil; a never-owned tooltip answers "ANCHOR_NONE". The reference also answers it after any
// hide, whose un-own leaves mode 7; this keeps the last mode. Addons compare it with what they
// passed to SetOwner, so the name must round-trip.
m.set(
"GetAnchorType",
lua.create_function(|lua, this: Table| {
let anchor = with_tip(lua, &this, |t| t.anchor)?;
Ok(anchor.name())
})?,
)?;
// IsOwned(frame): the hover loop's OnUpdate gate (`ContainerFrame.lua:657`); the owner drops
// on hide, so a stale OnUpdate never brings a hidden tooltip back.
m.set(
"IsOwned",
lua.create_function(|lua, (this, frame): (Table, Table)| {
let owner = with_tip(lua, &this, |t| t.owner)?;
let fh = frame_handle_of(lua, &frame)?;
Ok(owner == Some(fh))
})?,
)?;
// SetText(text, r, g, b, alpha, wrap) (`0x531b90`): alpha has its own number gate, default 1,
// and a text that is neither string nor number raises the binding's usage error. It clears,
// writes line 1 and shows: the empty-slot hover calls no Show after it
// (PaperDollFrame.lua:747).
m.set(
"SetText",
lua.create_function(|lua, (this, args): (Table, Variadic<Value>)| {
let h = frame_handle_of(lua, &this)?;
if !matches!(
args.first(),
Some(Value::String(_)) | Some(Value::Number(_)) | Some(Value::Integer(_))
) {
return Err(mlua::Error::RuntimeError(
"Usage: SetText(\"text\" [, color])".into(),
));
}
let text = text_of(args.first());
let [r, g, b, _] = parse_line_color(args.get(1), args.get(2), args.get(3));
let a = match args.get(4) {
Some(Value::Number(n)) => *n as f32,
Some(Value::Integer(i)) => *i as f32,
_ => 1.0,
};
let wrap = bool_arg(args.get(5));
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
append_line(lua, &this, (text, [r, g, b, a]), None, wrap)?;
set_shown(lua, h, true);
Ok(())
})?,
)?;
// AppendText(text) (`0x531e30`): appends to line 1's left cell in place, keeping its colour,
// and does nothing with no lines, as the reference's body does (`0x5305b0`, line 1 at
// `0x5305cc`). The bag tooltips hang a keybinding on their SetText title with it
// (ContainerFrame.xml:198, MainMenuBarBagButtons.lua:91).
m.set(
"AppendText",
lua.create_function(|lua, (this, text): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let extra = text_of(Some(&text));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let lh = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Tooltip(t)) if t.num_lines > 0 => t.left_lines[0],
Some(KindState::Tooltip(_)) => return Ok(()),
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
};
let (current, color) = match model.region_data.get(&lh) {
Some(d) => (
d.text.clone().unwrap_or_default(),
d.vertex_color.unwrap_or([1.0, 1.0, 1.0, 1.0]),
),
None => (String::new(), [1.0, 1.0, 1.0, 1.0]),
};
write_cell(&mut model, lh, &(current + &extra), color);
Ok(())
})?,
)?;
// AddLine(text [, r, g, b [, wrap]]): positional, and appends without showing; the
// `(text, "", r, g, b)` shape has a non-number r-slot, so it renders default gold.
m.set(
"AddLine",
lua.create_function(|lua, (this, args): (Table, Variadic<Value>)| {
let text = text_of(args.first());
let (color, wrap) = parse_line_tail(&args[1.min(args.len())..]);
append_line(lua, &this, (text, color), None, wrap)
})?,
)?;
// AddDoubleLine(textL, textR, rL, gL, bL, rR, gR, bR [, wrap]) (`0x531840`): each side's
// colour gates on its own r-slot. The core forces wrap off when the right text is non-empty;
// this never wraps.
m.set(
"AddDoubleLine",
lua.create_function(
|lua, (this, tl, tr, cl): (Table, Value, Value, Variadic<Value>)| {
let left_color = parse_line_color(cl.first(), cl.get(1), cl.get(2));
let right_color = parse_line_color(cl.get(3), cl.get(4), cl.get(5));
append_line(
lua,
&this,
(text_of(Some(&tl)), left_color),
Some((text_of(Some(&tr)), right_color)),
false,
)
},
)?,
)?;
m.set(
"ClearLines",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_content(&mut model, h);
}
fire_cleared(lua, h);
Ok(())
})?,
)?;
m.set(
"NumLines",
lua.create_function(|lua, this: Table| with_tip(lua, &this, |t| t.num_lines as i64))?,
)?;
// AddFontStrings(left, right) (`0x530c40`): adopts two caller-made FontStrings as the next
// line pair, placed like an engine-grown pair but keeping the caller's fonts.
m.set(
"AddFontStrings",
lua.create_function(|lua, (this, left, right): (Table, Table, Table)| {
let h = frame_handle_of(lua, &this)?;
let lh = region_handle_of(lua, &left)?;
let rh = region_handle_of(lua, &right)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame_id = model.frame_id(h);
let prev_left = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Tooltip(t)) => t.left_lines.last().copied(),
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
};
let anchor = match prev_left {
None => Anchor::new(
Point::TopLeft,
frame_id,
Point::TopLeft,
TOOLTIP_PAD,
-TOOLTIP_PAD,
),
Some(prev) => {
let prev_id = model.region_id(prev);
Anchor::new(
Point::TopLeft,
prev_id,
Point::BottomLeft,
0.0,
-TOOLTIP_LINE_GAP,
)
}
};
let left_id = model.region_id(lh);
{
let d = model.region_data.entry(lh).or_default();
d.hidden = true;
d.justify.set_h(crate::script::JustifyH::Left);
d.anchors = vec![anchor];
}
{
let d = model.region_data.entry(rh).or_default();
d.hidden = true;
d.justify.set_h(crate::script::JustifyH::Right);
d.anchors = vec![Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)];
}
model.touch_layout(); // two line rows entered the layout graph
let t = tip_mut(&mut model, h)?;
t.left_lines.push(lh);
t.right_lines.push(rh);
Ok(())
})?,
)?;
// SetMinimumWidth(w): a floor on the auto-sized width (`SetTooltipMoney` passes the money
// row's, GameTooltip.lua:91).
m.set(
"SetMinimumWidth",
lua.create_function(|lua, (this, w): (Table, f32)| {
with_tip(lua, &this, |t| t.min_width = w.max(0.0))
})?,
)?;
// SetPadding(w): extra auto-size width that survives a content clear (ItemRefTooltip's 16
// keeps its close button off the text, ItemRef.xml:40).
m.set(
"SetPadding",
lua.create_function(|lua, (this, w): (Table, f32)| {
with_tip(lua, &this, |t| t.padding = w.max(0.0))
})?,
)?;
// FadeOut(): alpha ramps to 0 over `TOOLTIP_FADE_SECS`, then the full hide; new content or
// Show cancels it.
m.set(
"FadeOut",
lua.create_function(|lua, this: Table| {
let t = now(lua);
with_tip(lua, &this, |tip| {
if tip.fade_start.is_none() {
tip.fade_start = Some(t);
}
})
})?,
)?;
// Show and Hide shadow the shared pair. Show (`0x530a80`, the override the generic Show at
// `0x7a3350` calls) shows only a plate with an owner and at least one line, checked at Show
// time only; otherwise it takes the full hide (`0x530a60`): un-owned, cleared,
// OnTooltipCleared fired. Hide is the full hide.
m.set(
"Show",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let live = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
full_alpha(&mut model, h);
tip_mut(&mut model, h)
.map(|t| t.owner.is_some() && t.num_lines > 0)
.unwrap_or(false)
};
match live {
true => set_shown(lua, h, true),
false => hide_tooltip(lua, h),
}
Ok(())
})?,
)?;
m.set(
"Hide",
lua.create_function(|lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
hide_tooltip(lua, h);
Ok(())
})?,
)?;
// The content methods: items, spells and auras, talents, units.
crate::script::tooltip_item::install_methods(lua, &m)?;
crate::script::tooltip_spell::install_methods(lua, &m)?;
crate::script::talent::install_tooltip_method(lua, &m)?;
crate::script::tooltip_unit::install_methods(lua, &m)?;
lua.set_named_registry_value(REG_TOOLTIP_METHODS, m)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The warning has to name the **line that called it**, not just the owner frame.
///
/// This diagnostic exists because a real session emits 283 of
/// `SetOwner: unknown anchor 'BOTTOMRIGHT' on owner 'WorldFrame'` and three static hunts failed
/// to find the caller: a paren-matching scan of every `SetOwner` in every installed addon's
/// `.lua` and `.xml` found only valid `ANCHOR_*` strings, and the addon harness has never
/// reproduced it. Whatever reaches it does so by a path source search cannot see, so the
/// warning must identify itself.
#[test]
fn an_unknown_anchor_warning_names_the_lua_line_that_passed_it() {
let mut s = UiScript::new().unwrap();
s.run(
r#"T = CreateFrame("GameTooltip", "SiteProbeTip", UIParent)
Host = CreateFrame("Frame", "SiteProbeHost", UIParent)
function PassABarePoint() T:SetOwner(Host, "BOTTOMRIGHT") end
PassABarePoint()"#,
)
.unwrap();
let warnings = s.take_warnings().join("\n");
assert!(
warnings.contains("unknown anchor 'BOTTOMRIGHT'"),
"the anchor itself must still be reported: {warnings}"
);
assert!(
warnings.contains("on owner 'SiteProbeHost'"),
"the owner must still be reported: {warnings}"
);
// The point of the change: a caller the warning can be traced back to.
assert!(
warnings.contains("from ") && !warnings.contains("from "),
"the warning must carry a non-empty call site: {warnings}"
);
assert!(
warnings.contains("PassABarePoint") || warnings.contains("chunk"),
"the site must name the calling frame, not the binding: {warnings}"
);
}
/// The hunt is over, so the repeats are noise: one terminal line per distinct `(anchor, owner,
/// site)`, while the retained log still counts every occurrence. The caller the diagnostic
/// found is `Shop_ProcessEntries` in TurtleWoW's own `Interface\FrameXML\Turtle_ShopUI` — off
/// the player's patch chain, so nothing in the session can stop it calling, and 283 copies of
/// one unfixable warning bury every other line in the log.
#[test]
fn a_repeated_unknown_anchor_reaches_the_host_once_and_the_retained_row_counts_them_all() {
let mut s = UiScript::new().unwrap();
s.run(
r#"T = CreateFrame("GameTooltip", "NoisyTip", UIParent)
Host = CreateFrame("Frame", "NoisyHost", UIParent)
function ShopProcessEntries() T:SetOwner(Host, "BOTTOMRIGHT") end
for i = 1, 283 do ShopProcessEntries() end"#,
)
.unwrap();
let host: Vec<String> = s
.take_warnings()
.into_iter()
.filter(|w| w.contains("unknown anchor 'BOTTOMRIGHT'"))
.collect();
assert_eq!(
host.len(),
1,
"283 identical warnings are ONE terminal line — the whole point: {host:#?}"
);
// The first line is still the full diagnostic: it is how the caller was found.
assert!(
host[0].contains("on owner 'NoisyHost'") && host[0].contains("ShopProcessEntries"),
"the one line kept must still name the owner and the call site: {host:#?}"
);
let row = s
.diagnostics()
.into_iter()
.find(|d| d.message.contains("unknown anchor 'BOTTOMRIGHT'"))
.expect("the warning is retained for the player's log");
assert_eq!(
row.count, 283,
"…and nothing is lost: the retained row carries the true total"
);
}
/// Two call sites are two diagnostics, so the dedup folds repeats and never a second caller:
/// the message carries `(anchor, owner, site)` and is itself the key.
#[test]
fn two_different_unknown_anchor_call_sites_each_get_their_own_first_line() {
let mut s = UiScript::new().unwrap();
s.run(
r#"T = CreateFrame("GameTooltip", "TwoSiteTip", UIParent)
A = CreateFrame("Frame", "OwnerAlpha", UIParent)
B = CreateFrame("Frame", "OwnerBeta", UIParent)
function SiteAlpha() T:SetOwner(A, "BOTTOMRIGHT") end
function SiteBeta() T:SetOwner(B, "TOPLEFT") end
SiteAlpha() SiteAlpha() SiteBeta() SiteBeta()"#,
)
.unwrap();
let host: Vec<String> = s
.take_warnings()
.into_iter()
.filter(|w| w.contains("unknown anchor"))
.collect();
assert_eq!(host.len(), 2, "one line each, not one in total: {host:#?}");
assert!(
host.iter().any(|w| w.contains("'OwnerAlpha'"))
&& host.iter().any(|w| w.contains("'OwnerBeta'")),
"both callers must be named: {host:#?}"
);
}
/// A valid anchor stays silent — the diagnostic must not start charging `debugstack` on the
/// path every tooltip in the UI takes.
#[test]
fn a_known_anchor_warns_nothing_and_so_walks_no_stack() {
let mut s = UiScript::new().unwrap();
s.run(
r#"T = CreateFrame("GameTooltip", "QuietTip", UIParent)
Host = CreateFrame("Frame", "QuietHost", UIParent)
T:SetOwner(Host, "ANCHOR_BOTTOMRIGHT")"#,
)
.unwrap();
assert!(
!s.take_warnings().join("\n").contains("unknown anchor"),
"ANCHOR_BOTTOMRIGHT is a real 1.12 anchor"
);
}
}