a chat link is one token now, and tooltips print true costs

- a chat link edits as one atomic token: the caret can't land inside it, its colors survive, one keypress removes it whole
- spell tooltips print the resolved mana cost through the reference's own table walk, and the "% of base mana" line dies
- the spirit healer's confirm comes back after a cancel and wears its warning icon
- the dressing room's pane is a live animating model, and a tried-on bow lands in the hand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:02:39 +07:00
co-authored by Claude Fable 5
parent d99ad8bc7d
commit cd58f90bec
46 changed files with 4840 additions and 1695 deletions
@@ -941,3 +941,106 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
s.mouse_button(rx, ry, "LeftButton", false);
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
/// **The keybind and the tab row must agree** (the director's 2026-08-06 report: *"go on skills tab
/// and press c, it goes back to char tab but without switching the actual tab ui below properly,
/// then if I click on char tab, it closes the whole window"*).
///
/// The cause was a dropped line: the reference selects the tab inside `ToggleCharacter` itself
/// (`PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())`, ref `CharacterFrame.lua:11`), on
/// EVERY path. Ours only did it in the tab button's own `OnClick`, so the two entry points into the
/// window diverged: a keybind switched the page and left the row selecting the old tab, and the
/// next click on the *now-showing* page's tab hit `ToggleCharacter`'s "this page is already
/// visible" branch and shut the window instead of switching back.
///
/// Driven through the same entry point the `C` / `SHIFT-P` bindings use — a bare
/// `ToggleCharacter(page)` call, never the button — because that is precisely the path the row used
/// to miss.
#[test]
fn a_keybind_page_switch_moves_the_tab_row_with_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "CharacterFrame.xml");
load_xml(&s, "PetPaperDollFrame.xml");
load_xml(&s, "SkillFrame.xml");
s.set_unit("player", Some(player_unit()));
// THE INVARIANT the fix rests on: a page's `id=` is its slot in this window's own tab row, and
// `BENILLA_CHARACTERFRAME_SUBFRAMES` is the same 1:1 mapping written the other way round. The
// reference's numbers are NOT ours — its Skills page is id 4 behind a Reputation tab we have
// not built — so this is the check that makes the divergence loud the day Reputation lands
// between them, instead of silently selecting the wrong tab.
for i in 1..=3 {
let id: i64 = s
.eval(&format!(
"return getglobal(BENILLA_CHARACTERFRAME_SUBFRAMES[{i}]):GetID()"
))
.unwrap();
assert_eq!(
id, i,
"page {i} of BENILLA_CHARACTERFRAME_SUBFRAMES must carry id={i}"
);
}
let selected = |s: &mut UiScript| {
s.eval::<i64>("return PanelTemplates_GetSelectedTab(BenillaCharacterFrame)")
.unwrap()
};
// A tab wears its "Active" (=Disabled) art exactly when it is the selected one — the visible
// half of the same fact, so a SetTab that updated the number without repainting fails here too.
let wearing_active_art = |s: &mut UiScript, tab: u32| {
s.eval::<bool>(&format!(
"return BenillaCharacterFrameTab{tab}MiddleDisabled:IsVisible()"
))
.unwrap()
};
let shown = |s: &mut UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}:IsVisible()"))
.unwrap()
};
// Open on Character (the `C` binding), then move to Skills the same way.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert_eq!(selected(&mut s), 1);
s.run(r#"ToggleCharacter("BenillaSkillFrame")"#).unwrap();
assert!(shown(&mut s, "BenillaSkillFrame"));
assert_eq!(selected(&mut s), 3, "the row follows a keybind to Skills");
assert!(wearing_active_art(&mut s, 3));
assert!(!wearing_active_art(&mut s, 1));
// THE REPORT: `C` from the Skills page. The page goes back to Character — and so must the row.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(shown(&mut s, "BenillaPaperDollFrame"));
assert!(!shown(&mut s, "BenillaSkillFrame"));
assert_eq!(
selected(&mut s),
1,
"the tab row followed the keybind back to Character"
);
assert!(wearing_active_art(&mut s, 1));
assert!(!wearing_active_art(&mut s, 3));
// …and the second half of the report — *"then if I click on char tab, it closes the whole
// window"* — falls out of the same fix rather than needing its own. `PanelTemplates_SelectTab`
// DISABLES the tab it selects (a selected tab is not re-clickable), so with the row in step the
// Character tab is inert while the Character page is up. The window only closed because the row
// still said Skills: tab 1 was left enabled, the click reached `ToggleCharacter`, and the page
// it named was already visible — the HideUIPanel arm.
s.run("BenillaCharacterFrameTab1:Click()").unwrap();
assert!(
shown(&mut s, "BenillaCharacterFrame"),
"the selected tab is disabled — clicking it cannot close the window"
);
assert!(
shown(&mut s, "BenillaPaperDollFrame"),
"…or change the page"
);
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
@@ -243,6 +243,62 @@ fn xp_loss_two_step_confirm_then_range_hide() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A cancelled confirm comes back on the next ask (decision 1068, B80): every CONFIRM_XP_LOSS
/// fire re-shows the dialog — the feed fires per SMSG, and this guards the Lua half of that
/// contract. And the XP_LOSS dialogs carry the ref's alert dress (StaticPopup.lua l.1579-1589):
/// width 420 with the AlertIcon shown, reset to 320/hidden for a non-alert entry on the same
/// instance.
#[test]
fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
420.0,
"showAlert widens the dialog to the ref's 420"
);
assert!(
s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"showAlert shows the DialogAlertIcon"
);
// Cancel: nothing queues, nothing on the wire — the dialog just hides.
s.run("this = StaticPopup1Button2 StaticPopup_OnClick(StaticPopup1, 2) this = nil")
.unwrap();
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.take_death_actions().is_empty());
// Asking the healer again = a fresh SMSG = the feed fires again: the confirm is back.
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a fresh confirm re-shows after a Cancel (B80's deadlock)"
);
s.run("StaticPopup_Hide(\"XP_LOSS\")").unwrap();
// A non-alert entry on the same instance resets the dress — Show's unconditional reset.
s.run(concat!(
"StaticPopupDialogs[\"TEST_PLAIN\"] = { text = \"x\", button1 = \"OK\", timeout = 0, ",
"whileDead = 1 } StaticPopup_Show(\"TEST_PLAIN\")"
))
.unwrap();
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
320.0,
"a non-alert Show resets the width"
);
assert!(
!s.eval::<bool>("return StaticPopup1AlertIcon:IsShown()")
.unwrap(),
"a non-alert Show hides the icon"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// UnitIsGhost/UnitIsDeadOrGhost — the trio's ghost legs (a ghost has health 1, so UnitIsDead is
/// false for it; decision 0308 §1).
#[test]
@@ -58,8 +58,10 @@ pub(super) fn drive_script(
world_assets: Option<ResMut<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
mut font_atlas: Option<ResMut<UiFontAtlas>>,
// The token -> off-screen-baked-face bridge a `SetPortraitTexture`-bound region samples.
portraits: Res<crate::portrait::PortraitImages>,
// Both directions of the booth seam: the token -> off-screen-baked-face bridge a
// `SetPortraitTexture`-bound region samples, and the pane geometry this pass publishes back
// for the booths' projection aspect + render gate (decision 1069).
mut booths: crate::portrait::BoothBridge,
// Gates the held-cursor icon quad (decision 0216 §5): CAPTURE-ONLY, the same presence check
// every other capture-only system uses (`ui_script::capture_ui_active`'s sibling pattern).
capture: Option<Res<crate::capture::CaptureMode>>,
@@ -324,7 +326,7 @@ pub(super) fn drive_script(
let settled = capture.is_none()
&& prev.dims == Some(dims)
&& text_ui == prev.text_ui
&& portraits.0 == prev.portraits
&& booths.images.0 == prev.portraits
&& extracted == prev.extracted;
if settled {
drop(extract_span);
@@ -358,8 +360,13 @@ pub(super) fn drive_script(
}
prev.dims = Some(dims);
prev.text_ui = text_ui.clone();
prev.portraits = portraits.0.clone();
prev.portraits = booths.images.0.clone();
prev.extracted = extracted.clone();
// This frame's booth panes, refilled by the loop below (decision 1069). Cleared only HERE, on
// the un-skipped path: a settled frame draws exactly what the last one did, so the map it left
// is still this frame's truth — clearing it above the gate would make every quiet frame put the
// body panes' cameras to sleep and freeze their animation.
booths.panes.0.clear();
for eq in extracted {
let Some(r) = eq.rect else { continue };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
@@ -419,7 +426,20 @@ pub(super) fn drive_script(
// booth yet) draws nothing rather than the run-splitter's white default.
if let Some(token) = &portrait_unit {
use crate::portrait::PortraitSource;
let handle = match portraits.0.get(token) {
// A **square** binding is a booth pane (`BenillaSetBoothTexture`, decision
// 0208 §5), not a round unit portrait: publish the rect's aspect so the booth
// can bake at the shape it will be stretched into, and know it is on screen at
// all (decision 1069). Recorded before the readiness `continue` below — a pane
// whose bake hasn't landed yet is still a pane being drawn. The region's rect
// is the whole answer because no pane crops its bake; a pane that grew
// `<TexCoords>` would have to fold that UV window in here too.
if !circular && rect.height() > 0.0 {
booths
.panes
.0
.insert(token.clone(), rect.width() / rect.height());
}
let handle = match booths.images.0.get(token) {
Some(PortraitSource::Live(h)) => Some(h.clone()),
Some(PortraitSource::File(p)) => assets
.as_mut()
@@ -820,6 +840,7 @@ mod clip_plumb_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -903,6 +924,7 @@ mod clip_plumb_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -959,6 +981,7 @@ mod extract_gate_tests {
app.init_resource::<UiQuads>();
app.init_resource::<Assets<Image>>();
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::hover_log::UiFrameCost>();
app.init_resource::<Time>();
@@ -183,14 +183,23 @@ pub(super) fn emit(
// actually draws, which is what a truncation report needs to show.
let probe = std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1");
if probe {
// For the focused edit box, also the two numbers that must agree: where the engine puts the
// caret (`caret=`, advance-table-derived) and how wide the text this pass actually draws is
// (`ink=`). They diverge by the width of the markup when the advance table is measured over
// the raw buffer — the caret-out-in-space report (decision 1075) as a pair of numbers.
let ebox_geom = ebox.map(|ui| {
let ink = crate::ui_text::measure_text(atlas, draw_text, None, spec).0;
format!(" caret={:.1} ink={ink:.1}", ui.caret_x * host.scale)
});
info!(
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?} {:?}",
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?}{} {:?}",
draw_rect.min.x,
draw_rect.min.y,
host.rect.width(),
host.rect.height(),
style.font_height,
&draw_text[..draw_text.len().min(30)]
ebox_geom.unwrap_or_default(),
&draw_text[..draw_text.len().min(60)]
);
}
// The drop shadow (font object `<Shadow>` — MasterFont's (1,-1) black covers the whole
@@ -413,6 +413,51 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
"the diet icon is shown for a hunter pet"
);
// …and it is actually PAINTED, not merely a mouse-enabled hole. The director's 2026-08-06
// report was exactly that split — the tooltip answered while nothing was on screen — so the
// hover assertion below is not sufficient on its own: pin the art too, with its size and its
// reference TexCoords (the icon is the pet's DIET affordance, so its coords are static).
s.resolve();
let quads = s.extract();
let icon = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-PetHappiness"))
})
.expect("the diet icon's UI-PetHappiness quad is in the render list");
let rect = icon.rect.expect("…with a resolved rect");
assert!(
(rect.right - rect.left - 24.0).abs() < 0.5 && (rect.top - rect.bottom - 23.0).abs() < 0.5,
"24x23 (ref PetPaperDollPetInfo), got {}x{}",
rect.right - rect.left,
rect.top - rect.bottom
);
// …and it draws ON TOP of the model pane it sits inside — the actual regression. The pane's
// booth quad is OPAQUE and the icon's rect is wholly within it, so being in the render list is
// not the same as being seen. The pane rides BACKGROUND for exactly this reason (decision
// 1070): at ARTWORK the draw layer, which is bucket-wide and outranks the frame (0884), buried
// the icon no matter which frame was declared later.
let pane = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path, .. }
if path.as_deref().unwrap_or_default().is_empty())
&& q.rect.is_some_and(|r| {
r.left <= rect.left
&& r.right >= rect.right
&& r.bottom <= rect.bottom
&& r.top >= rect.top
})
})
.expect("the pet model pane's booth quad covers the icon's rect");
assert!(
icon.z > pane.z,
"the diet icon must paint over the pane it sits in (icon z={:#x}, pane z={:#x})",
icon.z,
pane.z
);
s.run(
"local f = BenillaPetPaperDollPetInfo \
f:GetScript(\"OnEnter\")(f)",