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:
@@ -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)",
|
||||
|
||||
Reference in New Issue
Block a user