Merge upstream/main (samwhosung/benilla 66388278, 78 commits) into everwood-cs

Keeps both sides: upstream's settings rework, model_render rework, mesh-tag change-gating,
static_gx node move + diagnostics span, WorldPointLight, brightness/weather options,
weapon trails; and the MONKEY lighting stack (sun shadow lanes, dynamic interiors, cube-map
torch shadows, daylight fixtures, spell lights, bake floor, K=12 exterior selection, debug panel).
All MONKEY light lanes migrated from Bevy PointLight to upstream's WorldPointLight.
1091 world+formats tests green; all 8 WGSL probes OK; server-less smoke (inn, Northshire) clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-17 10:46:19 +01:00
co-authored by Claude Fable 5.1
1045 changed files with 157434 additions and 61619 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+176 -209
View File
@@ -133,6 +133,8 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -214,6 +216,8 @@ fn shift_b_toggles_every_bag_at_once() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.set_container(
@@ -295,6 +299,8 @@ fn bag_bar_buttons_light_while_their_bag_is_open() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.set_container(
@@ -405,9 +411,15 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The stock tooltip declares no size: it sizes from its lines through the font engine, as
// the client's does (1968) — a harness that reads its rect needs one; the fixed-width
// font is that engine here.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -415,6 +427,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -484,6 +497,8 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -491,6 +506,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -587,6 +603,8 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -594,6 +612,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -615,6 +634,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
slots.insert(
2,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -675,8 +695,8 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
assert!(s
.eval::<bool>(
"return GameTooltipMoneyCoin1:IsShown() \
and GameTooltipMoneyCoin1Num:GetText() == '52'",
"return GameTooltipMoneyFrame:IsShown() \
and GameTooltipMoneyFrameCopperButton:GetText() == '52'",
)
.unwrap());
assert_eq!(
@@ -689,7 +709,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
unhover(&mut s);
assert_eq!(s.ui_cursor(), None, "ResetCursor on leave");
assert!(s
.eval::<bool>("return not GameTooltipMoneyCoin1:IsShown()")
.eval::<bool>("return not GameTooltipMoneyFrame:IsShown()")
.unwrap());
// The unsellable item: the ITEM_UNSELLABLE line, no coins.
@@ -704,7 +724,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
.unwrap();
assert!(has_line, "SellPrice 0 shows the ITEM_UNSELLABLE line");
assert!(s
.eval::<bool>("return not GameTooltipMoneyCoin1:IsShown()")
.eval::<bool>("return not GameTooltipMoneyFrame:IsShown()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -721,6 +741,8 @@ fn readable_letter_hover_shows_the_inspect_magnifier() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -791,6 +813,8 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -798,6 +822,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -874,6 +899,8 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -881,6 +908,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -909,7 +937,8 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
);
assert!(!open(&s, 1), "hidden by default");
// CharacterBag0Slot == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
// CharacterBag0Slot == bag id 1: the stock bar's handlers carry no `bagId` field and
// recompute `this:GetID() - CharacterBag0Slot:GetID() + 1` (see `:2119` below).
s.run("CharacterBag0Slot:Click()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 1), "the bag-bar click opened bag 1's window");
@@ -946,6 +975,8 @@ fn equipped_bag_window_snug_fits_its_row_count() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -1005,6 +1036,8 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
for file in BAG_UI {
load_xml(s, file);
}
load_xml(s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(s, "Interface\\FrameXML\\StackSplitFrame.xml");
s.set_money(0);
@@ -1013,6 +1046,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -1148,7 +1182,7 @@ fn the_split_frame_raises_over_a_same_stratum_window() {
local w = CreateFrame("Frame", "FakeBagnon", UIParent)
w:SetFrameStrata("HIGH")
w:SetPoint("BOTTOMLEFT", 0, 0)
w:SetSize(1024, 768)
w:SetWidth(1024); w:SetHeight(768)
local bg = w:CreateTexture(nil, "BACKGROUND")
bg:SetTexture("Interface\\FakeBagnonBG")
bg:SetAllPoints()
@@ -1345,6 +1379,8 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.tick(100.0); // a nonzero clock epoch, like the engine cooldown tests
@@ -1371,19 +1407,15 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
s.set_container(0, Some(backpack(Some((52_000, 60_000, true)))));
s.run("OpenAllBags()").unwrap();
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(0)]);
// The stock machine (decision 2019): the slot's cooldown pane, sequence 0 scrubbed by the
// next paint's `OnUpdateModel` to the elapsed fraction.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
let sweep = |s: &UiScript| {
s.extract().iter().find_map(|q| match q.content {
QuadContent::Cooldown { fraction, .. } => Some(fraction),
_ => None,
})
};
let fraction = sweep(&s).expect("the bag slot sweeps");
assert!(
(fraction - 0.8).abs() < 1e-3,
"48 of 60 s elapsed: fraction {fraction}"
);
let sweep = |s: &UiScript| super::test_ui::cooldown_play_any(s);
let play = sweep(&s).expect("the bag slot sweeps");
assert_eq!(play, (0, 800), "48 of 60 s elapsed: sequence 0 at 800 ms");
// The cooldown clears (a CLEAR_COOLDOWN, or it simply ran out before the re-push): the
// refresh event re-reads the now-cold triple and hides the widget.
@@ -1418,6 +1450,8 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.resolve();
@@ -1454,6 +1488,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
// SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot).
let mut inv: benilla_ui::script::InventorySlots = Default::default();
inv[20] = Some(benilla_ui::script::InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -1551,6 +1586,8 @@ fn keyring_surface(s: &UiScript) {
for file in BAG_UI {
load_xml(s, file);
}
load_xml(s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(s, "Interface\\FrameXML\\UIErrorsFrame.xml");
}
@@ -1635,13 +1672,15 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
bar_right - perf_right
);
// And it reverts: destroying the last key takes the keyring back off the bar (our divergence
// from the ref's one-way saved-variable latch — see MainMenuBar_UpdateKeyRing).
// And it does NOT revert: the reference's latch is one-way — `MainMenuBar_UpdateKeyRing`
// only ever Shows, and SHOW_KEYRING is a saved variable (stock MainMenuBar.lua:174-183,
// MainMenuBar.xml:323-336). Our file used to take the button away with the last key; that
// divergence went with the file (1938).
s.set_has_key(false);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"losing the last key takes the button away again"
s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"losing the last key leaves the button on the bar — the reference's one-way latch"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1807,59 +1846,70 @@ fn a_key_dropped_on_the_button_files_itself() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
/// icon down into **that** container's bag-bar button and nobody else's, along the curves read out of
/// `ForcedBackpackItem.m2` — pop in, hang, fall, shrink away — and hides itself at the end.
///
/// This is the whole observable contract of `BenillaItemPushAnim_*`: the routing (which button), the
/// motion (starts a fall above the button, lands centred on it), the fade/scale shape (the file's
/// keys), and the CLAMP end (one play, then gone). It is also the regression net for the OnUpdate
/// gate — an anim frame left shown would keep ticking forever.
/// The item-push drop animation is the reference's own `<Model>` now (decisions 2013/2015):
/// `ITEM_PUSH(container, icon)` reaches the stock `ItemAnim_OnEvent`, which puts the icon on the
/// pane whose parent button owns that inventory slot (`ReplaceIconTexture`), arms sequence 0 at
/// 0 and shows it; the pane's clock runs the file's 1000 ms clamp; `ItemAnim_OnAnimFinished`
/// hides it. The pane authored no size, so its rect is the file's own bounding box in layout
/// units — the 42.41 × 124.72 the 0887 card measured by hand at 16:9.
#[test]
fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// `ForcedBackpackItem.m2` (`m2seq`/`m2batch`): one sequence, id 0, 1000 ms, clamp; the
// header box spans the card's whole travel band — 0.02707 × 0.07962 model units, which at
// 16:9 (a layout unit is 768·√(a²+1) = 1566.4 FrameXML units) is the 42.41 × 124.72 rect
// 0887's card measured.
const CARD: &str = r"Interface\ItemAnimations\ForcedBackpackItem.mdx";
s.set_model_facts(
CARD,
ModelFileFacts {
sequences: vec![SequenceFacts {
anim_id: 0,
duration_ms: 1000,
looping: false,
}],
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
cameras: 0,
},
);
s.resolve();
let shown = |s: &UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}BenillaItemPush:IsShown()"))
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
.unwrap()
};
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
return ax - bx, ay - by"
))
.unwrap()
};
let size = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetWidth()"))
.unwrap()
};
let alpha = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetAlpha()"))
.unwrap()
};
// The card's centre relative to its button's BOTTOMRIGHT corner — the reference's OWN frame of
// reference for this widget (it anchors the `<Model>`'s BOTTOMRIGHT to the button's), and the
// only one the numbers are stable in: the card's placement depends on the anchor offset and
// the M2 bounding box, NOT on how big the button is. Measuring against the button's centre
// instead is what let 0887's placement look plausible.
let corner = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
return ax - b:GetRight(), ay - b:GetBottom()"
))
.unwrap()
// The shown card panes, as the renderer sees them: `(icon, play head)` per visible pane.
type Card = (Option<String>, Option<(u16, u32)>);
let cards = |s: &mut UiScript| -> Vec<Card> {
let heads = s.visible_model_panes();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::ModelPane {
handle,
model: Some(m),
icon,
..
} if m == CARD => Some((
icon,
heads
.iter()
.find(|p| p.handle == handle)
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms))),
)),
_ => None,
})
.collect()
};
// Nothing is animating until a push arrives.
@@ -1870,11 +1920,11 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
] {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
assert!(cards(&mut s).is_empty());
// A push into equipped bag 2. `arg1` is the reference's own vocabulary since 1751 window 3 —
// the button's INVENTORY-slot id, which is what `ItemAnim_OnEvent` compares against — so bag 2
// is `CharacterBag1Slot`'s 21, not the container id 2 this used to send (`ui_loot.rs`
// `push_container`, the `0x491bb5` selector).
// is `CharacterBag1Slot`'s 21.
s.fire_event(
"ITEM_PUSH",
vec![
@@ -1882,159 +1932,70 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
assert!(
shown(&s, "CharacterBag1Slot"),
"the card that took it plays"
);
assert!(
!shown(&s, "MainMenuBarBackpackButton") && !shown(&s, "KeyRingButton"),
"…and nobody else's"
);
s.resolve();
assert!(shown(&s, "CharacterBag1Slot"), "bag 2's card plays");
for b in [
"MainMenuBarBackpackButton",
"CharacterBag0Slot",
"KeyRingButton",
] {
assert!(!shown(&s, b), "{b} took nothing, so {b} animates nothing");
}
// The pushed icon reaches the RENDERER, not just the Lua state — exactly one quad carries it.
let drawn = |s: &UiScript, icon: &str| {
s.extract()
.iter()
.filter(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == icon),
)
.count()
};
let live = cards(&mut s);
assert_eq!(
drawn(&s, "Interface\\Icons\\INV_Misc_Bag_08"),
1,
"the card wears the pushed item's icon (ITEM_PUSH's arg2)"
live,
vec![(
Some(r"Interface\Icons\INV_Misc_Bag_08".to_string()),
Some((0, 0))
)],
"one pane, the pushed icon on it, sequence 0 at 0"
);
// The implicit rect: the file's box in layout units, hung off the button's BOTTOMRIGHT
// (−10, 0) as the stock template anchors it.
let (w, h): (f32, f32) = s
.eval("return CharacterBag1SlotItemAnim:GetWidth(), CharacterBag1SlotItemAnim:GetHeight()")
.unwrap();
assert!(
(w - 42.41).abs() < 0.05 && (h - 124.72).abs() < 0.05,
"the card's rect is the file's box: {w} × {h}"
);
let (dx, dy): (f32, f32) = s
.eval(
"local a, b = CharacterBag1SlotItemAnim, CharacterBag1Slot \
return a:GetRight() - b:GetRight(), a:GetBottom() - b:GetBottom()",
)
.unwrap();
assert!(
(dx + 10.0).abs() < 0.01 && dy.abs() < 0.01,
"BOTTOMRIGHT (−10, 0) off the button: ({dx}, {dy})"
);
// Every figure below is the REFERENCE's, from wow-re's §5-cross-checked
// `modelframe-implicit-size-law.md` §3 — not a ratio we chose. The three it publishes
// (t = 0, 0.133, 1.0) are asserted to a tenth of a pixel; the rest assert the SHAPE of the
// motion, which is where 0887's authored placement actually went wrong.
//
// What changed from 0887, and why the old numbers looked right: it measured against the
// button's CENTRE and drove both axes off one shared 0..1 "drop" curve, which happens to land
// within ~1 px of the truth in y for a 36 px button. In x it was wrong in DIRECTION — the card
// drifts RIGHT as it falls, and the old code had it starting left and ending centred.
// t=0: invisible, full size, parked high above the button. The .m2's alpha key (0.000, 0.0),
// scale key (0.000, 1.0), translation (0.000, (0,0)).
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cx + 30.14).abs() < 0.1 && (cy - 68.99).abs() < 0.1,
"starts at the ref's (-30.14, 68.99) from the button's bottom-right: got ({cx}, {cy})"
);
assert!(
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
"the quad is 0.0288 model units, not the 0.03 that 0887 rounded it to: got {}",
size(&s, "CharacterBag1Slot")
);
assert!(
alpha(&s, "CharacterBag1Slot") < 0.01,
"fades in from nothing"
);
// t=0.133: fully faded in, at the 1.2x swell.
s.tick(0.133);
s.resolve();
assert!(
(alpha(&s, "CharacterBag1Slot") - 1.0).abs() < 0.02,
"opaque by the alpha track's second key"
);
assert!(
(size(&s, "CharacterBag1Slot") - 44.24).abs() < 0.1,
"swollen to the ref's 44.24 px peak: got {}",
size(&s, "CharacterBag1Slot")
);
// The pop moves the centre by a twentieth of a pixel, and that tiny drift is the tell that the
// bone's PIVOT is not the quad's centre — scaling up pushes the centre away from the pivot,
// scaling down pulls it in, one mechanism at both ends. Folding the pivot into the centre (the
// obvious simplification) loses this and misses the landing point by a fifth of a pixel.
let (px, py) = corner(&s, "CharacterBag1Slot");
assert!(
(px + 30.18).abs() < 0.02 && (py - 68.98).abs() < 0.02,
"the 1.2x pop drifts the centre off the pivot by the ref's (-0.046, -0.015): got ({px}, {py})"
);
// t=0.267: back to the icon's own size, still opaque, still parked.
s.tick(0.134);
s.resolve();
assert!(
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
"settled back at the scale track's third key"
);
let (_, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cy - 68.99).abs() < 0.1,
"has not started falling: got {cy}"
);
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
// second is a hang. The other two tracks are already running down, though: the three curves
// keep different schedules, which is the thing a "hold everything then drop" reading would get
// wrong (scale/alpha are 0.687/0.682 here, on the 0.267→1.000 ramps).
s.tick(0.233);
s.resolve();
let (_, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cy - 69.0).abs() < 0.1,
"has not moved yet at the half second: got {cy}"
);
assert!(
(size(&s, "CharacterBag1Slot") - 25.31).abs() < 0.2,
"but is already dwindling: got {}",
size(&s, "CharacterBag1Slot")
);
assert!(
(alpha(&s, "CharacterBag1Slot") - 0.682).abs() < 0.02,
"and already fading: got {}",
alpha(&s, "CharacterBag1Slot")
);
// t=0.75: halfway through the fall — and moving RIGHT as it drops, which is the axis 0887 had
// backwards. The translation is per-axis (+0.0104, -0.0402 model units), not one shared curve.
s.tick(0.25);
s.resolve();
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cx + 23.34).abs() < 0.2,
"drifted RIGHT by half the 13.3 px total: got {cx}"
);
assert!(
(cy - 43.33).abs() < 0.2,
"half of the 51.4 px fall travelled by t=0.75: got {cy}"
);
// Just short of the end: down on the button, shrunk to nothing, faded out.
s.tick(0.24);
s.resolve();
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cx + 16.87).abs() < 0.2 && (cy - 18.68).abs() < 0.2,
"arrives at the ref's landing point: got ({cx}, {cy})"
);
// Which is also, within a couple of pixels, the button's own centre — the reference's card
// really does drop INTO the bag button, and that is the one thing 0887 got right for the wrong
// reason. The corner-relative assertion above is the exact one; this is the sanity check, and
// its tolerance is a button-size term, not slack: the landing point is fixed relative to the
// button's BOTTOMRIGHT, so the offset from its CENTRE moves by half of any size change. The
// bar's buttons are 37x37 now — `ItemButtonTemplate`'s own size, inherited through the
// reference's `PaperDollItemSlotButtonTemplate` — where the deleted BagFrame.xml authored 36.
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
dx.abs() < 2.0 && dy.abs() < 2.0,
"lands on the button it went into: got ({dx}, {dy})"
);
assert!(
size(&s, "CharacterBag1Slot") < 2.0,
"collapsed to the scale track's 0.0144"
);
assert!(alpha(&s, "CharacterBag1Slot") < 0.05, "faded out");
// Past 1.000s the CLAMP sequence is over — the ref's OnAnimFinished → Hide.
s.tick(0.05);
// The clock runs the file's clamp: half a second in, the play head is at 500 ms…
s.tick(0.5);
assert_eq!(cards(&mut s)[0].1, Some((0, 500)));
// …and past 1000 ms the completion fires `ItemAnim_OnAnimFinished` → `Hide()`.
s.tick(0.55);
assert!(
!shown(&s, "CharacterBag1Slot"),
"one play, then gone (and no OnUpdate left running)"
"one play, then gone (the stock OnAnimFinished hides it)"
);
assert!(cards(&mut s).is_empty());
// A second push RESTARTS the card (SetSequenceTime(0, 0) on a fresh arm).
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(21),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_09".into()),
],
);
s.resolve();
assert_eq!(
cards(&mut s),
vec![(
Some(r"Interface\Icons\INV_Misc_Bag_09".to_string()),
Some((0, 0))
)]
);
// The keyring is a real destination, not a rounding of the backpack.
@@ -2079,6 +2040,8 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -2135,6 +2098,8 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
for i in 0..4 {
@@ -2195,6 +2160,8 @@ fn the_backpack_buttons_ring_follows_its_own_window_through_real_clicks() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
// The reference's `ToggleBag` opens nothing for a container of size 0, so the backpack has to
@@ -438,7 +438,7 @@ fn without_a_player_at_addon_load_bagnon_draws_an_empty_window() {
}
/// The **other** fault, pinned on its own so a later regression names itself: the reference's
/// `SetItemButton*` family (`assets/ui/ItemButtonTemplate.xml`).
/// `SetItemButton*` family (`Interface\FrameXML\ItemButtonTemplate.xml`).
///
/// Bagnon ends every slot update with `SetItemButtonDesaturated` / `SetItemButtonTexture` /
/// `SetItemButtonCount`. While those were nil the raise landed inside `BagnonFrame_AddBag`'s
@@ -1133,16 +1133,16 @@ fn a_texture_gradient_tints_the_art_it_sits_on() {
/// **The director's report: "when I first open the bags with bagnon the gold numbers are all
/// cramped up; if I close and open again it looks good."**
///
/// Bagnon's money display is `SmallMoneyFrameTemplate` — OUR `MoneyFrame.xml`. Its `ShowCoin` used
/// to size each coin with `label:GetStringWidth()`, which is served from the measure round-trip and
/// therefore reads **0 in the tick that set the text**: every coin came out at exactly one icon
/// width and the digits overlapped. The second open looked right because the first open's measure
/// had landed in the cache by then — the reopen was reading the previous open's numbers.
/// Bagnon's money display is `SmallMoneyFrameTemplate`, off the chain's own `MoneyFrame.xml`. Its
/// `ShowCoin` used to size each coin with `label:GetStringWidth()`, which is served from the
/// measure round-trip and therefore reads **0 in the tick that set the text**: every coin came out
/// at exactly one icon width and the digits overlapped. The second open looked right because the
/// first open's measure had landed in the cache by then — the reopen was reading the previous open's numbers.
///
/// The fix sums `BENILLA_DIGIT_W`, the app's per-digit advance feed
/// ([`benilla_ui::script::UiScript::set_digit_advances`]) — data pushed ahead, so the answer exists
/// *in* the tick. That feed's own doc names this frame as what it was built for; only the merchant
/// price had ever used it.
/// The fix is the engine's font measurer answering inside the Lua call that asked
/// (`benilla_ui`'s `script::measure`): `GetStringWidth` returns a real number in the tick that set
/// the text. A digits-only stand-in — a per-digit advance feed pushed ahead from the app — shipped
/// first and was retired by the general answer (decision 1285).
///
/// The assertion is the FIRST open, which is the half that was broken.
#[test]
@@ -1395,7 +1395,13 @@ fn bagnon_forevers_records_survive_the_logout_boundary() {
// The logout boundary: the despawn frame (no self store) and then the shutdown's own events.
// The record the write persists must be the bags the player actually had.
let mut memory = crate::ui_items::feed::FeedMemory::default();
crate::ui_items::feed::apply_container_source(&mut s, &mut memory, None, [0; 10], Vec::new());
crate::ui_items::feed::apply_container_source(
&mut s,
&mut memory,
None,
Default::default(),
Vec::new(),
);
s.fire_event("PLAYER_LEAVING_WORLD", Vec::new());
s.fire_event("PLAYER_LOGOUT", Vec::new());
let (size, item) = s
@@ -1,5 +1,5 @@
//! The shipped `assets/ui/BasicControls.xml` — the reference's second file, driven the way an
//! addon drives it.
//! The stock `Interface\FrameXML\BasicControls.xml` — the reference's second file, driven the way
//! an addon drives it.
//!
//! Nothing benilla ships calls `message`, `TEXT` or `_ERRORMESSAGE`; their only consumers are
//! third-party addons (26 call `TEXT`, ~10 genuinely call `message`, two replace `_ERRORMESSAGE`).
@@ -12,8 +12,8 @@ use super::test_ui::load_ui as load_xml;
/// Fonts then BasicControls — the manifest's own order, and the whole dependency this file has.
fn basic_controls() -> UiScript {
let mut s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
s.set_screen_size(1024.0, 768.0);
s.resolve();
s
@@ -0,0 +1,259 @@
//! The stock `BattlefieldFrame.xml` and `Minimap.xml`'s queue icon (decision 1974), driven
//! engine-only over the queue family's pushes: the icon and its tooltip across
//! `UPDATE_BATTLEFIELD_STATUS`, the confirm dialog, the list window across `BATTLEFIELDS_SHOW`
//! / `_CLOSED`, and the join button's request.
use benilla_ui::script::{BattlefieldListView, BattlefieldMapInfo, BattlefieldQueueSlot, UiScript};
use super::test_ui::load_ui as load_xml;
/// The stock pair with everything they call at load and on the events below — the durability
/// harness's shape (UIParent.xml for the panel kit and `UIPanelWindows`; StaticPopup for the
/// confirm dialog; UIDropDownMenu for the icon's menu; UIPanelTemplates for the buttons).
fn session() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The list verbs read the local player object at call time (1974) — seat one, as every
// in-world call has.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
s.run("function PlaySound() end").unwrap();
// `FloatingChatFrame.lua`'s constant (l.9), which the queue icon's fade-in reads; the chat
// files are not part of this harness.
s.run("CHAT_FRAME_FADE_TIME = 0.15").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
] {
load_xml(&s, f);
}
s.resolve();
s
}
fn slot(map_id: u32, name: &str, status: u32, instance: u32) -> BattlefieldQueueSlot {
BattlefieldQueueSlot {
map_id,
map_name: Some(name.into()),
status,
instance_id: instance,
min_level: 20,
max_level: 29,
port_expiration_ms: 75_000,
estimated_wait_ms: 90_000,
time_waited_ms: 12_000,
}
}
fn empty() -> BattlefieldQueueSlot {
BattlefieldQueueSlot {
map_name: Some("Eastern Kingdoms".into()),
..Default::default()
}
}
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap()
}
/// The minimap's queue icon: hidden with no queue, shown by a queued slot with the reference's
/// three-line tooltip, the confirm dialog on a ready slot, hidden again when the queue clears.
#[test]
fn the_queue_icon_follows_the_slots_across_update_battlefield_status() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
assert!(!visible(&s, "MiniMapBattlefieldFrame"), "no queue at load");
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 1, 0), empty(), empty()], 0);
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
assert!(
visible(&s, "MiniMapBattlefieldFrame"),
"a queued slot shows the icon"
);
let tooltip = s
.eval::<String>("return MiniMapBattlefieldFrame.tooltip")
.unwrap();
assert!(
tooltip.starts_with("You are in the queue for Arathi Basin\n"),
"{tooltip}"
);
// `SecondsToTime` leaves a trailing space after every unit — the reference's own text.
assert!(
tooltip.contains("Average wait time: 1 Min (Last 10 players)"),
"{tooltip}"
);
assert!(tooltip.contains("Time in queue: 12 Secs \n"), "{tooltip}");
assert!(
tooltip.ends_with("|cffffffff<Right Click> for PvP Options|r"),
"{tooltip}"
);
assert!(!visible(&s, "StaticPopup1"), "queued is not yet a question");
// Ready: the confirm dialog with the map name and the port countdown.
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 2, 3), empty(), empty()], 0);
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
assert!(
visible(&s, "StaticPopup1"),
"confirm raises CONFIRM_BATTLEFIELD_ENTRY"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"You are now eligible to enter Arathi Basin 3, choose an action:"
);
let tooltip = s
.eval::<String>("return MiniMapBattlefieldFrame.tooltip")
.unwrap();
assert!(
tooltip.starts_with(
"You are eligible to enter Arathi Basin 3\nYou will be removed from the queue in 1 Min 15 Secs "
),
"{tooltip}"
);
// The dialog's Enter Battle is AcceptBattlefieldPort(slot, 1).
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(s.take_battlefield_port_requests(), vec![(1, true)]);
// The queue clears: the icon goes.
s.set_battlefield_queue(vec![empty(), empty(), empty()], 0);
s.fire_event("UPDATE_BATTLEFIELD_STATUS", vec![]);
assert!(
!visible(&s, "MiniMapBattlefieldFrame"),
"no queue hides the icon"
);
}
/// The list window: `BATTLEFIELDS_SHOW` opens it on the pushed list — the fake "First
/// Available" row, one row per instance, the description — the join button asks for the
/// selection, and `BATTLEFIELDS_CLOSED` hides it.
#[test]
fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
s.set_battlefield_list(BattlefieldListView {
instances: vec![4, 9],
bracket_min: 20,
bracket_max: 29,
info: Some(BattlefieldMapInfo {
name: "Arathi Basin".into(),
description: Some("The Arathi Basin is one of the main staging points.".into()),
min_level: 20,
max_level: 60,
field_16: -1,
field_17: 0.0,
field_18: 0.0,
}),
group_queue: true,
});
s.set_battlefield_queue(vec![empty(), empty(), empty()], 0);
assert!(!visible(&s, "BattlefieldFrame"));
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
assert!(visible(&s, "BattlefieldFrame"), "the event shows the panel");
assert_eq!(
s.eval::<String>("return BattlefieldFrameFrameLabel:GetText()")
.unwrap(),
"Arathi Basin"
);
assert_eq!(
s.eval::<String>("return BattlefieldZone1:GetText()")
.unwrap(),
"First Available"
);
assert_eq!(
s.eval::<String>("return BattlefieldZone2:GetText()")
.unwrap(),
"Arathi Basin 4"
);
assert_eq!(
s.eval::<String>("return BattlefieldZone3:GetText()")
.unwrap(),
"Arathi Basin 9"
);
assert!(
!visible(&s, "BattlefieldZone4"),
"two instances, three rows"
);
assert_eq!(
s.eval::<String>("return BattlefieldFrameZoneDescription:GetText()")
.unwrap(),
"The Arathi Basin is one of the main staging points."
);
assert!(
visible(&s, "BattlefieldFrameGroupJoinButton"),
"the map allows group queues: the button shows"
);
assert!(
!s.eval::<bool>("return BattlefieldFrameGroupJoinButton:IsEnabled() ~= 0")
.unwrap(),
"…disabled while not leading a group"
);
// Join the default selection (row 1 = first available), then a specific instance.
s.run("BattlefieldFrameJoinButton:Click()").unwrap();
assert_eq!(s.take_battlefield_join_requests(), vec![(0, false)]);
assert!(!visible(&s, "BattlefieldFrame"), "joining hides the panel");
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
s.run("BattlefieldZone3:Click()").unwrap();
assert_eq!(s.eval::<i64>("return GetSelectedBattlefield()").unwrap(), 2);
s.run("BattlefieldFrameJoinButton:Click()").unwrap();
assert_eq!(s.take_battlefield_join_requests(), vec![(9, false)]);
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
assert!(visible(&s, "BattlefieldFrame"));
s.fire_event("BATTLEFIELDS_CLOSED", vec![]);
assert!(
!visible(&s, "BattlefieldFrame"),
"the leash's event hides it"
);
}
/// The queue icon's status pass marks the listed rows: a queued instance reads "(In Queue)"
/// beside its row, and the first-available row carries it when the queue names no instance.
#[test]
fn the_list_rows_carry_the_queue_status() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
s.set_battlefield_list(BattlefieldListView {
instances: vec![4],
info: Some(BattlefieldMapInfo {
name: "Arathi Basin".into(),
..Default::default()
}),
..Default::default()
});
s.set_battlefield_queue(vec![slot(529, "Arathi Basin", 1, 4), empty(), empty()], 0);
s.fire_event("BATTLEFIELDS_SHOW", vec![]);
assert_eq!(
s.eval::<String>("return BattlefieldZone2Status:GetText()")
.unwrap(),
"(In Queue)"
);
assert_eq!(
s.eval::<Option<String>>("return BattlefieldZone1Status:GetText()")
.unwrap(),
None,
"an un-queued row's status line is blank, and a blank FontString reads back NIL \
(`FontString:GetText 0x79d690` substitutes — decision 2110)"
);
}
@@ -11,12 +11,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "BinderConfirm.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s.set_binder_pending(true);
s
}
@@ -77,7 +77,9 @@ fn shipped_xml() -> Vec<(String, String)> {
})
.collect();
out.sort();
assert!(out.len() >= 40, "only {} xml files swept", out.len());
// A sanity floor for the walk, not a census: `assets/ui` retires file by file (1751), so
// the floor sits well under the count rather than one step above it (1956).
assert!(out.len() >= 6, "only {} xml files swept", out.len());
out
}
@@ -98,7 +100,7 @@ fn shipped_xml() -> Vec<(String, String)> {
fn no_shipped_file_declares_its_own_copy_of_a_managed_offset() {
let mut offences = Vec::new();
for (name, text) in shipped_xml() {
if name == "UIParent.xml" {
if name == r"Interface\FrameXML\UIParent.xml" {
continue; // the owner: its var rows are where these numbers are defined
}
for (n, line) in without_xml_comments(&text).lines().enumerate() {
@@ -156,11 +158,6 @@ const BOTTOM_EXEMPT: &[(&str, &str)] = &[
"the base itself — the bar everything else measures its clearance FROM, so it has no \
clearance of its own to compute",
),
(
"WorldFrame",
"a full-screen named handle, not a HUD frame (UIParent.xml) — setAllPoints, renders \
nothing",
),
("UIParent", "the full-screen root itself"),
(
"ZoneTextFrame",
@@ -211,11 +208,17 @@ const BOTTOM_EXEMPT: &[(&str, &str)] = &[
/// not need to. Failing here is not a bug report; it is a prompt to pick one.
#[test]
fn every_bottom_anchored_top_level_frame_is_accounted_for() {
let pass = std::fs::read_to_string(ui_dir().join("UIParent.xml")).expect("UIParent.xml");
// The pass is the stock `UIParent.lua`'s since 1988 — read off the player's chain.
let _data = benilla_formats::wow_data_or_skip!();
let pass = String::from_utf8_lossy(
&super::reference_ui::read(r"Interface\FrameXML\UIParent.lua")
.expect("the stock UIParent.lua off the chain"),
)
.into_owned();
for f in MANAGED_FRAMES {
assert!(
pass.contains(f),
"{f} is listed here as managed but does not appear in UIParent.xml's pass — this \
"{f} is listed here as managed but does not appear in UIParent.lua's pass — this \
list has drifted from the file it mirrors"
);
}
@@ -225,8 +228,7 @@ fn every_bottom_anchored_top_level_frame_is_accounted_for() {
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("{file}: {e}"));
for (name, anchors) in bottom_anchored_top_level(&doc) {
let known = MANAGED_FRAMES.contains(&name.as_str())
|| BOTTOM_EXEMPT.iter().any(|(n, _)| *n == name)
|| registers_a_listener(&text, &name);
|| BOTTOM_EXEMPT.iter().any(|(n, _)| *n == name);
if !known {
unaccounted.push(format!("{file}: {name} ({anchors})"));
}
@@ -236,21 +238,12 @@ fn every_bottom_anchored_top_level_frame_is_accounted_for() {
unaccounted.is_empty(),
"these top-level frames anchor to the screen's bottom edge but nothing decides their \
clearance over the action bars. Give each one a row in \
UIPARENT_MANAGED_FRAME_POSITIONS (UIParent.xml), or seat it from a managed global and \
register UIParent_RegisterManagedPositionListener, or add it to BOTTOM_EXEMPT here with \
the reason it needs neither (decision 1499):\n{}",
UIPARENT_MANAGED_FRAME_POSITIONS (the stock UIParent.lua's table, since 1988), or add \
it to BOTTOM_EXEMPT here with the reason it needs no row (decision 1499):\n{}",
unaccounted.join("\n")
);
}
/// A file that registers a managed-position listener is seating something itself; the frames it
/// declares are covered by that registration. Coarse on purpose — the listener seats a whole
/// family (the bag stack is five windows), and naming each one here would be the same drift trap
/// `MANAGED_FRAMES` guards against.
fn registers_a_listener(text: &str, _frame: &str) -> bool {
text.contains("UIParent_RegisterManagedPositionListener")
}
/// Every top-level INSTANCE (a `<Frame>`/`<Button>`/… that is not `virtual` and carries no
/// `parent` attribute) whose own `<Anchors>` name a BOTTOM point against the screen root — an
/// anchor with no `relativeTo`, or one naming `UIParent`/`WorldFrame`, all three of which resolve
@@ -361,6 +354,20 @@ fn no_bottom_band_frame_overlaps_a_raised_bar() {
let mut raised = Vec::new();
for (i, bar) in RAISABLE_BARS.iter().enumerate() {
let on = mask & (1 << i) != 0;
// **Through the saved globals, not a bare Show.** The stock pass reads
// `SHOW_MULTI_ACTIONBAR_1`/`_2` for its bottom-bar flags (`UIParent.lua:1598-1606`),
// never the frames' shown state, so raising a bar by hand and running the pass is a
// state the client never reaches — and it clears nothing (1988; our retired copy of
// the pass read `IsShown`, which is what let this drive work before).
let global = match *bar {
"MultiBarBottomLeft" => Some("SHOW_MULTI_ACTIONBAR_1"),
"MultiBarBottomRight" => Some("SHOW_MULTI_ACTIONBAR_2"),
_ => None,
};
if let Some(g) = global {
s.run(&format!("{g} = {}", if on { "1" } else { "nil" }))
.unwrap();
}
s.run(&format!(
"if {bar} then {bar}:{}() end",
if on { "Show" } else { "Hide" }
@@ -467,6 +474,20 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// The card is the stock `<Model>` since 2015, sized by its file's box once the facts land
// (`ForcedBackpackItem.m2`: one 1000 ms clamp; the box 0.02707 × 0.07962 model units).
s.set_model_facts(
r"Interface\ItemAnimations\ForcedBackpackItem.mdx",
benilla_ui::widget::ModelFileFacts {
sequences: vec![benilla_ui::widget::SequenceFacts {
anim_id: 0,
duration_ms: 1000,
looping: false,
}],
bbox: ([0.0, 0.0, 0.0], [0.02707, 0.07962, 0.0]),
cameras: 0,
},
);
s.run("MultiBarBottomLeft:Show() MultiBarBottomRight:Show() UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
@@ -481,15 +502,23 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
s.tick(0.133); // the opaque peak — the instant the card is most visible
s.resolve();
assert!(
shown(&s, "MainMenuBarBackpackButtonBenillaItemPush"),
shown(&s, "MainMenuBarBackpackButtonItemAnim"),
"the card plays"
);
let card = rect(&s, "MainMenuBarBackpackButtonBenillaItemPush");
// The pane's rect is the file's box in layout units at 16:9 — the card's whole travel band,
// 42.41 × 124.72 hung off the button's BOTTOMRIGHT (−10, 0) — and the card the file's keys
// put inside it at its opaque peak is the reference's 48.9..93.1 above the screen floor
// (0887 measured that quad by hand; it sits inside this band).
let card = rect(&s, "MainMenuBarBackpackButtonItemAnim");
let bar = rect(&s, "MultiBarBottomRight");
assert!(
(card.1 - 48.9).abs() < 0.5 && (card.3 - 93.1).abs() < 0.5,
"the card's band is the reference's 48.9..93.1 above the screen floor: got {card:?}"
(card.2 - card.0 - 42.41).abs() < 0.05 && (card.3 - card.1 - 124.72).abs() < 0.05,
"the pane's rect is the file's box: {card:?}"
);
assert!(
card.1 <= 48.9 && card.3 >= 93.1,
"the reference's card peak 48.9..93.1 lies inside the pane's band: {card:?}"
);
assert!(
overlaps(bar, card),
+32 -22
View File
@@ -1,6 +1,6 @@
//! The player buff bar (`assets/ui/BuffFrame.xml`, decisions 0255/0257) against its reference
//! behaviour. The XML/Lua is the unit under test; the app-side feed (`crate::ui_aura`) is stubbed by
//! pushing an [`AuraState`] list straight through [`UiScript::set_auras`] and firing
//! The player buff bar (stock `Interface\FrameXML\BuffFrame.xml`, decisions 0255/0257) against its
//! reference behaviour. The XML/Lua is the unit under test; the app-side feed (`crate::ui_aura`) is
//! stubbed by pushing an [`AuraState`] list straight through [`UiScript::set_auras`] and firing
//! `PLAYER_AURAS_CHANGED`, so these exercise the *button* handlers — the row/filter wiring, the
//! dispel-tinted border, the stack count, the countdown, the warning flash, and the right-click
//! cancel — the way the reference's own `BuffButton_*` do.
@@ -26,27 +26,37 @@ fn harness() -> UiScript {
// under a timed aura. Our copy carried them as `X = X or "%d s"` fallbacks; the reference's
// file formats them straight and `format(nil, …)` raises.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml"); // NORMAL/HIGHLIGHT_FONT_COLOR + the FontStrings' faces
// `GameTooltip`, which the reference's BuffButton_Update indexes on EVERY repaint to ask
// `IsOwned(this)` (BuffFrame.lua l.104) — not just on hover. Ours guarded it; the reference
// does not, so a session without the tooltip loses the whole repaint.
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); // NORMAL/HIGHLIGHT_FONT_COLOR + the FontStrings' faces
// `GameTooltip`, which the reference's BuffButton_Update indexes on EVERY repaint to ask
// `IsOwned(this)` (BuffFrame.lua l.104) — not just on hover. Ours guarded it; the reference
// does not, so a session without the tooltip loses the whole repaint.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// `SecondsToTimeAbbrev`, which 1.12 keeps in UIParent.lua and so do we since window 18.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
// The timer switch, PLANTED ON — not the shipped value. 1.12 declares it in
// UIOptionsFrame.lua (default "0"); we have no counterpart to that file, so it lives with the
// row that drives it (OptionsFrame.xml), where it shipped "1" from 0255/1139 until 1804 put
// it back on the reference's "0". These tests are about the timer text and the geometry it
// buys, so the harness turns it on the way the Interface page's row does. Order matters: the
// reference's `BuffFrame_OnLoad` calls `BuffButtons_UpdatePositions`, which seats the debuff
// row 20px differently depending on this value, so setting it afterwards leaves the bar laid
// out for the wrong one.
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
// The timer switch, PLANTED ON — not the shipped value. 1.12 declares it in
// UIOptionsFrame.lua (default "0"); we have no counterpart to that file, so it lives with the
// row that drives it (OptionsFrame.xml), where it shipped "1" from 0255/1139 until 1804 put
// it back on the reference's "0". These tests are about the timer text and the geometry it
// buys, so the harness turns it on the way the Interface page's row does. Order matters: the
// reference's `BuffFrame_OnLoad` calls `BuffButtons_UpdatePositions`, which seats the debuff
// row 20px differently depending on this value, so setting it afterwards leaves the bar laid
// out for the wrong one.
s.run("SHOW_BUFF_DURATIONS = \"1\"").unwrap();
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
// …and APPLIED, the way the app applies it (`manifest::apply_buff_durations`).
@@ -1028,7 +1038,7 @@ fn the_duration_line_reads_the_real_global_strings() {
s.run(
r#"
BENILLA_ANCHOR = CreateFrame("Button", "BF9")
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetWidth(10); BENILLA_ANCHOR:SetHeight(10)
BENILLA_TIP = CreateFrame("GameTooltip", "TT9")
"#,
)
+41 -10
View File
@@ -15,8 +15,12 @@ use super::test_ui::load_ui as load_xml;
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "CastingBar.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// The stock file writes `CastingBarText:SetText(FAILED)` rather than a literal, so the window
// needs GlobalStrings — which the manifest has above it, and which our own retired
// `CastingBar.xml` did not need because it hardcoded "Failed".
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\CastingBarFrame.xml");
s
}
@@ -29,7 +33,7 @@ fn bar_color(s: &UiScript) -> (f64, f64, f64) {
.unwrap()
}
/// One tick of the app's real order (`drive_script`): OnUpdate, resolve, then the draw list.
/// One tick of the app's real order (`tick_script` then `paint_script`): OnUpdate, resolve, then the draw list.
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
s.tick(dt);
s.resolve();
@@ -381,11 +385,22 @@ fn bottom(s: &UiScript, name: &str) -> f64 {
fn managed_positions_track_the_bottom_bar_stack() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "CastingBar.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\CastingBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // the dropdown kit's MenuBackdrop reads TOOLTIP_DEFAULT_COLOR
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
// The loader's post-load bootstrap, replayed with no bars in existence: the bare bases.
s.run("UIParent_ManageFramePositions()").unwrap();
@@ -401,14 +416,30 @@ fn managed_positions_track_the_bottom_bar_stack() {
// The bar stubs carry a no-op SetPoint: `MultiBarBottomLeft` and `ShapeshiftBarFrame` are
// themselves rows in UIPARENT_MANAGED_FRAME_POSITIONS, so since those frames wear their
// reference names the pass positions them as well as reading their visibility.
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
// **The bottom-bar flags come from the SAVED GLOBALS, not from the frames.** The stock pass
// reads `SHOW_MULTI_ACTIONBAR_1`/`_2` (`UIParent.lua:1598-1606`) and never asks the bars
// whether they are shown — our retired copy asked, which is why this drive used to fake a
// frame with an `IsShown`. The fake also had no `IsObjectType`, which the pass calls on every
// row it seats (1988).
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 100.0, "60 + bottomEither 40");
assert_eq!(bottom(&s, "ChatFrame1"), 102.0, "85 + bottomLeft 17");
// The stance bar shows (the warrior at login): the pet term, plus chat's both-flags extra.
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
// The pass's shapeshift-appearance arm (the reference's own, UIParent.lua:1705-1732 — in ours
// since 1938) touches the bar's three shelf textures by name, unguarded as the reference has
// it; a stand-in frame needs stand-in textures.
s.run(
"local t = { Show = function() end, Hide = function() end } \
ShapeshiftBarLeft, ShapeshiftBarMiddle, ShapeshiftBarRight = t, t, t",
)
.unwrap();
s.run(
"ShapeshiftBarFrame = ShapeshiftBarFrame or CreateFrame(\"Frame\", \"ShapeshiftBarFrame\") \
ShapeshiftBarFrame:Show() UIParent_ManageFramePositions()",
)
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 140.0, "60 + 40 + pet 40");
@@ -419,7 +450,7 @@ fn managed_positions_track_the_bottom_bar_stack() {
);
// It hides again (a druid leaving forms is the live case): everything settles back.
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame:Hide() UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 100.0);
@@ -68,6 +68,7 @@ fn combat_stats() -> UnitCombatStats {
fn inventory_with_head_item() -> InventorySlots {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -94,6 +95,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
slots.insert(
1,
benilla_ui::script::ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -158,6 +160,10 @@ fn shipped_character_frame_drives_end_to_end() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The stock tooltip declares no size: it sizes from its lines through the font engine, as
// the client's does (1968) — a harness that reads its rect needs one; the fixed-width
// font is that engine here.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
@@ -1175,6 +1181,102 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
/// **The five tabs fit their labels on the first show, from the reference's own `<OnShow>`** —
/// the whole point of retiring benilla's own copy of `CharacterFrameTabButtonTemplate`
/// (decision 1993).
///
/// The reference fits a tab once, in the template's `<OnShow>`: `PanelTemplates_TabResize(0)`, so
/// `tab = tabText:GetWidth() + 2 * $parentLeft:GetWidth()`. That needs a `GetStringWidth` that
/// answers inside the Lua call that asked; ours landed a frame late when our template was written,
/// which is the only reason that template ever carried an `OnUpdate` settle. The engine has a
/// synchronous measurer now, so the falsifier is a row of tabs still wearing the template's
/// authored 115 after the window opens — and a second one, a width that keeps moving per frame.
#[test]
fn the_five_tabs_fit_their_labels_on_the_first_show() {
let _data = benilla_formats::wow_data_or_skip!();
/// `2 * CharacterFrameTab1Left:GetWidth()` — the template's two 20-unit end slices.
const SIDES: f64 = 40.0;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The app installs `AtlasMeasurer`; a bare VM installs none, and an OnShow fit against a
// pending measure is a configuration the app does not have (the same correction 1848 made to
// the macro harness).
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.resolve();
let widths = |s: &mut UiScript| -> Vec<(f64, f64)> {
(1..=5)
.map(|i| {
s.eval::<(f64, f64)>(&format!(
"return CharacterFrameTab{i}Text:GetStringWidth(), CharacterFrameTab{i}:GetWidth()"
))
.unwrap()
})
.collect()
};
// Tab 2 is the PET tab, and `PetTab_Update` keeps it hidden while the player has no pet
// (`CharacterFrame.lua`) — a hidden tab never gets an OnShow, so it is still at its authored
// width until a pet turns it on. That is the reference's own arrangement, and it is asserted
// below rather than skipped.
assert!(
!s.eval::<bool>("return CharacterFrameTab2:IsVisible()")
.unwrap(),
"the pet tab is off with no pet — the rest of this test rests on it"
);
let first = widths(&mut s);
let fitted = |label: f64| label + SIDES;
for i in [0, 2, 3, 4] {
let (label, width) = first[i];
assert!(label > 0.0, "tab {} measured its label", i + 1);
assert_eq!(
width,
fitted(label),
"tab {} is its text plus the two end slices, from OnShow alone",
i + 1
);
assert_ne!(
width,
115.0,
"tab {} is still at the template's authored pre-fit",
i + 1
);
}
assert_eq!(
first[1].1, 115.0,
"…and the hidden pet tab is not yet fitted"
);
// Turn it on the way a pet does, and it fits on ITS first show — same handler, no settle.
s.run("CharacterFrameTab2:Show()").unwrap();
s.resolve();
let (pet_label, pet_width) = widths(&mut s)[1];
assert_eq!(
pet_width,
fitted(pet_label),
"the pet tab fits when it is first shown"
);
// …and it does not move afterwards: the reference fits once, and nothing re-fits per frame.
for _ in 0..3 {
s.tick(0.016);
}
s.resolve();
let settled = widths(&mut s);
assert_eq!(
settled[0], first[0],
"the fit is once, in OnShow — nothing re-fits per frame"
);
assert_eq!(&settled[2..], &first[2..], "…for the whole row");
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
/// **The tab kit's XML-facing entry point, driven the way an addon drives it.**
///
/// Four corpus addons (Enchantrix, Outfitter, SimpleActionSets, TheoryCraft) put
@@ -1198,16 +1300,22 @@ fn an_addons_tab_click_selects_through_the_generic_entry_point() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
// The reference's `PanelTemplates_SelectTab` ends with `if GameTooltip:IsOwned(tab)` —
// an arm our deleted copy omitted ("our tabs set no tooltip"), so selecting a tab now needs
// the tooltip to exist (1860).
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, r"Interface\FrameXML\CharacterFrameTemplates.xml"); // the window tab (1993)
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// A conforming row: tabs named `<frame>Tab1..N` (what `PanelTemplates_UpdateTabs` getglobals)
// and each carrying its own id, exactly as an addon's XML declares them.
@@ -39,28 +39,44 @@ fn bake_strings(s: &UiScript) {
fn chat_with_menu() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
bake_strings(&s);
super::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
}
/// Right-click a frame's centre through the real hit path.
/// The tabs ship hidden and the dock reveals them on a stationary hover (`FCF_OnUpdate`); a
/// click on a tab needs that reveal first.
fn reveal_then(s: &mut UiScript) {
reveal_dock(s);
}
fn right_click(s: &mut UiScript, frame: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
@@ -89,7 +105,7 @@ fn reveal_dock(s: &mut UiScript) {
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
@@ -119,41 +135,29 @@ fn right_clicking_a_chat_tab_opens_its_options_menu() {
.unwrap(),
"no menu before any click"
);
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the tab's right-click opens the options menu"
);
// The window verb, then the DISPLAY block, in the reference's own order
// (FloatingChatFrame.lua l.236-307).
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
4,
"Lock Window + Display title + Font Size + Background"
);
// **Unlock**, not Lock: both dock windows ship LOCKED (the stock chat-cache `LOCKED 1`), so
// the row offers the way out of it. `chat_resize_tests` drives the toggle itself.
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Unlock Window"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button2:GetText()")
.unwrap(),
"Display"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
.unwrap(),
"Font Size"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button4:GetText()")
.unwrap(),
"Background"
);
// The reference's first six rows for the default window (FloatingChatFrame.lua
// `FCFOptionsDropDown_Initialize`, level 1): the lock verb, Rename, New Window, the Display
// title, Font Size and Background — a docked non-default window gets Close before Display.
for (n, key) in [
(1, "UNLOCK_WINDOW"),
(2, "RENAME_CHAT_WINDOW"),
(3, "NEW_CHAT_WINDOW"),
(4, "DISPLAY"),
(5, "FONT_SIZE"),
(6, "BACKGROUND"),
] {
assert!(
s.eval::<bool>(&format!("return DropDownList1Button{n}:GetText() == {key}"))
.unwrap(),
"row {n} is {key}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -163,9 +167,10 @@ fn right_clicking_a_chat_tab_opens_its_options_menu() {
fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
left_click(&mut s, "ChatFrame2Tab");
assert_eq!(
s.eval::<i64>("return BenillaFCF.selected").unwrap(),
s.eval::<i64>("return SELECTED_DOCK_FRAME:GetID()").unwrap(),
2,
"left-click selects"
);
@@ -174,7 +179,6 @@ fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
.unwrap(),
"a left click opens no menu"
);
// …and it closes one that is open (ref FCF_Tab_OnClick's `CloseDropDownMenus()`).
right_click(&mut s, "ChatFrame1Tab");
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
left_click(&mut s, "ChatFrame1Tab");
@@ -197,28 +201,32 @@ fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
// The shipped window is fully transparent at rest — the classic "text over the world".
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap(),
0.0
);
// …so the reversed slider is seeded at its far end.
assert_eq!(
s.eval::<f64>("return DropDownList1Button4.opacity")
s.eval::<f64>("return DropDownList1Button6.opacity")
.unwrap(),
1.0,
"info.opacity is 1 - a (the ref's own 'the slider is reversed')"
);
left_click(&mut s, "DropDownList1Button4ColorSwatch");
left_click(&mut s, "DropDownList1Button6ColorSwatch");
assert!(
s.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap(),
"the Background swatch opens the colour picker"
);
// The player's hand is on the picker now, off the chat frame: the hover's fade-out runs to
// its end before the slider is touched, exactly as it does in real time.
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(
s.eval::<bool>("return OpacitySliderFrame:IsVisible()")
.unwrap(),
@@ -230,27 +238,25 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
1.0,
"the slider opens seeded from the window"
);
// Drag the thumb to 80% opaque. The slider's OnValueChanged is the live-preview hop.
s.run("OpacitySliderFrame:SetValue(1 - 0.8)").unwrap();
// **203, not 204** — and that is the reference, not a rounding bug of ours. Its setter is
// `__ftol(x · 255.0)`, which TRUNCATES, and `0.8 × 255` is `203.99999999999997` in binary.
let stored: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
// 204/255, and the last byte of that is 2133's doing. The slider carries
// `valueStep="0.01"`, so `SetValue` snaps `1 - 0.8` onto the lattice and rebuilds it as
// `n·step`; the reconstruction lands a hair BELOW the literal, `1 - value` a hair above 0.8,
// and the alpha store's floor quantizer then keeps 204 where it used to shed one to 203.
// The player asked for 0.8 and the store holds 0.8 — the client's own arithmetic, improved
// by accident.
assert!(
(stored - 203.0 / 255.0).abs() < 1e-9,
(stored - 204.0 / 255.0).abs() < 1e-9,
"the drag reached the engine store, quantized to its byte: {stored}"
);
// And the pixels follow: one FCF_OnUpdate tick with the cursor nowhere near the dock, and the
// background wears the new base rather than fading back to nothing.
//
// **The paint takes the FLOAT the slider produced, not the byte the store kept** — that split
// is the reference's, not ours: its `FCF_SetWindowAlpha` calls `SetAlpha(alpha)` with the raw
// value and `SetChatWindowAlpha(id, alpha)` with the same one, and only the second quantizes.
// The two reconcile at the next `FloatingChatFrame_Update`, i.e. the next login.
s.run("FCF_OnUpdate(1.0)").unwrap();
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let painted: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
(painted - 0.8).abs() < 1e-6,
@@ -278,11 +284,10 @@ fn the_shipped_window_still_fades_zero_to_a_quarter() {
"at rest: invisible"
);
reveal_dock(&mut s);
assert_eq!(
s.eval::<f64>("return ChatFrame1Background:GetAlpha()")
.unwrap(),
0.25,
"hovered: DEFAULT_CHATFRAME_ALPHA, exactly as before 1589"
let alpha: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
(alpha - 0.25).abs() < 1e-6,
"hovered: DEFAULT_CHATFRAME_ALPHA — got {alpha}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -294,29 +299,40 @@ fn the_shipped_window_still_fades_zero_to_a_quarter() {
fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
// Open the submenu the way a player does — hover the `hasArrow` row (UIDropDownMenu.xml's
// row OnEnter → `UIDropDownMenuRow_OpenSubmenu`).
hover(&mut s, "DropDownList1Button3");
hover(&mut s, "DropDownList1Button5");
assert_eq!(
s.eval::<i64>("return DropDownList2.numButtons").unwrap(),
4,
"CHAT_FONT_HEIGHTS is 12, 14, 16, 18"
);
// The rows come from `for index, value in CHAT_FONT_HEIGHTS` — a `next` walk over a table
// built with explicit `[n] =` keys, whose order is the VM's hash order and not necessarily
// ascending; the test reads the rows by their values rather than assuming one.
let row_with = |s: &UiScript, pt: i64| -> String {
s.eval::<String>(&format!(
"for i = 1, DropDownList2.numButtons do \
local b = getglobal('DropDownList2Button'..i) \
if b.value == {pt} then return b:GetName() end \
end return ''"
))
.unwrap()
};
assert_eq!(
s.eval::<String>("return DropDownList2Button1:GetText()")
s.eval::<String>(&format!("return {}:GetText()", row_with(&s, 12)))
.unwrap(),
"12 pt"
"12 pt",
"FONT_SIZE_TEMPLATE over the value"
);
// ChatFontNormal is 14 px, so the second row is the one already ticked.
let ticked = row_with(&s, 14);
assert!(
s.eval::<bool>("return DropDownList2Button2Check:IsVisible()")
s.eval::<bool>(&format!("return {ticked}Check:IsVisible()"))
.unwrap(),
"the tick follows the font the frame is actually wearing"
"the tick follows the font the frame is actually wearing (ChatFontNormal, 14)"
);
left_click(&mut s, "DropDownList2Button3"); // 16 pt
let sixteen = row_with(&s, 16);
left_click(&mut s, &sixteen);
let (_, height): (String, f64) = s
.eval("local f, h = ChatFrame1:GetFont() return f, h")
.unwrap();
@@ -337,12 +353,17 @@ fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
fn each_tabs_menu_writes_its_own_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame2Tab");
assert_eq!(
s.eval::<i64>("return FCF_GetCurrentChatFrameID()").unwrap(),
2
);
left_click(&mut s, "DropDownList1Button4ColorSwatch");
// A docked non-default window's menu carries Close before Display, so Background is row 7.
assert!(s
.eval::<bool>("return DropDownList1Button7:GetText() == BACKGROUND")
.unwrap());
left_click(&mut s, "DropDownList1Button7ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap(); // reversed: 0 = fully opaque
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(2) return a")
@@ -365,15 +386,14 @@ fn each_tabs_menu_writes_its_own_window() {
fn cancelling_the_picker_puts_the_window_back() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_with_menu();
// Start from a non-default look so "restored" is distinguishable from "reset".
s.run("FCF_SetWindowAlpha(ChatFrame1, 0.4) FCF_SetWindowColor(ChatFrame1, 0.2, 0.4, 0.6)")
.unwrap();
let before: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
.unwrap();
reveal_then(&mut s);
right_click(&mut s, "ChatFrame1Tab");
left_click(&mut s, "DropDownList1Button4ColorSwatch");
left_click(&mut s, "DropDownList1Button6ColorSwatch");
s.run("OpacitySliderFrame:SetValue(0)").unwrap();
assert_eq!(
s.eval::<f64>("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
@@ -381,7 +401,6 @@ fn cancelling_the_picker_puts_the_window_back() {
1.0,
"the drag previewed live"
);
left_click(&mut s, "ColorPickerCancelButton");
let after: f64 = s
.eval("local _,_,_,_,_,a = GetChatWindowInfo(1) return a")
@@ -411,10 +430,20 @@ fn the_restore_event_repaints_the_window_from_the_store() {
font_size: 18,
locked: true,
docked: Some(1),
..Default::default()
},
)]);
// The reference paints colour, alpha and lock from the record on the FIRST
// `UPDATE_CHAT_WINDOWS` a frame sees (`FloatingChatFrame_Update`'s `not isInitialized`
// gate) — a login, which is when the app restores the file. The helper already fired that
// one, so this is the frame meeting the event fresh, as at the next login.
s.run("ChatFrame1.isInitialized = nil").unwrap();
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
s.run("BenillaFCF.hover = false FCF_OnUpdate(1.0)").unwrap();
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert_eq!(
s.eval::<f64>("return ChatFrame1Background:GetAlpha()")
.unwrap(),
@@ -425,14 +454,9 @@ fn the_restore_event_repaints_the_window_from_the_store() {
.eval("return ChatFrame1Background:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.0, 0.0), "and the restored tint");
let (_, height): (String, f64) = s
.eval("local f, h = ChatFrame1:GetFont() return f, h")
.unwrap();
assert_eq!(height, 18.0, "and the restored font size");
// Restoring is not a player edit: nothing was queued back at the host.
assert!(
s.take_chat_window_changes().is_empty(),
"the restore path does not dirty the file it came from"
);
// The restore paints through the `doNotSave` arms, but the reference's dock pass that follows
// (`FCF_DockFrame` → `FCF_SaveDock` → `SetChatWindowDocked`) is a real write — the DOCKED 1 /
// DOCKED 2 every stock file carries are FrameXML's, not the loader's.
assert_eq!(s.take_chat_window_changes(), vec![0, 1]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -17,21 +17,38 @@ use super::test_ui::load_ui as load_xml;
fn chat_ui() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the
// bar's own load-time chain precedes it, as in the action-bar harness.
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
super::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
@@ -78,6 +95,29 @@ fn left_click(s: &mut UiScript, frame: &str) {
}
/// Press a grip and walk the cursor to `(x, y)` — the real gesture, without releasing.
/// The tabs ship hidden; a stationary hover over the dock reveals them (`FCF_OnUpdate`), and
/// the reference moves a window by dragging its TAB (ChatTabTemplate's OnDragStart →
/// `StartMoving`), so every drag here starts with the reveal.
fn reveal(s: &mut UiScript) {
let (cx, cy) = centre(s, "ChatFrame1");
s.mouse_move(cx, cy);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
}
/// Drag `ChatFrame1Tab` by `dx, dy` — the reference's move gesture — and release.
fn drag_tab(s: &mut UiScript, dx: f32, dy: f32) {
reveal(s);
let (tx, ty) = centre(s, "ChatFrame1Tab");
s.mouse_button(tx, ty, "LeftButton", true);
s.mouse_move(tx + 5.0, ty); // past the 4px threshold: OnDragStart -> StartMoving
s.mouse_move(tx + dx, ty + dy);
s.mouse_button(tx + dx, ty + dy, "LeftButton", false);
s.resolve();
}
fn grab_grip(s: &mut UiScript, grip: &str) -> (f32, f32) {
let (x, y) = centre(s, grip);
s.mouse_button(x, y, "LeftButton", true);
@@ -98,17 +138,14 @@ fn both_dock_windows_ship_locked() {
))
.unwrap(),
1,
"window {i} ships locked (the stock chat-cache row)"
"window {i} ships locked (the boot init's LOCKED 1)"
);
assert!(
s.eval::<bool>(&format!("return ChatFrame{i}.isLocked ~= nil"))
.unwrap(),
"…and the frame was seated from it"
"…and the frame was seated from it by FloatingChatFrame_Update"
);
}
// The blanket switch ships OFF — the reference's own initialiser, not its checkbox table's
// disagreeing `default = "1"`.
assert_eq!(s.eval::<String>("return CHAT_LOCKED").unwrap(), "0");
assert!(s
.eval::<Option<i64>>("return FCF_Get_ChatLocked()")
.unwrap()
@@ -123,20 +160,17 @@ fn both_dock_windows_ship_locked() {
fn the_tab_menus_lock_row_toggles_the_window_and_flips_its_label() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
reveal(&mut s);
right_click(&mut s, "ChatFrame1Tab");
// Shipped locked, so the row offers the way OUT of it.
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Unlock Window"
);
assert!(s
.eval::<bool>("return DropDownList1Button1:GetText() == UNLOCK_WINDOW")
.unwrap());
assert_eq!(
s.eval::<i64>("return DropDownList1Button1.notCheckable")
.unwrap(),
1,
"a verb, not a tick"
);
left_click(&mut s, "DropDownList1Button1");
assert!(
s.eval::<Option<i64>>("return ChatFrame1.isLocked")
@@ -150,14 +184,10 @@ fn the_tab_menus_lock_row_toggles_the_window_and_flips_its_label() {
.is_none(),
"…and wrote it through SetChatWindowLocked, so it can be persisted"
);
// Re-open: the same row is now the other verb.
right_click(&mut s, "ChatFrame1Tab");
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"Lock Window"
);
assert!(s
.eval::<bool>("return DropDownList1Button1:GetText() == LOCK_WINDOW")
.unwrap());
left_click(&mut s, "DropDownList1Button1");
assert_eq!(
s.eval::<i64>("return ChatFrame1.isLocked").unwrap(),
@@ -176,39 +206,21 @@ fn a_locked_window_refuses_a_grip_drag() {
let mut s = chat_ui();
let before = width(&mut s);
assert_eq!(before, 430.0, "the authored width");
// The grip is where it should be — this is not a test that passes because nothing is there.
// The reference's grips take the mouse whatever the lock says — the refusal is
// `FCF_Resize`'s `isLocked` gate, not a disabled button.
assert!(s
.eval::<bool>("return ChatFrame1ResizeBottomRight ~= nil")
.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap());
assert!(
!s.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap(),
"a locked window's grips are click-through"
);
// The press falls past the disabled button — to whatever is behind it, which at this corner
// is the world (the grip straddles the window's edge and its centre sits just outside).
let (x, y) = centre(&mut s, "ChatFrame1ResizeBottomRight");
assert_ne!(
s.hit_test_name(x, y).as_deref(),
Some("ChatFrame1ResizeBottomRight"),
"a disabled grip does not eat the click"
);
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
let (x, y) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 60.0, y - 40.0);
assert_eq!(width(&mut s), before, "a locked window does not resize");
// …and the direct call refuses too, which is the reference's own gate rather than ours.
s.run("ChatFrame1ResizeBottomRight:EnableMouse(true)")
.unwrap();
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 60.0, y - 40.0);
assert_eq!(
width(&mut s),
before,
"FCF_Resize's isLocked gate refuses even a live grip"
assert!(
s.eval::<Option<i64>>("return ChatFrame1.resizing")
.unwrap()
.is_none(),
"FCF_Resize returned before StartSizing"
);
s.mouse_button(x + 60.0, y - 40.0, "LeftButton", false);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -299,35 +311,29 @@ fn the_release_ends_the_resize_from_anywhere_on_screen() {
/// gesture has to cross the drag threshold before anything moves, which is what keeps 0843's
/// held-spell dismissal working on the same button.
#[test]
fn the_body_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
fn the_tab_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
let (cx, cy) = centre(&mut s, "ChatFrame1");
let start = left(&mut s);
// Locked: not drag-registered at all, so the gesture never arms.
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 80.0, cy);
s.mouse_button(cx + 80.0, cy, "LeftButton", false);
assert_eq!(left(&mut s), start, "a locked window does not drag");
drag_tab(&mut s, 80.0, 0.0);
assert_eq!(
left(&mut s),
start,
"a locked window does not drag — ChatTabTemplate's OnDragStart returns on isLocked"
);
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy); // past the 4px threshold: OnDragStart -> StartMoving
s.mouse_move(cx + 80.0, cy);
// **75, not 80.** The gesture's first 5px are spent crossing the drag threshold, and
// `StartMoving` samples the cursor where it is when `OnDragStart` fires — so the window follows
// from *there*, not from the press. That is the engine's own shape (`cursor::maybe_start_drag`
// runs after the move pump in the same `mouse_move`), and it is what every drag-to-move frame
// in the client does.
reveal(&mut s);
let (tx, ty) = centre(&mut s, "ChatFrame1Tab");
s.mouse_button(tx, ty, "LeftButton", true);
s.mouse_move(tx + 5.0, ty);
s.mouse_move(tx + 80.0, ty);
assert_eq!(
left(&mut s),
start + 75.0,
"the window followed the cursor from where the drag started"
);
s.mouse_button(cx + 80.0, cy, "LeftButton", false);
s.mouse_move(cx + 400.0, cy);
s.mouse_button(tx + 80.0, ty, "LeftButton", false);
s.mouse_move(tx + 400.0, ty);
assert_eq!(
left(&mut s),
start + 75.0,
@@ -343,20 +349,14 @@ fn the_body_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
// The managed pass owns the seat to begin with — the control for the assertion below.
s.run("UIParent_ManageFramePositions()").unwrap();
let seated = left(&mut s);
assert!(
!s.eval::<bool>("return ChatFrame1:IsUserPlaced()").unwrap(),
"nothing has placed it yet"
);
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy);
s.mouse_move(cx + 120.0, cy);
s.mouse_button(cx + 120.0, cy, "LeftButton", false);
drag_tab(&mut s, 120.0, 0.0);
let moved = left(&mut s);
assert_eq!(
moved,
@@ -367,7 +367,6 @@ fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
s.eval::<bool>("return ChatFrame1:IsUserPlaced()").unwrap(),
"the drag set the userPlaced bit"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert_eq!(
left(&mut s),
@@ -390,12 +389,7 @@ fn the_geometry_round_trips_through_the_save_file() {
let (gx, gy) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(gx + 70.0, gy - 40.0);
s.mouse_button(gx + 70.0, gy - 40.0, "LeftButton", false);
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy + 5.0);
s.mouse_move(cx + 90.0, cy + 60.0);
s.mouse_button(cx + 90.0, cy + 60.0, "LeftButton", false);
s.resolve();
drag_tab(&mut s, 90.0, 60.0);
let want = (
width(&mut s),
@@ -452,8 +446,11 @@ fn the_geometry_round_trips_through_the_save_file() {
fn the_grip_art_follows_the_windows_reveal_and_tint() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
// Off-hover, at the shipped alpha-0 base: everything in the set is invisible.
s.run("BenillaFCF.hover = false FCF_OnUpdate(1.0)").unwrap();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
for piece in ["Background", "ResizeBottomRightTexture", "ResizeTopTexture"] {
assert_eq!(
s.eval::<f64>(&format!("return ChatFrame1{piece}:GetAlpha()"))
@@ -462,24 +459,16 @@ fn the_grip_art_follows_the_windows_reveal_and_tint() {
"{piece} is invisible at rest"
);
}
// Park the cursor in the window and let the stationary delay and the ramp run out.
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_move(cx, cy);
for _ in 0..25 {
s.tick(0.016);
s.resolve();
}
reveal(&mut s);
for piece in ["Background", "ResizeBottomRightTexture", "ResizeTopTexture"] {
assert_eq!(
s.eval::<f64>(&format!("return ChatFrame1{piece}:GetAlpha()"))
.unwrap(),
0.25,
"{piece} reveals with the box (DEFAULT_CHATFRAME_ALPHA)"
let alpha: f64 = s
.eval(&format!("return ChatFrame1{piece}:GetAlpha()"))
.unwrap();
assert!(
(alpha - 0.25).abs() < 1e-6,
"{piece} reveals with the box (DEFAULT_CHATFRAME_ALPHA): {alpha}"
);
}
// And the tab menu's tint reaches them too — one list, one colour.
s.run("FCF_SetWindowColor(ChatFrame1, 1, 0, 0)").unwrap();
let (r, g, b): (f64, f64, f64) = s
.eval("return ChatFrame1ResizeTopTexture:GetVertexColor()")
@@ -496,24 +485,38 @@ fn a_drag_in_flight_holds_the_chrome_visible() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
reveal(&mut s);
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
// Drag the cursor right out of the window's hover rect. Everything the fade normally keys on
// now says "conceal": the cursor is nowhere near the dock, so only `chatFrame.resizing` can
// hold the grips on screen.
s.mouse_move(1500.0, 820.0);
s.run("FCF_OnUpdate(1.0)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaFCF.reveal").unwrap(),
1.0,
"the resize holds the reveal open with the cursor off the window"
s.eval::<i64>("return ChatFrame1.resizing").unwrap(),
1,
"FCF_Resize marked the frame"
);
s.mouse_move(1500.0, 820.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let alpha: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
(alpha - 0.25).abs() < 1e-6,
"the resize holds the reveal open with the cursor off the window (`or chatFrame.resizing`): {alpha}"
);
// The falsifier is the release: with the drag over, the same cursor position conceals.
s.mouse_button(1500.0, 820.0, "LeftButton", false);
s.run("FCF_OnUpdate(1.0)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaFCF.reveal").unwrap(),
0.0,
"and lets go when the drag ends"
assert!(
s.eval::<Option<i64>>("return ChatFrame1.resizing")
.unwrap()
.is_none(),
"FCF_StopResize cleared it"
);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let alpha: f64 = s.eval("return ChatFrame1Background:GetAlpha()").unwrap();
assert!(
alpha < 1e-6,
"and the box fades once the drag ends: {alpha}"
);
}
@@ -522,30 +525,24 @@ fn a_drag_in_flight_holds_the_chrome_visible() {
#[test]
fn the_chat_locked_global_overrides_the_per_window_lock() {
let _data = benilla_formats::wow_data_or_skip!();
let s = chat_ui();
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
assert!(s
.eval::<bool>("return FCF_CanMove(ChatFrame1) == 1")
.unwrap());
let before = width(&mut s);
s.run("FCF_Set_ChatLocked(1)").unwrap();
assert_eq!(s.eval::<String>("return CHAT_LOCKED").unwrap(), "1");
assert!(
s.eval::<Option<i64>>("return FCF_CanMove(ChatFrame1)")
.unwrap()
.is_none(),
"the blanket switch wins over an unlocked window"
let (x, y) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 50.0, y - 30.0);
assert_eq!(
width(&mut s),
before,
"the blanket switch wins over an unlocked window — FCF_Resize's first gate"
);
assert!(
!s.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap(),
"…and takes the edges back"
);
s.mouse_button(x + 50.0, y - 30.0, "LeftButton", false);
s.run("FCF_Set_ChatLocked(nil)").unwrap();
assert!(s
.eval::<bool>("return FCF_CanMove(ChatFrame1) == 1")
.unwrap());
let (x, y) = grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
s.mouse_move(x + 50.0, y - 30.0);
assert_eq!(width(&mut s), before + 50.0, "and lets go when cleared");
s.mouse_button(x + 50.0, y - 30.0, "LeftButton", false);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -556,24 +553,22 @@ fn the_chat_locked_global_overrides_the_per_window_lock() {
fn the_docked_combat_log_cannot_be_moved_or_resized_on_its_own() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame2, nil)").unwrap();
assert!(
s.eval::<Option<i64>>("return FCF_CanMove(ChatFrame2)")
.unwrap()
.is_none(),
"a docked window that is not the default one owns no geometry"
);
assert!(!s
.eval::<bool>("return ChatFrame2ResizeBottomRight:IsMouseEnabled()")
.unwrap());
// …and it still follows window 1 when window 1 does move.
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
let (cx, cy) = centre(&mut s, "ChatFrame1");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_move(cx + 5.0, cy);
s.mouse_move(cx + 60.0, cy);
s.mouse_button(cx + 60.0, cy, "LeftButton", false);
s.run("FCF_SelectDockFrame(ChatFrame2) FCF_SetLocked(ChatFrame2, nil)")
.unwrap();
s.resolve();
let before: f64 = s.eval("return ChatFrame2:GetWidth()").unwrap();
let (x, y) = grab_grip(&mut s, "ChatFrame2ResizeBottomRight");
s.mouse_move(x + 50.0, y - 30.0);
s.resolve();
assert_eq!(
s.eval::<f64>("return ChatFrame2:GetWidth()").unwrap(),
before,
"a docked window that is not the default one owns no geometry — FCF_Resize's third gate"
);
s.mouse_button(x + 50.0, y - 30.0, "LeftButton", false);
s.run("FCF_SelectDockFrame(ChatFrame1) FCF_SetLocked(ChatFrame1, nil)")
.unwrap();
drag_tab(&mut s, 60.0, 0.0);
let one: f64 = s.eval("return ChatFrame1:GetLeft()").unwrap();
let two: f64 = s.eval("return ChatFrame2:GetLeft()").unwrap();
assert_eq!(one, two, "the dock moved as one");
+499 -100
View File
@@ -1,9 +1,9 @@
//! Shipped end-to-end tests for the chat window + input (decision 0084, the chat arc): the real
//! `assets/ui/ChatFrame.xml` loaded through the engine loader, driven exactly as the app drives it —
//! `add_chat_message` → `resolve`/`extract` (lines render in the pinned colors), the wheel → the Lua
//! `OnMouseWheel` handler → `ScrollUp` (which freezes the fade), and the input EditBox's ENTER →
//! `OnEnterPressed` → `SubmitChatInput` → `take_chat_input` (the app then parses it — see
//! `crate::ui_chat`'s `parse_line` tests for the `/say`//`/yell`//`/w` mapping).
//! `Interface\FrameXML\ChatFrame.xml` loaded through the engine loader, driven exactly as the app
//! drives it — `add_chat_message` → `resolve`/`extract` (lines render in the pinned colors), the
//! wheel → the Lua `OnMouseWheel` handler → `ScrollUp` (which freezes the fade), and the input
//! EditBox's ENTER → `OnEnterPressed` → `SubmitChatInput` → `take_chat_input` (the app then parses
//! it — see `crate::ui_chat`'s `parse_line` tests for the `/say`//`/yell`//`/w` mapping).
use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript};
@@ -12,17 +12,38 @@ use super::test_ui::load_ui as load_xml;
/// The full ChatFrame stack (fonts first, so the FontString's `inherits="ChatFontNormal"` resolves).
fn chat_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// GameTooltip.xml + UIDropDownMenu.xml are real RUNTIME dependencies of the chat tabs since
// decision 1589: a left click closes any open menu (`CloseDropDownMenus`, the reference's own
// first move in `FCF_Tab_OnClick`) and a right click opens the window's options menu.
// `benilla.toc` already orders both ahead of ChatFrame.xml (l.60/64 vs l.399); the harness
// says so too, rather than a guard that would hide a real ordering fault. (The tooltip file is
// the dropdown kit's own dependency — its MenuBackdrop reads `TOOLTIP_DEFAULT_COLOR`.)
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
// The UIParent slice (RaiseFrameLevel, MouseIsOver, the fade and flash kits) ahead of the
// bar and the chat files; `FCF_OnUpdate` — the dock's hover, fade and drag driver — runs
// from UIParent's OnUpdate, as in the reference.
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `FCF_ValidateChatFramePosition` (a tab-drag stop) reads `MainMenuBar:GetHeight()`; the bar's
// own load-time chain precedes it, as in the action-bar harness.
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
super::fire_chat_login(&mut s);
s.set_screen_size(1600.0, 900.0);
s.resolve();
s
@@ -109,24 +130,19 @@ fn newest_line_sits_at_the_bottom() {
#[test]
fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
let mut s = chat_frame();
// Short fade so a tick visibly ramps: straight into phase 2 (timeVisible 0), 4s ramp.
s.run("ChatFrame1:SetTimeVisible(0); ChatFrame1:SetFadeDuration(4)")
.unwrap();
for t in ["L0", "L1", "L2"] {
s.add_chat_message("ChatFrame1", t, 1.0, 1.0, 1.0);
}
s.resolve();
// One tick at the bottom ramps every line's alpha down.
s.tick(1.0);
s.resolve();
let a1 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
assert!(a1 < 1.0 && a1 > 0.0, "the line faded partway: {a1}");
// Scroll up via the wheel (a point inside the docked frame: BOTTOMLEFT (32,85), 430x160). The
// Lua OnMouseWheel handler calls ScrollUp, which re-arms every displayed line to full alpha —
// and, no longer AtBottom, the tick then holds it there.
s.mouse_wheel(100.0, 150.0, 1.0);
// The scroll verb is what the reference's scroll buttons call; the 1.12 chat frame takes
// no wheel of its own (`enableMouse="false"`, no OnMouseWheel).
s.run("ChatFrame1:ScrollUp()").unwrap();
s.resolve();
let a2 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a2, 1.0), "the scroll brought the line back: {a2}");
@@ -134,9 +150,7 @@ fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
s.resolve();
let a3 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
assert!(close(a3, 1.0), "frozen while scrolled up: {a3}");
// Wheel back down to the bottom — that scroll re-arms too, so the ramp restarts from full.
s.mouse_wheel(100.0, 150.0, -1.0);
s.run("ChatFrame1:ScrollToBottom()").unwrap();
s.tick(1.0);
s.resolve();
let a4 = text_color(&s.extract(), "L1").expect("L1 visible")[3];
@@ -147,18 +161,25 @@ fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
#[test]
fn input_editbox_enter_drains_the_typed_line() {
let mut s = chat_frame();
// ENTER opens (the app calls focus_editbox); typing goes to the focused box.
assert!(s.focus_editbox("ChatFrameEditBox"), "the edit box focuses");
assert!(s.has_keyboard_focus(), "focus gates the world's keys");
s.char_input("/yell hi");
// ENTER → OnEnterPressed → SubmitChatInput(GetText()) + SetText('') + ClearFocus + Hide.
assert!(s.key_input("ENTER"), "the box consumes ENTER");
assert_eq!(s.take_chat_input(), vec!["/yell hi".to_string()]);
// The reference's own path: `ChatEdit_OnEnterPressed` → `ChatEdit_SendText` →
// `ChatEdit_ParseText` → `SendChatMessage("hi", "YELL", …)`.
let sends = s.take_chat_sends();
assert_eq!(
sends
.iter()
.map(|c| (c.text.as_str(), c.chat_type.as_str()))
.collect::<Vec<_>>(),
vec![("hi", "YELL")]
);
assert!(
!s.has_keyboard_focus(),
"submit closes the box (ClearFocus + Hide)"
"submit closes the box (ChatEdit_OnEscapePressed: ClearFocus + Hide)"
);
assert!(s.take_chat_input().is_empty(), "drained");
assert!(s.take_chat_sends().is_empty(), "drained");
}
#[test]
@@ -181,12 +202,13 @@ fn chat_box_arrows_edit_and_history_recalls() {
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("ab");
// Plain LEFT must MOVE the caret (not be swallowed): a backspace after it deletes 'a', not 'b'.
s.editbox_action(EditAction::Move {
unit: EditUnit::Char,
back: true,
extend: false,
});
// The reference's ChatFrameEditBoxTemplate is `ignoreArrows="true"` — the engine's
// AltArrowKeyMode, which the app's key routing reads to hand a plain arrow to the bindings
// (the character turns while you type) rather than to the box. The box itself still edits.
assert!(
s.editbox_alt_arrow_mode(),
"the stock template's ignoreArrows landed as AltArrowKeyMode on the focused box"
);
s.editbox_action(EditAction::Delete {
unit: EditUnit::Char,
back: true,
@@ -194,30 +216,38 @@ fn chat_box_arrows_edit_and_history_recalls() {
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"b",
"plain arrows move the caret in the shipped chat box"
"a"
);
// Submit, then push the canonical line the router would (ui_chat::input::history_line) — the
// asynchronous ChatEdit_AddHistory slot.
s.run("ChatFrameEditBox:SetText('')").unwrap();
s.char_input("/yell hi");
assert!(s.key_input("ENTER"));
assert_eq!(s.take_chat_input(), vec!["/yell hi".to_string()]);
assert!(
s.editbox_add_history("ChatFrameEditBox", "/yell hi"),
"the seam finds the shipped box by name after it closed"
// `ChatEdit_SendText` → `ChatEdit_ParseText` → the YELL type's send, and the reference's
// `AddHistoryLine` on the way.
let sends = s.take_chat_sends();
assert_eq!(
sends
.iter()
.map(|c| (c.text.as_str(), c.chat_type.as_str()))
.collect::<Vec<_>>(),
vec![("hi", "YELL")]
);
// Reopen (the app refocuses on the next Enter): Up recalls, Down restores the draft.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("dra");
s.editbox_action(EditAction::HistoryPrev);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
// `ChatEdit_AddHistory` filed `SLASH_YELL1 .. " " .. text` = "/y hi" (ChatFrame.lua
// l.1913-1937); the recall lands it in the box, and the box's own `OnTextChanged` live parse
// (`ChatEdit_ParseText(this, 0)`) turns it straight into the YELL type with "hi" — which is
// what the player sees: a "Yell:" header and the text.
assert!(
s.eval::<bool>(
"return ChatFrameEditBox.chatType == 'YELL' and ChatFrameEditBox:GetText() == 'hi'"
)
.unwrap(),
"Up recalls the filed line, live-parsed: {:?} {:?}",
s.eval::<String>("return ChatFrameEditBox.chatType")
.unwrap(),
"/yell hi",
"Up recalls the submitted line"
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap()
);
s.editbox_action(EditAction::HistoryNext);
assert_eq!(
@@ -238,19 +268,13 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
use benilla_ui::script::{
ContainerSlot, ContainerState, SpellBookState, SpellSlotView, SpellTabView,
};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The reference's spellbook wants the action-bar chain beneath it (1952); the chat window
// loads after it, as in the manifest.
let mut s = super::spellbook_tests::spellbook_ui(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"SpellBookFrame.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, f);
}
@@ -314,6 +338,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -353,11 +378,11 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
// ── ChatTypeInfo: the addon-facing color table ────────────────────────────────────────────────
/// `ChatTypeInfo` carries the shipped default chat colors twice: once in `assets/ui/ChatFrame.xml`
/// for addons to read, and once in [`crate::ui_chat::default_color`] for our own feed to render.
/// Both are the same wow-re byte table (`chat-color-table.md`, the static registry at
/// `.rdata 0x804710`) — so this is the gate that makes the duplication safe: every kind we model
/// must agree to the byte, and the table's shape (`sticky`/`id`) must be the reference's.
/// `ChatTypeInfo` carries the shipped default chat colors twice: once in
/// `Interface\FrameXML\ChatFrame.xml` for addons to read, and once in
/// [`crate::ui_chat::default_color`] for our own feed to render. Both are the same wow-re byte
/// table (`chat-color-table.md`, the static registry at `.rdata 0x804710`) — so this is the gate
/// that makes the duplication safe: every kind we model must agree to the byte, and the table's shape (`sticky`/`id`) must be the reference's.
#[test]
fn chat_type_info_matches_the_host_color_table() {
use crate::ui_chat::{default_color, ChatEventKind as K};
@@ -740,11 +765,9 @@ fn hovering_the_dock_still_reveals_the_tabs() {
s.resolve();
}
assert!(
s.eval::<f64>("return ChatFrame1Tab:GetAlpha()").unwrap() < 1e-6,
"the dock starts concealed"
!s.eval::<bool>("return ChatFrame1Tab:IsVisible()").unwrap(),
"the dock starts concealed — the tab template ships hidden"
);
// The dock's own centre, asked of the layout rather than hardcoded.
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
@@ -752,17 +775,15 @@ fn hovering_the_dock_still_reveals_the_tabs() {
)
.unwrap();
s.mouse_move(x, y);
// 0.2 s stationary to arm the reveal, then the 0.15 s ramp — 25 frames covers both with room.
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let alpha: f64 = s.eval("return ChatFrame1Tab:GetAlpha()").unwrap();
assert!(
(alpha - 1.0).abs() < 1e-6,
"a stationary hover reveals the selected tab at full alpha — got {alpha}"
s.eval::<bool>("return ChatFrame1Tab:IsVisible()").unwrap() && (alpha - 1.0).abs() < 1e-6,
"a stationary hover past CHAT_TAB_SHOW_DELAY reveals the selected tab at full alpha — got {alpha}"
);
}
@@ -785,8 +806,6 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
s.tick(0.016);
s.resolve();
}
// At the bottom, flash hidden: the sentinel must survive.
s.run("ChatFrame1.flashTimer = 0.42").unwrap();
for _ in 0..10 {
s.tick(0.016);
@@ -803,20 +822,17 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
.unwrap(),
"the flash stays hidden at rest"
);
// Control 1: scrolled up, the blink still runs — the timer accumulates past 0.5 and shows
// the flash (a point inside the docked frame, as the wheel test uses).
s.mouse_wheel(100.0, 150.0, 1.0);
s.tick(0.3); // 0.42 + 0.3 = 0.72 >= 0.5 -> toggle on
// The reference's ChatFrameTemplate takes no mouse and has no wheel script (1.12 scrolls its
// chat by the buttons), so the scroll is the frame's own verb.
s.run("ChatFrame1:ScrollUp()").unwrap();
s.tick(0.3); // 0.42 + 0.3 = 0.72 >= CHAT_BUTTON_FLASH_TIME -> toggle on
s.resolve();
assert!(
s.eval::<bool>("return ChatFrame1BottomButtonFlash:IsVisible()")
.unwrap(),
"scrolled up, the bottom-button blink still lights"
"scrolled up, the bottom-button blink lights"
);
// Control 2: back to the bottom while the flash is LIT — the transition still writes.
s.mouse_wheel(100.0, 150.0, -1.0);
s.run("ChatFrame1:ScrollToBottom()").unwrap();
s.tick(0.016);
s.resolve();
assert!(
@@ -824,10 +840,12 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
.unwrap(),
"returning to the bottom hides a lit flash"
);
assert_eq!(
s.eval::<f64>("return ChatFrame1.flashTimer").unwrap(),
0.0,
"and zeroes the timer with it (our reset rides inside the reference's gate)"
// …and leaves the residual phase where it was: the reference's at-bottom arm hides the flash
// and returns, it never zeroes the timer (ChatFrame.lua `ChatFrame_OnUpdate`).
let timer: f64 = s.eval("return ChatFrame1.flashTimer").unwrap();
assert!(
(timer - 0.22).abs() < 1e-6,
"the residual phase stands: {timer}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -849,7 +867,6 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
#[test]
fn selecting_the_combat_log_keeps_the_dock_driver_running() {
let mut s = chat_frame();
// Hover the dock and let the reveal settle: General at full, Combat Log at half.
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
@@ -857,7 +874,7 @@ fn selecting_the_combat_log_keeps_the_dock_driver_running() {
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
@@ -871,19 +888,16 @@ fn selecting_the_combat_log_keeps_the_dock_driver_running() {
),
"the hovered dock starts with General selected — this test must not pass vacuously"
);
// Click the Combat Log tab, then advance the clock WITHOUT touching FCF_OnUpdate.
s.run("BenillaFCF_TabClick(2)").unwrap();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
s.eval::<bool>("return ChatFrame2:IsShown() and not ChatFrame1:IsShown()")
.unwrap(),
"the click swapped the windows"
"the selection swapped the windows"
);
let (a1, a2): (f32, f32) = s
.eval("return ChatFrame1Tab:GetAlpha(), ChatFrame2Tab:GetAlpha()")
@@ -906,11 +920,11 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
)
.unwrap();
s.mouse_move(x, y);
for _ in 0..25 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
s.run("BenillaFCF_TabClick(2)").unwrap();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
@@ -922,17 +936,16 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
),
"revealed before the cursor leaves"
);
s.mouse_move(1500.0, 850.0); // away from the dock
for _ in 0..20 {
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a2: f32 = s.eval("return ChatFrame2Tab:GetAlpha()").unwrap();
// The reference fades the tab out and `FCF_ChatTabFadeFinished` hides it.
assert!(
a2 < 1e-6,
"the dock conceals itself again on leave — got Combat Log tab alpha {a2}"
!s.eval::<bool>("return ChatFrame2Tab:IsVisible()").unwrap(),
"the dock conceals itself again on leave"
);
}
@@ -943,7 +956,7 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
#[test]
fn the_combat_log_window_runs_its_own_bottom_button_blink() {
let mut s = chat_frame();
s.run("BenillaFCF_TabClick(2)").unwrap();
s.run("FCF_SelectDockFrame(ChatFrame2)").unwrap();
for i in 0..40 {
s.add_chat_message("ChatFrame2", &format!("line {i}"), 1.0, 1.0, 1.0);
}
@@ -965,3 +978,389 @@ fn the_combat_log_window_runs_its_own_bottom_button_blink() {
"window 2's bottom-button flash blinks while it is scrolled up"
);
}
/// **The chat menu runs on the REFERENCE's UIMenu kit** — `Interface\FrameXML\UIMenu.xml` and its
/// own `UIMenu.lua`, not a transcription of them.
///
/// Ours kept the reference's ten names in `ChatFrame.xml`, which loads at manifest 939 against the
/// chain's 194, so our copies overwrote the chain's from the day that entry landed — the reverse of
/// 1855's direction, and nothing drove this menu in a test, so nothing said so. Decision 1869.
#[test]
fn the_chat_menu_builds_its_rows_on_the_references_kit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\UIMenu.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, f);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
let _ = s.errors();
// `ChatMenu_OnLoad` calls `UIMenu_Initialize()` bare, relying on `this` — the reference's own
// idiom — then `UIMenu_AddButton` per row. If the kit were missing, the rows would not exist.
s.run("ChatMenu:Show()").unwrap();
assert!(s.errors().is_empty(), "opening it raises: {:?}", s.errors());
assert!(
s.eval::<i64>("return ChatMenu.numButtons or 0").unwrap() >= 7,
"the reference's `UIMenu_AddButton` counted the rows this window adds"
);
assert_eq!(
s.eval::<String>("return ChatMenuButton1:GetText()")
.unwrap(),
"Say",
// The reference labels a row with `button:SetText(text)` — the Button's own text, not a
// named FontString, so there is no `…Button1Text` to read.
"row 1 is the Say row, built by the chain's kit"
);
// A row click runs the owner's func and closes the menu — `UIMenuButton_OnClick`'s contract.
s.run("ChatMenuButton1:Click()").unwrap();
assert!(
s.errors().is_empty(),
"a row click raises: {:?}",
s.errors()
);
assert!(
!s.eval::<bool>("return ChatMenu:IsShown() and true or false")
.unwrap(),
"the reference's row click hides the menu"
);
// `ChatMenu_Say` → `ChatMenu_SetChatType(chatFrame, "SAY")`: the box opens and its type is
// set directly — no slash is typed into it (ChatFrame.lua l.2245-2255).
assert!(
s.eval::<bool>(
"return ChatFrameEditBox:IsVisible() and ChatFrameEditBox.chatType == 'SAY'"
)
.unwrap(),
"and it opened the edit box as SAY"
);
}
/// **A glass window's plate fades in on every hover, not only the first.** A restored window
/// (the cache's `COLOR 0 0 0 0`, the stock row) keeps its nine plate textures at alpha 0 and
/// relies on `FCF_OnUpdate`'s hover arm to lift them to `DEFAULT_CHATFRAME_ALPHA` and drop them
/// back (FloatingChatFrame.lua l.873-877, l.913-916). The director's report (2026-09-05): hovering
/// the chat window shows the tabs and the buttons but no plate.
#[test]
fn a_glass_windows_plate_fades_in_on_every_hover() {
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
let bg =
|s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() };
assert_eq!(bg(&mut s), 0.0, "a glass window rests at alpha 0");
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
for round in 1..=3 {
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a = bg(&mut s);
assert!(
(a - 0.25).abs() < 1e-6,
"hover {round}: the plate fades to DEFAULT_CHATFRAME_ALPHA — got {a} (oldAlpha={:?})",
s.eval::<Option<f64>>("return ChatFrame1.oldAlpha").unwrap()
);
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a = bg(&mut s);
assert!(
a.abs() < 1e-6,
"leave {round}: the plate fades back to its saved alpha — got {a}"
);
}
}
/// The same law under the WHOLE shipped manifest, driven the way the app drives it.
#[test]
fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "{failures:?}");
s.resolve();
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
super::fire_chat_login(&mut s);
s.resolve();
let bg =
|s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() };
assert_eq!(bg(&mut s), 0.0, "a glass window rests at alpha 0");
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
for round in 1..=3 {
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The plate as the renderer receives it: one Background quad over ChatFrame1's rect at
// the faded alpha, black.
let plates: Vec<(Option<benilla_ui::layout::Rect>, f32, Option<[f32; 4]>)> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p.to_ascii_lowercase().contains("chatframebackground") => {
Some((q.rect, q.alpha, *color))
}
_ => None,
})
.collect();
let (left, bottom): (f32, f32) = s
.eval("return ChatFrame1:GetLeft(), ChatFrame1:GetBottom()")
.unwrap();
// The texture's own anchors: TOPLEFT (-2, 3) and BOTTOMLEFT (-2, -6) off the frame.
let over_frame1 = plates.iter().find(|(r, _, _)| {
r.is_some_and(|r| {
(r.left - (left - 2.0)).abs() < 1.0 && (r.bottom - (bottom - 6.0)).abs() < 1.0
})
});
assert!(
over_frame1.is_some_and(|(_, a, c)| {
(a - 0.25).abs() < 1e-3 && c.is_some_and(|c| c[0] == 0.0 && c[1] == 0.0 && c[2] == 0.0)
}),
"hover {round}: the extracted plate quad over ChatFrame1 ({left}, {bottom}) — {plates:?}"
);
let a = bg(&mut s);
assert!(
(a - 0.25).abs() < 1e-6,
"hover {round}: the plate fades to DEFAULT_CHATFRAME_ALPHA — got {a} (oldAlpha={:?}, hover={:?}, hasBeenFaded={:?}, init={:?})",
s.eval::<Option<f64>>("return ChatFrame1.oldAlpha").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.hover").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.hasBeenFaded").unwrap(),
s.eval::<Option<f64>>("return ChatFrame1.init").unwrap(),
);
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
let a = bg(&mut s);
assert!(
a.abs() < 1e-6,
"leave {round}: the plate fades back to its saved alpha — got {a}"
);
}
}
/// **The plate comes back after a quick exit and re-entry** (decision 1998 — the director's
/// report: an existing window shows tabs but no plate on hover; a new one shows the plate; the
/// tab menu's opacity slider, used once, makes the hover work again).
///
/// The stock `FCF_OnUpdate` traps itself: leave the window (the tabs' fade-out is queued with
/// `FCF_ChatTabFadeFinished` as its finished callback), re-enter inside `CHAT_FRAME_FADE_TIME`,
/// and the callback fires `chatFrame.oldAlpha = nil` under the live hover — after which the
/// plate arm (`oldAlpha < DEFAULT_CHATFRAME_ALPHA`) never passes and no leave clears `hover`, so
/// the hover-start re-read never runs either. Two VMs prove the guard is what makes the
/// difference: the bare chat stack (the reference's Lua alone) traps, the shipped manifest (the
/// guard installed by `bootstrap_positions`) does not. The control keeps this from passing
/// vacuously — if the stock Lua or the engine ever stop trapping, it says so, and the guard is
/// then a repair of nothing.
#[test]
fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
fn drive(s: &mut UiScript) -> (f64, f64, f64) {
let (x, y): (f32, f32) = s
.eval(
"return (ChatFrame1:GetLeft() + ChatFrame1:GetRight()) / 2, \
(ChatFrame1:GetBottom() + ChatFrame1:GetTop()) / 2",
)
.unwrap();
let bg =
|s: &mut UiScript| -> f64 { s.eval("return ChatFrame1Background:GetAlpha()").unwrap() };
s.mouse_move(1500.0, 850.0);
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
// A stationary hover past CHAT_TAB_SHOW_DELAY and the ramp: the plate is up.
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let first = bg(s);
// Out for ~50 ms — inside the 0.15 s fade-out — and back, then stationary.
s.mouse_move(1500.0, 850.0);
for _ in 0..3 {
s.tick(0.016);
s.resolve();
}
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let reentry = bg(s);
// Away for a full second, then a fresh hover: whatever the re-entry left behind stays.
s.mouse_move(1500.0, 850.0);
for _ in 0..60 {
s.tick(0.016);
s.resolve();
}
s.mouse_move(x, y);
for _ in 0..45 {
s.tick(0.016);
s.resolve();
}
let later = bg(s);
assert!(s.errors().is_empty(), "{:?}", s.errors());
(first, reentry, later)
}
// The control: the reference's Lua alone.
let mut bare = chat_frame();
let (first, reentry, later) = drive(&mut bare);
assert!((first - 0.25).abs() < 1e-6, "control, first hover: {first}");
assert!(
reentry.abs() < 1e-6 && later.abs() < 1e-6,
"the control must trap — the reference's Lua nils oldAlpha under a live hover \
(re-entry {reentry}, later {later}); if it no longer does, the guard is a repair of nothing"
);
assert!(
bare.eval::<bool>("return ChatFrame1Tab:IsVisible() and ChatFrame1.oldAlpha == nil and ChatFrame1.hover == 1")
.unwrap(),
"the trapped state the director described: tab up, oldAlpha nil, hover stuck"
);
// The shipped interface, with the guard the manifest load installs.
let mut shipped = UiScript::new().unwrap();
shipped.set_screen_size(1600.0, 900.0);
super::test_ui::load_ui(&shipped, "Interface\\FrameXML\\GlobalStrings.lua");
let failures = super::load_default_ui(&shipped);
assert!(failures.is_empty(), "{failures:?}");
shipped.resolve();
super::fire_chat_login(&mut shipped);
shipped.resolve();
let (first, reentry, later) = drive(&mut shipped);
assert!((first - 0.25).abs() < 1e-6, "shipped, first hover: {first}");
assert!(
(reentry - 0.25).abs() < 1e-6,
"shipped: the plate comes back on the quick re-entry — got {reentry}"
);
assert!(
(later - 0.25).abs() < 1e-6,
"shipped: and on every hover after — got {later}"
);
}
/// **`/afk` and `/dnd`, through the reference's own parser and out the far side as a SEND.**
///
/// The director's report: typing either printed `Unknown chat type "AFK".` and nothing happened —
/// no away flag, so no `<AFK>` over the head and no `<AFK>` on their chat lines, because both of
/// those surfaces were already built and were waiting on a flag that never got set.
///
/// The break was a seam, which is why this test spans it rather than testing either side. benilla's
/// own slash grammar has always had a working `/afk` (`ui_chat::input::parse`'s `S::ChatAfk`), but
/// migrating the chat window (1948) put the stock `ChatFrame.lua` on the chain and **its** parser
/// claims a slash line first — so the live path became
/// `ChatEdit_ParseText` → `SlashCmdList["CHAT_AFK"](msg)` → `SendChatMessage(msg, "AFK")` →
/// `SendType::from_token`, which answered `None` because a doc comment there asserted AFK/DND
/// "set a flag rather than sending a line". They do not: they are `CMSG_MESSAGECHAT` `0x14`/`0x15`
/// and the SERVER toggles the bit. Decision 2082.
///
/// So the assertion runs the two halves in series: the stock file really does emit the token, and
/// the token really does resolve to a wire kind. Testing either alone passes while the seam is
/// broken — which is exactly how this shipped.
#[test]
fn the_stock_afk_and_dnd_commands_reach_the_wire() {
use crate::net::ChatKind;
use crate::ui_chat::edit::SendType;
let typed = |line: &str| -> (String, String) {
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input(line);
assert!(s.key_input("ENTER"), "the box consumes ENTER");
let sends = s.take_chat_sends();
assert_eq!(sends.len(), 1, "{line:?} produced one send");
(sends[0].text.clone(), sends[0].chat_type.clone())
};
// Half one — the stock `SlashCmdList` body, verbatim: `SendChatMessage(msg, "AFK")`.
let (text, token) = typed("/afk Away from Keyboard");
assert_eq!(
(text.as_str(), token.as_str()),
("Away from Keyboard", "AFK")
);
// Half two — the seam that was cut. `None` here is the reported bug.
assert_eq!(
SendType::from_token(&token).map(SendType::wire),
Some(ChatKind::Afk),
"the token the stock file sends must resolve to a wire kind"
);
let (text, token) = typed("/dnd Do not Disturb");
assert_eq!((text.as_str(), token.as_str()), ("Do not Disturb", "DND"));
assert_eq!(
SendType::from_token(&token).map(SendType::wire),
Some(ChatKind::Dnd)
);
// **A BARE `/afk` still sends** — with an empty body, which is the toggle. The stock body
// passes `msg` straight through, and vmangos's `CHAT_MSG_AFK` arm reads an empty message as
// "toggle" and a non-empty one as "set, and store this as the auto-reply".
let (text, token) = typed("/afk");
assert_eq!((text.as_str(), token.as_str()), ("", "AFK"));
assert_eq!(
SendType::from_token(&token).map(SendType::wire),
Some(ChatKind::Afk),
"the bare toggle is a send too, not a no-op"
);
// The control: a token the reference has no send for still answers None, so the arms above
// are two specific additions and not a blanket "anything goes" that would let a typo through.
assert!(SendType::from_token("NOT_A_CHAT_TYPE").is_none());
assert!(
SendType::from_token("TEXT_EMOTE").is_none(),
"a real ChatTypeInfo key that is nonetheless not sendable stays None"
);
}
@@ -22,12 +22,16 @@ use super::test_ui::load_ui as load_xml;
fn ui_with_the_cinematic_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "MoneyFrame.xml"); // StaticPopup's money row, or UiPanels errors at load
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua"); // StaticPopup's money row, or UiPanels errors at load
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\CinematicFrame.xml");
s.resolve();
s
@@ -1,5 +1,5 @@
//! The shipped `assets/ui/ColorPickerFrame.xml` + the dropdown's colour-swatch row, driven the way
//! the corpus drives them.
//! The stock `Interface\FrameXML\ColorPickerFrame.xml` + the dropdown's colour-swatch row, driven
//! the way the corpus drives them.
//!
//! Nothing benilla ships opens this window. Its consumers are third-party addons — 86 of the 218
//! reach `ColorPickerFrame`, and 64 of those files are copies of `Dewdrop-2.0.lua`, THE Ace2 menu
@@ -17,15 +17,19 @@ use super::test_ui::load_ui as load_xml;
fn picker() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\ColorPickerFrame.xml",
] {
load_xml(&s, file);
@@ -298,7 +302,13 @@ fn cancel_restores_the_previous_colour_through_cancel_func() {
.eval("local t = applied[table.getn(applied)] return t.r, t.a")
.unwrap();
assert_eq!(mid_r, 1.0, "the live preview really did go red");
assert!((mid_a - 0.95).abs() < 1e-9);
// 1e-6, like the accept test's alpha above and for one more reason on top of the f32 store:
// `OpacitySliderFrame` carries `valueStep="0.01"`, so `SetValue` rebuilds the value as
// `n·step + min` (2133) and the reconstruction costs a few more ulps than the literal.
assert!(
(mid_a - 0.95).abs() < 1e-6,
"alpha is 1 - the slider's 0.05, got {mid_a}"
);
// …then backs out.
s.run("ColorPickerCancelButton:Click()").unwrap();
@@ -322,7 +332,7 @@ fn cancel_restores_the_previous_colour_through_cancel_func() {
/// the rung cancel-CLICKS rather than hiding — so the colour comes back and the game menu does not
/// open behind it.
#[test]
fn escape_cancels_rather_than_merely_hiding() {
fn escape_hides_the_picker_and_cancel_is_what_reverts() {
let _data = benilla_formats::wow_data_or_skip!();
let s = picker();
dewdrop_open(&s, 0.1, 0.5, 0.9, 0.25);
@@ -333,14 +343,26 @@ fn escape_cancels_rather_than_merely_hiding() {
assert!(!s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
// **The reference's ESC is a bare hide, and the previewed colour stands.** `ColorPickerFrame`
// is a `UISpecialFrames` row (`UIParent.lua:52-55`), so the ladder's `CloseAllWindows` hides
// it — the stock file runs `cancelFunc` from the Cancel button's own OnClick and from nowhere
// else, and its OnHide has no such arm. Our retired ladder clicked Cancel for the player;
// that arm went with the file (1988).
let (r, g, b): (f64, f64, f64) = s
.eval("local t = applied[table.getn(applied)] return t.r, t.g, t.b")
.unwrap();
assert_eq!(
(r, g, b),
(0.1, 0.5, 0.9),
"ESC ran cancelFunc, not a bare Hide"
(1.0, 0.0, 0.0),
"ESC hid the picker and left the previewed colour applied"
);
// The Cancel button is still what reverts it, which is the reference's whole cancel path.
s.run("ColorPickerFrame:Show() ColorPickerCancelButton:Click()")
.unwrap();
let (r, g, b): (f64, f64, f64) = s
.eval("local t = applied[table.getn(applied)] return t.r, t.g, t.b")
.unwrap();
assert_eq!((r, g, b), (0.1, 0.5, 0.9), "Cancel runs cancelFunc");
}
/// `AceConsole-2.0.lua` l.1402-1406, verbatim in shape: fetch the Okay button's own handler, replace
@@ -18,11 +18,35 @@ use super::test_ui::load_ui as load_xml;
fn load_combat_text() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "CombatText.xml");
s.run("SHOW_COMBAT_TEXT = \"1\"; CombatText_UpdateDisplayedMessages()")
.unwrap();
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua"); // ENTERING_COMBAT & co.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); // TEXT()
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
// The reference's own addon, LoadOnDemand off the chain, loaded the way the options window
// loads it (1964).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_CombatText");
// The family's saved-variable defaults are the options window's file-scope block (the
// reference's UIOptionsFrame.lua l.135-152, carried by our OptionsFrame.xml since 1964); a
// harness without that window seats the same block by hand, the master switched on.
s.run(
r#"SHOW_COMBAT_TEXT = "1"
COMBAT_TEXT_SHOW_LOW_HEALTH_MANA = "1"
COMBAT_TEXT_SHOW_AURAS = "1"
COMBAT_TEXT_SHOW_AURA_FADE = "0"
COMBAT_TEXT_SHOW_COMBAT_STATE = "1"
COMBAT_TEXT_SHOW_DODGE_PARRY_MISS = "0"
COMBAT_TEXT_SHOW_RESISTANCES = "0"
COMBAT_TEXT_SHOW_REPUTATION = "0"
COMBAT_TEXT_SHOW_REACTIVES = "0"
COMBAT_TEXT_SHOW_FRIENDLY_NAMES = "0"
COMBAT_TEXT_SHOW_COMBO_POINTS = "0"
COMBAT_TEXT_SHOW_MANA = "0"
COMBAT_TEXT_FLOAT_MODE = "1"
COMBAT_TEXT_SHOW_HONOR_GAINED = "1"
UIParentLoadAddOn("Blizzard_CombatText")
CombatText_UpdateDisplayedMessages()"#,
)
.unwrap();
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
@@ -33,6 +57,7 @@ fn load_combat_text() -> UiScript {
/// shows nothing.
#[test]
fn combat_text_damage_scrolls_and_expires() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combat_text();
// All 20 pool strings start hidden.
@@ -113,6 +138,7 @@ fn combat_text_damage_scrolls_and_expires() {
/// scrolls away from the seat).
#[test]
fn combat_text_crit_pops_and_parks() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combat_text();
s.fire_event(
"COMBAT_TEXT_UPDATE",
@@ -163,6 +189,7 @@ fn extracted_text_height(s: &mut UiScript, text: &str) -> Option<f32> {
/// paints "Health Low" once and re-arms only after recovering above the threshold.
#[test]
fn combat_text_state_and_low_health_triggers() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combat_text();
s.fire_event("PLAYER_REGEN_DISABLED", vec![]);
let ok: bool = s
@@ -231,6 +258,7 @@ fn combat_text_state_and_low_health_triggers() {
/// so the ref constants stand verbatim.
#[test]
fn combat_text_crit_peak_is_uncapped() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combat_text();
let ok: bool = s
.eval("return COMBAT_TEXT_LOCATIONS.startY == 384 and COMBAT_TEXT_LOCATIONS.endY == 609")
@@ -263,6 +291,7 @@ fn combat_text_crit_peak_is_uncapped() {
/// something would otherwise have.
#[test]
fn combat_text_master_toggle_unregisters() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combat_text();
s.fire_event(
"COMBAT_TEXT_UPDATE",
@@ -281,6 +310,10 @@ fn combat_text_master_toggle_unregisters() {
s.run("SHOW_COMBAT_TEXT = \"0\"; CombatText_UpdateDisplayedMessages()")
.unwrap();
s.run("CombatText_ClearAnimationList()").unwrap();
// The stock `CombatText_ClearAnimationList` hides the strings and leaves the list to the
// ticker (our transcription had emptied it): "nothing paints" is the list not growing and no
// string on screen.
let before: i64 = s.eval("return getn(COMBAT_TEXT_TO_ANIMATE)").unwrap();
s.fire_event(
"COMBAT_TEXT_UPDATE",
vec![
@@ -289,8 +322,48 @@ fn combat_text_master_toggle_unregisters() {
],
);
let none: bool = s
.eval("return getn(COMBAT_TEXT_TO_ANIMATE) == 0 and CombatText1:IsShown() == nil")
.eval(&format!(
"return getn(COMBAT_TEXT_TO_ANIMATE) == {before} and CombatText1:IsShown() == nil"
))
.unwrap();
assert!(none, "disabled: nothing paints ({:?})", s.errors());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`DAMAGE_TEXT_FONT` is a Lua global the engine reads, not a hardcoded face** (decision 2156).
///
/// `0x6c847c` is the only instruction in `WoW.exe` that reads it, through `FrameScript_GetText`'s
/// fast arm into a plain `lua_gettable(LUA_GLOBALSINDEX)` — so whatever is in the global when the
/// world-entry load edge closes is what the numbers draw in. Stock `Fonts.xml` puts Friz there;
/// MikScrollingBattleText and pfUI put their own face there at `ADDON_LOADED`, which the reference
/// reads because `0x6c8470` runs *after* the addon load, not at CRT init as three wow-re notes
/// had it.
///
/// Skips without client data.
#[test]
fn the_damage_text_font_is_read_from_the_lua_global() {
let data = benilla_formats::wow_data_or_skip!();
let _ = data;
let s = UiScript::new().unwrap();
// Before anything assigns it, there is nothing to bind — the reference's `0x704350` failure
// block leaves `0x703bf0`'s pre-seeded `""`, and an empty name is what the font factory's own
// guard rejects.
assert_eq!(crate::combat_text::read_damage_text_font(&s).0, None);
// Stock FrameXML, off the player's own chain: `Fonts.xml:6`.
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
assert_eq!(
crate::combat_text::read_damage_text_font(&s).0.as_deref(),
Some("Fonts\\FRIZQT__.TTF"),
);
// An addon assigning it at ADDON_LOADED — MikScrollingBattleText's line 255, in shape.
s.run(r#"DAMAGE_TEXT_FONT = "Interface\\AddOns\\MSBT\\Fonts\\Adventure.ttf""#)
.expect("the assignment runs");
assert_eq!(
crate::combat_text::read_damage_text_font(&s).0.as_deref(),
Some("Interface\\AddOns\\MSBT\\Fonts\\Adventure.ttf"),
"the addon's face, not Friz"
);
}
@@ -18,15 +18,21 @@ use super::test_ui::load_ui as load_xml;
fn load_combo_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
+1 -1
View File
@@ -44,7 +44,7 @@ static UI: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR
/// The text of one shipped UI file, by path relative to `assets/ui` — `None` if we do not ship it.
///
/// `req` may arrive in Blizzard's backslash form and may name a directory it does not have; the
/// basename fallback is [`super::load_ui_files`]'s and lives at the call site, not here, because
/// basename fallback is `super::load_ui_files`'s and lives at the call site, not here, because
/// it is a property of FrameXML references rather than of the content store.
pub(super) fn read(req: &str) -> Option<String> {
// 1 · the source tree, dev builds only — so editing a FrameXML file needs no recompile.
+23 -13
View File
@@ -10,12 +10,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "DeathFrame.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s
}
@@ -38,7 +42,7 @@ fn death_popup_counts_down_and_release_queues_repop() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"5 minutes until release",
"5 Minutes until release",
"the countdown renders through the engine's DEATH per-tick text"
);
// ESC must NOT close it (no hideOnEscape on DEATH — the ref's law).
@@ -249,7 +253,7 @@ fn resurrect_request_picks_variant_and_answers() {
fn xp_loss_two_step_confirm_then_range_hide() {
let mut s = setup();
s.set_death(DeathUiState {
sickness_duration: Some("8 minutes".into()),
sickness_duration: Some("8 Minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
@@ -262,7 +266,7 @@ fn xp_loss_two_step_confirm_then_range_hide() {
.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap();
assert!(
text.contains("afflicted by 8 minutes of Resurrection Sickness"),
text.contains("afflicted by 8 Minutes of Resurrection Sickness"),
"the sickness duration formats into CONFIRM_XP_LOSS: {text}"
);
// First Accept: the AGAIN text swaps in, the dialog stays, nothing queues. XP_LOSS's verbatim
@@ -318,7 +322,7 @@ fn xp_loss_two_step_confirm_then_range_hide() {
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()),
sickness_duration: Some("8 Minutes".into()),
spirit_healer_in_range: true,
..Default::default()
});
@@ -381,11 +385,17 @@ fn the_ghost_predicates() {
..Default::default()
}),
);
assert!(!s.eval::<bool>("return UnitIsDead(\"player\")").unwrap());
assert!(s.eval::<bool>("return UnitIsGhost(\"player\")").unwrap());
// The trio's shape is 1/nil, never a boolean (decision 2043), so these read the value rather
// than its truthiness — `== nil` is the comparison a boolean would invert.
assert!(s
.eval::<bool>("return UnitIsDeadOrGhost(\"player\")")
.eval::<bool>("return UnitIsDead(\"player\") == nil")
.unwrap());
assert_eq!(s.eval::<i64>("return UnitIsGhost(\"player\")").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return UnitIsDeadOrGhost(\"player\")")
.unwrap(),
1
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -414,7 +424,7 @@ fn corpse_range_events_drive_recover_corpse() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"2 seconds until resurrection"
"2 Seconds until resurrection"
);
s.tick(1.6);
assert_eq!(
@@ -460,7 +470,7 @@ fn corpse_map_position_binding() {
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
assert_eq!((x, y), (0.0, 0.0), "no corpse ⇒ the (0,0) hide sentinel");
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new());
s.set_world_map_feed(None, None, 0.0, Some((0.25, 0.75)), Vec::new(), Vec::new());
let (x, y) = s
.eval::<(f64, f64)>("return GetCorpseMapPosition()")
.unwrap();
@@ -12,7 +12,9 @@
use benilla_ui::script::{ContainerSlot, ContainerState, UiScript};
use super::test_ui::{bag_open, bag_slot_button, load_ui as load_xml, BAG_UI};
use super::test_ui::{
bag_open, bag_slot_button, load_ui as load_xml, load_world_frame, world_click, BAG_UI,
};
/// A one-item, one-slot backpack holding `item_id`/`name` at `quality`, so the confirm text and
/// the wire's destroy count are exercisable end to end. Quality is what forks the driver, so it is
@@ -22,6 +24,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -55,11 +58,30 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
// The stock DELETE_GOOD_ITEM's OnHide hands focus back to `ChatFrameEditBox` (1960).
load_xml(&s, r"Interface\FrameXML\UIMenu.xml");
load_xml(&s, r"Interface\FrameXML\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
load_xml(&s, r"Interface\FrameXML\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, r"Interface\FrameXML\FloatingChatFrame.xml"); // declares ChatFrameEditBox
// **The world frame, because the world drop is what raises this popup** (B380, decision
// 2089). Every test here used to click at `(-50, -50)` — off-screen, over nothing — which is
// a world click only in a house where no `WorldFrame` is loaded. The real client's is
// full-screen and mouse-enabled, and it swallowed every one of these clicks for three days
// while all ten tests stayed green. `world_click` lands on the world now, and this is the
// file that has to be there for there to be one.
load_world_frame(&s);
s.set_money(0);
s
}
@@ -73,15 +95,27 @@ fn bag_setup() -> UiScript {
for file in BAG_UI {
load_xml(&s, file);
}
// `ContainerFrame_Update` reads `MerchantFrame:IsShown()` on any slot the tooltip owns, and
// `BankFrame` (BAG_UI's last entry) fills its purse through `BenillaMoney_Set`.
// `ContainerFrame_Update` reads `MerchantFrame:IsShown()` on any slot the tooltip owns.
// `BankFrame`'s purse is an ordinary `SmallMoneyFrameTemplate` off the chain's own
// `MoneyFrame.xml` (1937/1962), so it is not a merchant dependency.
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_world_frame(&s); // [`setup`]'s reason, same fixture
// …and the paper doll goes back where the reference keeps it. [`BAG_UI`] leaves
// `CharacterFrame.xml` out on purpose (its own note: a window no bag test opens), so the
// frame `PaperDollFrame.xml` declares has no hidden parent to sit inside and stands on screen
// instead — it and its model child covering the world the drop has to be clicked on. The real
// client never shows it there; hiding it restores its state, it does not dodge the fixture's.
s.run("PaperDollFrame:Hide()")
.expect("the paper doll the missing CharacterFrame would have hidden");
s.set_money(0);
s
}
/// Pick up the fixture item and click-carry it into the world — a completed LEFT CLICK (press +
/// release, both over nothing; 0218's byte-verified trigger) fires the world-drop
/// release, both on the world frame; 0218's byte-verified trigger) fires the world-drop
/// `DELETE_ITEM_CONFIRM(name, quality)` the driver listens for.
fn pick_up_and_drop_in_world(s: &mut UiScript) {
drop_in_world(s, 117, "Tough Jerky", 1);
@@ -93,12 +127,7 @@ fn drop_in_world(s: &mut UiScript, item_id: u32, name: &str, quality: u32) {
s.set_container(0, Some(one_item_backpack(item_id, name, quality)));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
// Off past every frame (the bag sits bottom-right; off-screen negative is always clear).
s.mouse_button(-50.0, -50.0, "LeftButton", true);
assert!(
s.mouse_button(-50.0, -50.0, "LeftButton", false),
"a world drop consumes the completed click"
);
world_click(s);
s.tick(0.01); // flush the queued DELETE_ITEM_CONFIRM into the driver's OnEvent
}
@@ -174,10 +203,13 @@ fn delete_item_confirm_no_clears_without_destroying() {
);
// Count repaints from here — the No-click path must trigger one via the event, not a click.
// 5.0's vararg spelling: the implicit `arg` table forwarded with `unpack`, because `...` as a
// VALUE is not in this VM's grammar — it is not in the 1.12 client's (decision 2101).
s.run(
"repaints = 0\n\
local real = ContainerFrame_Update\n\
ContainerFrame_Update = function(...) repaints = repaints + 1; return real(...) end",
ContainerFrame_Update = function(...) repaints = repaints + 1; \
return real(unpack(arg, 1, arg.n)) end",
)
.unwrap();
@@ -303,9 +335,9 @@ fn a_rare_payload_raises_the_typed_confirm_with_okay_disabled() {
.unwrap(),
"hasEditBox raises the narrow box"
);
assert!(
s.eval::<bool>("return StaticPopup1EditBox:HasFocus()")
.unwrap(),
assert_eq!(
s.focused_editbox_name().as_deref(),
Some("StaticPopup1EditBox"),
"the entry's OnShow focuses the box, so the player can type straight away"
);
assert_eq!(
@@ -436,25 +468,32 @@ fn no_on_the_typed_confirm_clears_and_leaves_the_box_empty_for_next_time() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// ESC out of the focused box: 1743's named divergence. The reference's `DELETE_GOOD_ITEM` names
/// no `EditBoxOnEscapePressed`, so in 1.12 the focused box swallows the key and the destroy
/// confirm cannot be dismissed with ESC at all. benilla's engine falls back to the ordinary
/// hideOnEscape leg — the entry's `OnCancel` (`ClearCursor()`), then hide — so the item is
/// released rather than left held under a dialog that will not close.
/// ESC inside the typed confirm's focused box is SWALLOWED, the reference's own behaviour (1960):
/// the stock `DELETE_GOOD_ITEM` entry defines no `EditBoxOnEscapePressed`, so the box's
/// `StaticPopup_EditBoxOnEscapePressed` finds nothing to call and the dialog stays up with the
/// item still on the cursor — the player answers with No. (ADD_FRIEND and its kin hide on ESC
/// because their entries say so; this one does not.) Our engine used to fall through to a
/// cancel here, which the earlier form of this test pinned.
#[test]
fn escape_out_of_the_typed_confirm_cancels_the_way_every_other_popup_does() {
fn escape_in_the_typed_confirms_box_is_swallowed_as_the_reference_leaves_it() {
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
assert!(s
.eval::<bool>("return StaticPopup1EditBox:HasFocus()")
.unwrap());
assert_eq!(
s.focused_editbox_name().as_deref(),
Some("StaticPopup1EditBox")
);
assert!(s.key_input("ESCAPE"), "the focused box consumes ESCAPE");
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"ESC closes it"
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the dialog stays: the entry names no escape handler"
);
assert!(s.cursor_item().is_none(), "and runs OnCancel's ClearCursor");
assert_eq!(
s.focused_editbox_name().as_deref(),
Some("StaticPopup1EditBox"),
"and the box keeps focus"
);
assert!(s.cursor_item().is_some(), "the item is still on the cursor");
assert!(s.take_container_destroys().is_empty(), "ESC never destroys");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -483,3 +522,41 @@ fn the_plain_arm_shows_no_edit_box() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The world drop, under the WHOLE shipped interface** — B380's own test (decision 2089).
///
/// The nine above build a fixture out of the files the popup needs, which is what let B380 hide:
/// a fixture is a subset of the manifest, and the file that broke this one was a file no fixture
/// had a reason to name. This one takes the manifest entire — the same load the player gets,
/// world frame and action bars and chat and all — picks an item up off the bag and clicks the
/// ground. It is the slowest test in the file and the only one that could not have been fooled.
#[test]
fn the_world_drop_survives_the_whole_shipped_interface() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.set_money(0);
s.set_container(0, Some(one_item_backpack(117, "Tough Jerky", 1)));
s.resolve();
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "fixture: the item is held");
world_click(&mut s);
s.tick(0.01);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"clicking the ground with an item held raises the destroy confirm"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Do you want to destroy Tough Jerky?"
);
// …and Yes destroys it, through the stock popup's own OnAccept.
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(s.cursor_item().is_none(), "the cursor is empty after Yes");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -34,13 +34,26 @@ const JERKY_LINK: &str = "|cffffffff|Hitem:117|h[Tough Jerky]|h|r";
/// with these, in manifest order.
const ROOM_UI: &[&str] = &[
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\ItemRef.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\StackSplitFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
// The reference's own room (1969): its Close/Reset buttons inherit the panel kit's templates,
// which resolve at load — so after it, as the manifest has it.
"Interface\\FrameXML\\DressUpFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\FloatingChatFrame.xml",
];
/// The shipped files the dressing room's click sites need, in manifest order — **with no bag
@@ -49,14 +62,18 @@ const ROOM_UI: &[&str] = &[
/// cannot.
fn load_room(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\Cooldown.xml",
] {
load_xml(s, file);
}
@@ -73,7 +90,7 @@ fn load_room(s: &UiScript) {
/// **Needs client data**: `BAG_UI` names a chain entry, so its callers open with
/// `wow_data_or_skip!`.
fn load_room_with_bags(s: &UiScript) {
load_xml(s, "UIParent.xml");
load_xml(s, r"Interface\FrameXML\UIParent.xml");
for file in BAG_UI {
load_xml(s, file);
}
@@ -99,7 +116,20 @@ fn shown_paper_doll() -> UiScript {
for file in CHARACTER_UI {
super::test_ui::load_ui_strict(&s, file);
}
for file in ["DressUpFrame.xml", "ChatFrame.xml"] {
for file in [
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
// The reference's room (1969), after the panel kit its buttons inherit from.
"Interface\\FrameXML\\DressUpFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
super::test_ui::load_ui_strict(&s, file);
}
s.set_unit(
@@ -390,9 +420,9 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
let _ = s.take_dressup_intents();
s.resolve();
// The pane's OnLoad seeded the ref's default facing.
// The pane's OnLoad (`Model_OnLoad`) seeded the ref's default facing on the widget itself.
assert!(
(s.dressup_yaw() - 0.61).abs() < 1e-6,
(s.model_pane_facing("DressUpModel") - 0.61).abs() < 1e-6,
"ref UIParent.lua:1422"
);
@@ -405,29 +435,28 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
);
// One tap of rotate-left: OnClick on press AND release, −0.03 each.
let before = s.dressup_yaw();
let before = s.model_pane_facing("DressUpModel");
let (x, y) = s
.eval::<(f32, f32)>(
"return (DressUpModelFrameRotateLeftButton:GetLeft() \
+ DressUpModelFrameRotateLeftButton:GetRight()) / 2, \
(DressUpModelFrameRotateLeftButton:GetTop() \
+ DressUpModelFrameRotateLeftButton:GetBottom()) / 2",
"return (DressUpModelRotateLeftButton:GetLeft() \
+ DressUpModelRotateLeftButton:GetRight()) / 2, \
(DressUpModelRotateLeftButton:GetTop() \
+ DressUpModelRotateLeftButton:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
(s.dressup_yaw() - (before - 0.06)).abs() < 1e-5,
(s.model_pane_facing("DressUpModel") - (before - 0.06)).abs() < 1e-5,
"a tap fires OnClick twice: {} → {}",
before,
s.dressup_yaw()
s.model_pane_facing("DressUpModel")
);
s.run("HideUIPanel(DressUpFrame)").unwrap();
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Close],
"closing the window empties the booth"
);
// Closing the window is what empties the booth, and the app reads it off the frame — the
// stock file's OnHide plays its sound and queues nothing (1969).
assert!(!s.frame_visible("DressUpFrame"), "the room is hidden");
assert!(s.take_dressup_intents().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -20,9 +20,11 @@ use super::test_ui::load_ui as load_xml;
/// which GameTooltip.xml declares.
fn load_dropdown_kit(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
] {
load_xml(s, file);
@@ -10,12 +10,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "DuelFrame.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s
}
@@ -77,7 +81,7 @@ fn out_of_bounds_counts_down_and_inbounds_dismisses_it() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Exiting duel area, you will forfeit in 10 seconds.",
"Exiting duel area, you will forfeit in 10 Seconds.",
"the engine's countdown branch fills %d %s"
);
s.fire_event("DUEL_INBOUNDS", vec![]);
@@ -15,14 +15,20 @@ fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"MinimapCluster.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
// The reference's own file. This module carried a private disk-only `load_xml` until the
// swap, which structurally could not name a chain entry — [`super::test_ui::load_ui`] is
// the one reader that speaks both stores, and it is why that helper exists.
@@ -387,17 +393,27 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"MinimapCluster.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\DurabilityFrame.xml",
"QuestLogFrame.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\QuestFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\QuestLogFrame.xml",
] {
load_xml(&s, f);
}
@@ -15,12 +15,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "EnchantConfirm.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s
}
@@ -1,5 +1,5 @@
//! The shipped errors/info frame (`assets/ui/ErrorsFrame.xml` — the ref UIErrorsFrame, a real
//! `<MessageFrame>`) driven engine-only: the yellow `UI_INFO_MESSAGE` toast (the quest
//! The errors/info frame — stock `Interface\FrameXML\UIErrorsFrame.xml`, a real
//! `<MessageFrame>` — driven engine-only: the yellow `UI_INFO_MESSAGE` toast (the quest
//! objective-progress popup's surface), the red `UI_ERROR_MESSAGE` line, insertMode-TOP stacking,
//! and the hold+fade expiry.
//!
@@ -39,7 +39,7 @@ fn info_and_error_messages_stack_hold_and_expire() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
// Empty at load. The frame itself is shown — a MessageFrame with nothing to say simply draws
@@ -109,20 +109,29 @@ fn an_error_toast_draws_over_an_open_panel_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_* — QuestLogDetail's reward money row
// ScrollTemplates.xml (the faux kit the list rides) + UIPanelTemplates.xml (the detail
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // QuestLogDetail's reward money row (1928)
// pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an
// under-loaded list passes and then dies on the first FauxScrollFrame_Update.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// A left-slot panel open, and the toast raised after it — the order that must not decide.
s.eval::<()>("ShowUIPanel(QuestLogFrame)").unwrap();
@@ -24,6 +24,7 @@ fn one_item_backpack() -> ContainerState {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -70,10 +71,17 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
// `PartyFrame.xml` is needed at LOAD (MAX_PARTY_MEMBERS) and skipping it fails loudly, but
// `ItemButtonTemplate` is only a warning and skipping it fails silently.
for file in super::test_ui::LOOT_UI {
if BAG_UI.contains(file) {
continue; // a file loads once — the bag chain above carried it
}
load_xml(&s, file);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_Set, BankFrame's purse helper
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
// The reference's own `ContainerFrameItemButton_OnClick` reads `MerchantFrame:IsShown()`
// on the bag-slot click below; the coin rig this line used to name is gone (1937/1962).
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -129,7 +137,16 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
s.take_loot_close(),
"closing the loot fired the release (OnHide → CloseLoot)"
);
assert!(s.cursor_item().is_none(), "ESC dropped the held cursor");
// **ESC does NOT drop the cursor's item.** Our retired ladder opened with
// `if CursorHasItem() then ClearCursor() end`; the reference's `ToggleGameMenu`
// (`UIParent.lua:1465-1497`) has no such arm, and wow-re's cursor carve places the
// ESC→ClearCursor wiring in FrameXML rather than the engine
// (`ui/scratch/cursor-dragdrop-payload.md`) — so in 1.12 the held item survives the key
// (1988).
assert!(
s.cursor_item().is_some(),
"the held item survives ESC, as it does in the reference"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -145,9 +162,19 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -178,20 +205,27 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
fn escape_closes_the_options_window_before_opening_the_menu() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // the Keybindings page's faux-scroll kit (1008)
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "GameMenuFrame.xml");
s.run("ShowUIPanel(OptionsFrame)").unwrap();
assert!(s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"the menu is down — the options rung is what must eat this press"
@@ -200,7 +234,8 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
// Press 1: the options rung eats it — the window closes and the menu stays down.
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap(),
!s.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap(),
"ESC closed the options window"
);
assert!(
@@ -235,8 +270,18 @@ fn escape_closes_an_open_stack_split_frame() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // ContainerFrameItemButton_OnClick reads MerchantFrame
load_xml(&s, "ChatFrame.xml"); // …and ChatFrameEditBox, the shift fork's first test
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // …and ChatFrameEditBox, the shift fork's first test
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\StackSplitFrame.xml");
s.set_money(0);
@@ -244,6 +289,7 @@ fn escape_closes_an_open_stack_split_frame() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -314,6 +360,8 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
// GameTooltip.xml (BAG_UI's, for the bag slots' tooltips) also carries TOOLTIP_DEFAULT_COLOR,
// which the dropdown backdrop's OnLoad reads — so the kit can load straight after it.
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -415,6 +463,8 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -478,11 +528,16 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "GameMenuFrame.xml");
// The addon's three lines, verbatim in shape.
@@ -0,0 +1,385 @@
//! **The event ARGUMENT-shape gate** — `reference/1.12-events.tsv` against what benilla actually
//! pushes at each fire site (decision 2140).
//!
//! Two gates already guard the event seam by NAME. [`super::reference_ui`]'s
//! `every_event_a_chain_file_registers_has_a_producer` asks whether something fires what a stock
//! file listens for (1889); `every_event_we_fire_is_an_event_the_reference_has` asks whether a
//! name we fire is a 1.12 name at all (1883). **Neither has ever looked at the arguments**, and an
//! event name is a plain string at both ends — so a fire with the wrong argument shape is silent
//! on both sides. It is 1842's finding one API over: the right name, the wrong shape.
//!
//! It was not hypothetical. Three live divergences on the first run:
//!
//! * `PLAYERBANKSLOTS_CHANGED` — two producers in the reference with **different** shapes
//! (`0x5ddd6e` argless, `0x4c728d` `"player"`), one shape here.
//! * `UNIT_PET_EXPERIENCE` and `UNIT_PET_TRAINING_POINTS` — argless here, `%s` there. The second
//! one reaches `PetPaperDollFrame_OnEvent` only through its final `elseif ( arg1 == "pet" )`
//! catch-all, so an argless fire reached **nobody**: the stock pet page never repainted its
//! training points off the event at all.
//!
//! ## What is compared, and what the table can and cannot say
//!
//! The oracle is wow-re's two byte-level censuses, vendored by `scripts/gen-reference-events.py`.
//! Its `conf` column carries the gate rule per row, exactly as `1.12-shapes.tsv` does:
//!
//! * `exact` — every producer that reaches the event has a known shape. **This is the column this
//! gate reads.**
//! * `advisory` — a contributing site declares more varargs than its caller pushes (three sites,
//! image-wide). Not gated: a faithful client must not copy a bug that hands Lua undefined
//! values.
//! * `none` — no producer family reaches it in that census. **Not a claim that it has none** (the
//! census's own header says so in capitals), so a `none` row is skipped, never failed. That is
//! where the whole `CHAT_MSG_*` family sits: those reach Lua through a formatted wrapper the
//! two-helper census does not see.
//!
//! ## Two deliberate narrowings
//!
//! **Only a fire whose event name is a literal is measured.** `ui_unit.rs` builds the ten power
//! events with `format!("UNIT_{}", power_token(…))`, and there is no name to key on at the call
//! site. Those are covered one gate over — `reference_ui`'s `CONSTRUCTED` list — and their single
//! `tok()` argument is the same shape all ten share.
//!
//! **Only a literal `ScriptValue` element's KIND is asserted.** An argument built from a variable
//! (`vec![ScriptValue::Int(bag)]` is literal enough — the *variant* is what types it, not the
//! value) is typed by its variant; anything that is not a `ScriptValue::` constructor at all
//! counts toward the arity and matches any kind. Arity is the assertion that catches the bug
//! class; kinds are the free extra.
use std::collections::BTreeMap;
/// One row of `reference/1.12-events.tsv`.
struct Row {
/// The alternatives, each a per-slot kind list. `%s` -> `s`, `%d`/`%u`/`%f` -> `n`.
shapes: Vec<Vec<char>>,
producers: String,
}
fn table() -> BTreeMap<String, Row> {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-events.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-events.tsv");
let mut out = BTreeMap::new();
for line in text.lines() {
if line.starts_with('#') || line.starts_with("name\t") {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
// name ids producers arg_formats conf note
if f.len() < 5 || f[4] != "exact" {
continue;
}
let shapes = f[3]
.split('|')
.map(|alt| {
if alt == "()" {
return Vec::new();
}
alt.split('%')
.skip(1)
.map(|d| match d.chars().next() {
Some('s') => 's',
_ => 'n',
})
.collect()
})
.collect();
out.insert(
f[0].to_string(),
Row {
shapes,
producers: f[2].to_string(),
},
);
}
out
}
/// Split `body` at its top-level commas — `vec![…]`'s elements, with nested calls left alone.
fn elements(body: &str) -> Vec<String> {
let mut out = Vec::new();
let (mut depth, mut cur, mut in_str, mut esc) = (0i32, String::new(), false, false);
for c in body.chars() {
if in_str {
cur.push(c);
if esc {
esc = false;
} else if c == '\\' {
esc = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => {
in_str = true;
cur.push(c);
}
'(' | '[' | '{' => {
depth += 1;
cur.push(c);
}
')' | ']' | '}' => {
depth -= 1;
cur.push(c);
}
',' if depth == 0 => {
out.push(std::mem::take(&mut cur));
}
_ => cur.push(c),
}
}
if !cur.trim().is_empty() {
out.push(cur);
}
out.into_iter()
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.collect()
}
/// The kind an argument expression pushes: `s` string, `n` number, `b` boolean, `x` nil,
/// `?` not determinable from the source.
fn kind_of(expr: &str) -> char {
let e = expr.trim_start_matches('&').trim();
for (variant, k) in [
("ScriptValue::Str", 's'),
("ScriptValue::Int", 'n'),
("ScriptValue::Number", 'n'),
("ScriptValue::Bool", 'b'),
("ScriptValue::Nil", 'x'),
] {
if e.starts_with(variant) {
return k;
}
}
'?'
}
/// Every `(event, argument kinds, where)` benilla fires with a literal name.
fn fire_sites() -> Vec<(String, Vec<char>, String)> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("crates");
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
if p.extension().is_none_or(|x| x != "rs") {
continue;
}
// A test's own synthetic fires are not a surface we ship — the same rule
// `every_event_we_fire_is_an_event_the_reference_has` holds, plus the inline
// `#[cfg(test)]` tail a file-name rule cannot see.
if p.to_string_lossy().contains("test") {
continue;
}
let whole = std::fs::read_to_string(&p).unwrap_or_default();
let text = whole
.split_once("#[cfg(test)]")
.map_or(whole.as_str(), |(head, _)| head);
// Split so this file cannot match its own scanner (the 1883 gate's lesson).
for call in [
concat!("fire_event", "("),
concat!("fire_event_into", "("),
concat!("pending_events.push", "(("),
] {
let mut from = 0;
while let Some(i) = text[from..].find(call) {
let at = from + i + call.len();
from = at;
let rest = text[at..].trim_start();
let rest = rest.strip_prefix("lua,").map_or(rest, str::trim_start);
let Some(body) = rest.strip_prefix('"') else {
continue;
};
let Some(end) = body.find('"') else { continue };
let (name, after) = (&body[..end], &body[end + 1..]);
let Some(args) = after.trim_start().strip_prefix(',') else {
continue;
};
let args = args.trim_start();
let kinds = if let Some(v) = args.strip_prefix("vec![") {
let mut depth = 1i32;
let mut close = v.len();
for (k, c) in v.char_indices() {
match c {
'[' => depth += 1,
']' => {
depth -= 1;
if depth == 0 {
close = k;
break;
}
}
_ => {}
}
}
elements(&v[..close]).iter().map(|e| kind_of(e)).collect()
} else if args.starts_with("Vec::new()") {
Vec::new()
} else {
// An argument list built elsewhere — arity unknowable from here.
continue;
};
out.push((name.to_string(), kinds, p.display().to_string()));
}
}
}
}
out
}
/// **Every event benilla fires must carry the arguments the reference's producer pushes.**
#[test]
fn every_event_we_fire_carries_the_reference_s_arguments() {
let table = table();
assert!(
table.len() > 250,
"the events table gave only {} gateable rows — it stopped being read",
table.len()
);
let sites = fire_sites();
assert!(
sites.len() > 200,
"the walker found only {} literal fire sites — it stopped matching, which would make this \
gate silently vacuous",
sites.len()
);
let mut checked = 0usize;
let mut wrong: Vec<String> = Vec::new();
for (name, kinds, at) in &sites {
let Some(row) = table.get(name) else { continue };
checked += 1;
let fits = row.shapes.iter().any(|want| {
want.len() == kinds.len()
&& want
.iter()
.zip(kinds)
.all(|(w, g)| *g == '?' || *g == *w || (*w == 'n' && *g == 'n'))
});
if !fits {
let shapes: Vec<String> = row
.shapes
.iter()
.map(|s| format!("({})", s.iter().collect::<String>()))
.collect();
wrong.push(format!(
"{name} fired as ({}) from {at} — the reference's {} producer(s) push {}",
kinds.iter().collect::<String>(),
row.producers,
shapes.join(" | ")
));
}
}
assert!(
checked >= 175,
"only {checked} fires were measurable against the table — coverage collapsed"
);
wrong.sort();
wrong.dedup();
assert!(
wrong.is_empty(),
"{} fire site(s) push the wrong argument shape. An event name is a plain string at both \
ends, so nothing else in the build can see this:\n {}",
wrong.len(),
wrong.join("\n ")
);
}
/// **Every event the reference's unit-field bridge produces must have a producer here.**
///
/// This is the half that let `UNIT_DYNAMIC_FLAGS` sit unfired for the whole life of the client.
/// [`super::reference_ui::every_event_a_chain_file_registers_has_a_producer`] asks whether
/// anything fires what a **stock chain file** listens for — and *nothing in 1.12 FrameXML
/// registers `UNIT_DYNAMIC_FLAGS`, so the hole was invisible to it by construction. The corpus is
/// where the demand was (`CT_UnitFrames/CT_TargetFrame.xml:200`, `TipBuddy/TipBuddy.lua:17`), and
/// the corpus is not in this repo.
///
/// So this gate asks the question from the REFERENCE's side instead, over the one family where
/// that is bounded and exact: `0x51bbb0` registers one watch per named unit-window field, so
/// every event id below `0xb6` that has a name in the client's own table is produced by that
/// bridge, always with the unit token. Twenty-five names, one producer function on our side
/// ([`crate::ui_unit::fire_transitions`] and the two pet-stat feeds), and no judgment call about
/// which of them "should" exist — the reference fires all of them.
///
/// A CENSUS with a declared set, like 1889's: what must not happen is a new name appearing.
#[test]
fn every_unit_field_bridge_event_has_a_producer() {
/// Produced by the reference's bridge, by nothing here. Each needs a reason, and an entry
/// that comes into production fails the gate so the list cannot outlive its fix.
const UNPRODUCED: &[(&str, &str)] = &[(
"UNIT_LOYALTY",
"field 138, the hunter pet's loyalty level. benilla's pet-stat feed (`ui_pet_stats`) \
reads happiness and training points off `PET_BYTES` but never the loyalty field, and no \
window it feeds shows a loyalty line — `PetPaperDollFrame` does not register it either. \
Unbuilt, not wrong: the day the pet page grows the line, this entry goes with it",
)];
let table = table();
let bridge: Vec<&String> = table
.iter()
.filter(|(_, r)| r.producers.contains("unit-field-bridge"))
.map(|(n, _)| n)
.collect();
assert!(
bridge.len() >= 25,
"only {} bridge events in the table — it stopped being read",
bridge.len()
);
let fired: std::collections::HashSet<String> = fire_sites()
.into_iter()
.map(|(n, _, _)| n)
.chain(
// The ten power events are built with `format!("UNIT_{}", power_token(…))`, so no
// literal exists to scan for — the same declared family `reference_ui`'s CONSTRUCTED
// list carries, and an entry there is a promise that something fires it.
[
"MANA",
"RAGE",
"FOCUS",
"ENERGY",
"HAPPINESS",
"MAXMANA",
"MAXRAGE",
"MAXFOCUS",
"MAXENERGY",
"MAXHAPPINESS",
]
.iter()
.map(|p| format!("UNIT_{p}")),
)
.collect();
let expected: std::collections::HashSet<&str> = UNPRODUCED.iter().map(|(e, _)| *e).collect();
let missing: Vec<&str> = bridge
.iter()
.map(|n| n.as_str())
.filter(|n| !fired.contains(*n) && !expected.contains(n))
.collect();
assert!(
missing.is_empty(),
"the reference fires these off a named unit descriptor field and benilla fires them \
nowhere. No stock file need listen for one — the corpus does, and an addon that \
registers an event nothing produces hears silence with no error anywhere:\n {missing:?}"
);
let now_fired: Vec<&str> = UNPRODUCED
.iter()
.map(|(e, _)| *e)
.filter(|e| fired.contains(*e))
.collect();
assert!(
now_fired.is_empty(),
"these now HAVE a producer — take them out of UNPRODUCED so the list keeps meaning what \
it says: {now_fired:?}"
);
}
@@ -15,16 +15,34 @@ fn exp_bar_harness() -> UiScript {
// is the reference's own since 1751, and a hand-rolled reader here would look for it in the
// wrong store — and would leave its `<Script file="TextStatusBar.lua"/>` unresolved besides.
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Cooldown.xml",
"ActionBar.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
] {
super::test_ui::load_ui(&s, file);
}
// 1.12 ships detailed tips ON — `SHOW_NEWBIE_TIPS = "1"` is UIOptionsFrame_Init's (ref
// UIOptionsFrame.lua l.100; ours sits in OptionsFrame.xml's uvar block, 1968), and a harness
// without the options file says so itself, the way the reference's tooltip would read it.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s
}
@@ -53,7 +71,8 @@ fn the_xp_bar_takes_the_mouse_and_explains_itself() {
"the XP strip must be mouse-enabled or the hover never fires"
);
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -71,7 +90,8 @@ fn the_xp_bar_takes_the_mouse_and_explains_itself() {
"the default-corner anchor"
);
s.run("BenillaExpBar_OnLeave()").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnLeave\")()")
.unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
@@ -291,7 +311,8 @@ fn the_xp_bar_numerals_show_on_hover() {
s.set_player_xp(1234, 5678);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>("return MainMenuBarExpText:GetText()")
.unwrap(),
@@ -305,7 +326,7 @@ fn the_xp_bar_numerals_show_on_hover() {
// `this.isZero` on one line where every other line reads `bar` (TextStatusBar.lua:95 — the
// reference's own slip, benign there because `this` is always SOME frame during dispatch, and
// a nil index only from bare Lua). Called from the bar's own `<OnLeave>` in play.
s.run("this = MainMenuExpBar BenillaExpBar_OnLeave(MainMenuExpBar) this = nil")
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnLeave\")() this = nil")
.unwrap();
assert!(
!s.eval::<bool>("return MainMenuBarExpText:IsShown()")
@@ -328,7 +349,8 @@ fn the_rest_state_line_joins_the_held_open_tooltip() {
// 1.12's default posture (0661): detailed newbie tips ON.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<f64>("return ExhaustionTick.timer").unwrap(),
1.0,
@@ -1,287 +0,0 @@
//! The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish
//! flash, rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`. Split from [`super`]
//! purely for size — [`cooldown_quads`] is the only face, called from the extract loop's
//! `QuadContent::Cooldown` arm.
use bevy::prelude::*;
use crate::ui_pass::UiQuad;
use benilla_assets::WorldAssets;
/// The cooldown wipe's paint: flat black at α = 0x99/255 — the byte content of
/// `Interface\Cooldown\cooldown.blp` (a uniform 32² black texel field at alpha 0x99, with only a
/// transparent clamp column for the sweep edge), constant through the sweep (the model's
/// quadrant color-alpha tracks hold 1.0 for all of sequence 0 — the m2anim key dump).
const COOLDOWN_WIPE_ALPHA: f32 = 153.0 / 255.0;
/// The finish-flash star's bone-scale pulse — sequence 1's 5 keys, byte-read off
/// `UI-Cooldown-Indicator.m2` with the `m2anim` bone-scale dump (uniform XYZ, so one scalar per
/// key): grow to 1.85× with the alpha rise, then a shrinking double-bounce while it fades.
const STAR_SCALE_KEYS: [(f32, f32); 5] = [
(0.0, 1.0),
(0.333, 1.853),
(0.666, 1.305),
(0.833, 1.605),
(1.0, 1.155),
];
/// Linear sample of [`STAR_SCALE_KEYS`] at flash progress `t` (the M2 linear-track read).
fn star_scale(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
for w in STAR_SCALE_KEYS.windows(2) {
let ((t0, v0), (t1, v1)) = (w[0], w[1]);
if t <= t1 {
return v0 + (v1 - v0) * ((t - t0) / (t1 - t0));
}
}
STAR_SCALE_KEYS[4].1
}
/// The Cooldown widget's pixels (decision 0137 phase 4): the radial dark wipe + the finish flash,
/// rebuilt natively from the byte-read `UI-Cooldown-Indicator.m2`.
///
/// **The wipe.** The model draws 4 quadrant quads whose UVs rotate over a clamp-edged
/// half-transparent texture — 2001's way of sweeping a straight edge through each quadrant; with
/// a *flat* texture behind it (`cooldown.blp` is uniform black at α 0x99), the composed result is
/// exactly a uniform dark pie whose bright sector grows **clockwise from 12 o'clock** as the
/// cooldown elapses. Rebuilt as ≤4 quads: one full-dark rect per still-covered quadrant, plus
/// the active quadrant's **wedge** — an exact convex fan (`c → ray exit → [outer corner] →
/// quadrant-end edge midpoint`) through [`UiQuad::corners`]. Pixel-equivalent, no scissor
/// interplay. (The wedge bypasses the scroll clip — no cooldown sits in a ScrollFrame; the
/// full-dark rects still carry it.)
///
/// **The flash.** The model's sequence 1 (authored 1.000 s): the additive `star4` burst whose
/// alpha is the byte-read texture-weight ramp — linear 0→1 over the first third, hold 1 to the
/// half, linear 1→0 over the back half — scaled by the star bone's byte-read 5-key pulse
/// ([`STAR_SCALE_KEYS`], the `m2anim` bone-scale dump): grow to 1.85× with the alpha rise, then
/// a shrinking double-bounce as it fades.
#[allow(clippy::too_many_arguments)] // one draw arm's full input set
pub(super) fn cooldown_quads(
rect: Rect,
z_key: u64,
frame_alpha: f32,
fraction: f32,
flash: Option<f32>,
clip: Option<Rect>,
assets: &mut Option<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
out: &mut Vec<UiQuad>,
) {
let c = rect.center();
let dark = [0.0, 0.0, 0.0, COOLDOWN_WIPE_ALPHA * frame_alpha];
if let Some(progress) = flash {
// The finish flash: the byte-read weight ramp (keys 0→1 over 0..⅓, hold to ½, →0 at 1).
let ramp = if progress < 1.0 / 3.0 {
progress * 3.0
} else if progress < 0.5 {
1.0
} else {
(1.0 - progress) * 2.0
};
let handle = assets
.as_mut()
.and_then(|a| a.sprite_texture("Interface\\Cooldown\\star4", images));
if let Some(handle) = handle {
let s = star_scale(progress);
let half = rect.half_size() * s;
out.push(UiQuad {
rect: Rect::from_center_half_size(c, half),
z_key,
texture: Some(handle),
color: [1.0, 1.0, 1.0, ramp * frame_alpha],
additive: true,
clip,
..default()
});
}
return;
}
// The sweep: bright sector [0, θ) clockwise from 12 o'clock; quadrants wholly past the edge
// are full-dark rects, the active one carries the exact wedge.
let theta = fraction.clamp(0.0, 1.0) * std::f32::consts::TAU;
let active = ((theta / std::f32::consts::FRAC_PI_2) as usize).min(3);
// Clockwise from 12 in y-down screen space: top-right, bottom-right, bottom-left, top-left.
let quadrant = |k: usize| match k {
0 => Rect::new(c.x, rect.min.y, rect.max.x, c.y),
1 => Rect::new(c.x, c.y, rect.max.x, rect.max.y),
2 => Rect::new(rect.min.x, c.y, c.x, rect.max.y),
_ => Rect::new(rect.min.x, rect.min.y, c.x, c.y),
};
for k in (active + 1)..4 {
out.push(UiQuad {
rect: quadrant(k),
z_key,
color: dark,
clip,
..default()
});
}
// The active quadrant's wedge. The sweep ray from c at angle θ has direction
// d(θ) = (sin θ, −cos θ) (y-down); per quadrant it can exit through two edges — the "first"
// one (adjacent to the sweep's entry axis, whose crossing keeps the quadrant's outer corner
// dark) or the "second" (past the corner). Vertices fan from c, clockwise:
// c → E (ray exit) → [K (outer corner) if the ray exits the first edge] → M (the
// quadrant-end axis point). A three-vertex wedge repeats M (the fan's degenerate second
// triangle draws nothing).
let d = Vec2::new(theta.sin(), -theta.cos());
// Per active quadrant: (first-edge t, second-edge t, K, M). A division by ±0 yields ±inf,
// which the min-pick handles (the boundary angles resolve to the finite edge).
let (t_first, t_second, k_corner, m_point) = match active {
0 => (
(rect.min.y - c.y) / d.y,
(rect.max.x - c.x) / d.x,
Vec2::new(rect.max.x, rect.min.y),
Vec2::new(rect.max.x, c.y),
),
1 => (
(rect.max.x - c.x) / d.x,
(rect.max.y - c.y) / d.y,
Vec2::new(rect.max.x, rect.max.y),
Vec2::new(c.x, rect.max.y),
),
2 => (
(rect.max.y - c.y) / d.y,
(rect.min.x - c.x) / d.x,
Vec2::new(rect.min.x, rect.max.y),
Vec2::new(rect.min.x, c.y),
),
_ => (
(rect.min.x - c.x) / d.x,
(rect.min.y - c.y) / d.y,
Vec2::new(rect.min.x, rect.min.y),
Vec2::new(c.x, rect.min.y),
),
};
let e_point = c + d * t_first.min(t_second);
let corners = if t_first <= t_second {
[c, e_point, k_corner, m_point]
} else {
[c, e_point, m_point, m_point]
};
out.push(UiQuad {
rect: quadrant(active),
z_key,
color: dark,
corners: Some(corners),
..default()
});
}
#[cfg(test)]
mod cooldown_quad_tests {
use super::{cooldown_quads, star_scale, STAR_SCALE_KEYS};
use bevy::prelude::*;
/// Emit the sweep for `fraction` on a unit-friendly 40×40 button at (0,0)..(40,40).
fn sweep(fraction: f32) -> Vec<crate::ui_pass::UiQuad> {
let mut out = Vec::new();
cooldown_quads(
Rect::new(0.0, 0.0, 40.0, 40.0),
7,
1.0,
fraction,
None,
None,
&mut None,
&mut Assets::<Image>::default(),
&mut out,
);
out
}
/// Total dark coverage of the emitted quads (rect quads + the corner wedge's shoelace area).
fn dark_area(quads: &[crate::ui_pass::UiQuad]) -> f32 {
quads
.iter()
.map(|q| match q.corners {
None => q.rect.width() * q.rect.height(),
Some(c) => {
// Shoelace over the fan's two triangles (0,1,2) + (0,2,3).
let tri = |a: Vec2, b: Vec2, d: Vec2| ((b - a).perp_dot(d - a) / 2.0).abs();
tri(c[0], c[1], c[2]) + tri(c[0], c[2], c[3])
}
})
.sum()
}
/// The square-cornered pie's exact per-phase geometry (a square pie's area is not linear in
/// the swept angle, so the checkpoints below are the analytically known shapes: full, the
/// 45° corner ray, the 180° half, the end sliver).
#[test]
fn cooldown_sweep_geometry_is_the_exact_square_pie() {
// Fraction 0: everything dark — 3 full quadrant rects + a wedge covering the fourth.
let q0 = sweep(0.0);
assert_eq!(q0.len(), 4);
assert!(
(dark_area(&q0) - 1600.0).abs() < 1e-2,
"fully dark at start"
);
// Fraction 1/8 (45° — the ray through quadrant 0's outer corner): the bright half of
// quadrant 0 is gone; the wedge is the triangle {center, corner, right-edge midpoint}.
let q = sweep(0.125);
assert_eq!(q.len(), 4);
assert!(
(dark_area(&q) - (1600.0 - 200.0)).abs() < 1.0,
"45°: half of one 400px² quadrant is bright, got {}",
dark_area(&q)
);
// Fraction 0.5 (180°): the right half is bright — two full-dark quadrants + a wedge
// that spans exactly quadrant 2 (t ties pick the corner-inclusive fan; area 400).
let q = sweep(0.5);
assert!((dark_area(&q) - 800.0).abs() < 1.0, "half bright at 180°");
// Fraction ~1: a sliver — near-zero dark area, no full quadrants left.
let q = sweep(0.999);
assert!(dark_area(&q) < 20.0, "almost done — a sliver remains");
assert_eq!(q.len(), 1, "only the active quadrant's wedge remains");
// The wedge always fans from the button center.
for f in [0.05, 0.3, 0.6, 0.9] {
let q = sweep(f);
let wedge = q
.iter()
.find_map(|u| u.corners)
.expect("a wedge each phase");
assert_eq!(wedge[0], Vec2::new(20.0, 20.0), "fan apex at the center");
}
}
/// The star pulse samples its byte-read keys exactly and lerps between them — the ⅓ peak
/// coincides with the alpha ramp's own peak.
#[test]
fn star_scale_follows_the_five_key_curve() {
for (t, v) in STAR_SCALE_KEYS {
assert!((star_scale(t) - v).abs() < 1e-4, "key at {t}");
}
// Midway up the rise: halfway between 1.0 and 1.853.
let mid = star_scale(0.333 / 2.0);
assert!((mid - (1.0 + 1.853) / 2.0).abs() < 1e-2, "got {mid}");
// Clamped outside the band.
assert!((star_scale(-1.0) - 1.0).abs() < 1e-4);
assert!((star_scale(2.0) - 1.155).abs() < 1e-4);
}
/// The flash phase draws no dark pie — only the additive star (skipped here: no assets in a
/// unit test — the pie's absence is the machine-checkable half).
#[test]
fn cooldown_flash_phase_emits_no_dark_pie() {
let mut out = Vec::new();
cooldown_quads(
Rect::new(0.0, 0.0, 40.0, 40.0),
7,
1.0,
1.2,
Some(0.4),
None,
&mut None,
&mut Assets::<Image>::default(),
&mut out,
);
assert!(out.is_empty(), "no assets → no star; and never a dark quad");
}
}
File diff suppressed because it is too large Load Diff
@@ -11,14 +11,14 @@ use benilla_ui::script::{EditBoxTextUi, FontShadow, JustifyH, JustifyV, Outline}
use benilla_ui::widget::FrameHandle;
use crate::ui_pass::{UiQuad, UvRect};
use crate::ui_text::{layout_text_quads, layout_text_quads_links, UiFontAtlas};
use crate::ui_text::{layout_text_quads, layout_text_quads_links, TextSeat, UiFontAtlas};
/// `WOW_TEXT_PROBE=1` — launch-time knob, read once (the check ran per Text quad per frame).
static TEXT_PROBE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| std::env::var("WOW_TEXT_PROBE").as_deref() == Ok("1"));
/// The `QuadContent::Text` payload minus the text itself — the region's resolved style, passed
/// verbatim from the destructure at the [`super::drive_script`] call site.
/// verbatim from the destructure at the [`super::paint_script`] call site.
pub(super) struct TextStyle {
pub color: Option<[f32; 4]>,
pub justify_h: JustifyH,
@@ -29,6 +29,9 @@ pub(super) struct TextStyle {
pub shadow: Option<FontShadow>,
pub outline: Outline,
pub alpha_gradient: Option<(f32, f32)>,
/// The region's seat claim (`benilla_ui`'s `RegionData::world_seat`) — true for the V-plate's
/// name and level, which are rigid to a device-snapped overlay rather than to the UI grid.
pub world_seat: bool,
}
/// The host-loop context one Text quad draws under: the extracted quad's identity (z/alpha/
@@ -71,6 +74,14 @@ pub(super) fn emit(
link_spans: &mut Vec<(FrameHandle, benilla_ui::layout::Rect, String, String)>,
) {
let base_color = style.color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
// Which pixel grid this block's top may land on (decision 2172): the interface's, or none —
// the WorldFrame overlays carry their own device-pixel seat and their text has to be rigid
// to it. The shadow pass below inherits it, like every other layout input.
let seat = if style.world_seat {
TextSeat::Exact
} else {
TextSeat::UiGrid
};
let spec = crate::ui_text::FontSpec {
path: style.font.as_deref(),
// The drawn px under the two size regimes × the 768-virtual scale × the owner's frame
@@ -200,13 +211,24 @@ pub(super) fn emit(
let ink = crate::ui_text::measure_text(&mut atlas.lock(), draw_text, None, spec).0;
format!(" caret={:.1} ink={ink:.1}", ui.caret_x * host.scale)
});
// **The FACE and the FLAGS are on this line for a reason.** It used to print the
// requested height and nothing else about the font, so the one question a "the text
// looks wrong" report actually asks — *which face, at what size, with what outline, did
// this quad draw* — could not be answered from the probe at all; it took a live
// `SetFont` probe, a control plate and a screenshot to establish for MSBT what these
// four fields say directly (decisions 2103, 2112). `px` is the DRAWN logical height
// (`drawn_px`: the requested one through the cap, the 768 seam and the frame scale),
// which is the number that disagrees with `h` whenever a size looks wrong.
info!(
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?}{} {:?}",
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?} px={:?} flags={:?} face={:?}{} {:?}",
draw_rect.min.x,
draw_rect.min.y,
host.rect.width(),
host.rect.height(),
style.font_height,
spec.height,
style.outline.as_str(),
spec.path.unwrap_or("<none — the fallback face>"),
ebox_geom.unwrap_or_default(),
&draw_text[..draw_text.len().min(60)]
);
@@ -238,6 +260,7 @@ pub(super) fn emit(
draw_justify,
host.z,
shadow_spec,
seat,
);
for q in &mut sq {
// Flatten rgb to the shadow color (markup tints ride the fill only); the alpha is
@@ -271,6 +294,7 @@ pub(super) fn emit(
},
host.z,
spec,
seat,
&mut spans,
);
for sp in spans {
@@ -297,6 +321,7 @@ pub(super) fn emit(
draw_justify,
host.z,
spec,
seat,
)
};
for q in &mut glyphs {
@@ -310,21 +335,43 @@ pub(super) fn emit(
// (`fontstring-vertical-placement.md`): compare `ink` against the law's `d + ascender` seat
// when hunting a vertical offset. Fill quads only (the shadow pass above would smear the
// bounds one px down-right).
if probe && !glyphs.is_empty() {
let vpl = style.world_seat && benilla_assets::trace::enabled_for("vpl");
if (probe || vpl) && !glyphs.is_empty() {
let (mut y0, mut y1) = (f32::MAX, f32::MIN);
for q in &glyphs {
y0 = y0.min(q.rect.min.y);
y1 = y1.max(q.rect.max.y);
}
info!(
"seat probe: top={:.2} ink=[{:.2}..{:.2}] (rel {:.2}..{:.2}) {:?}",
host.rect.min.y,
y0,
y1,
y0 - host.rect.min.y,
y1 - host.rect.min.y,
&draw_text[..draw_text.len().min(20)]
);
// **Where the plate's text actually inked**, on the same `vpl` tag the driver's `plate=`
// seat and the border's `paint=` line ride ([`crate::vplates`], decision 2168's
// measurement). The question the three lines answer together is not "does the text
// move?" — it is "does it move WITH the border?", which is a difference of two numbers
// per frame. Decision 2168 §5 named this line as the missing third; 2172 is what it
// found when it was built.
if vpl {
benilla_assets::trace::line(
"vpl",
&format!(
"text x={:.3} rect={:.3} ink={:.3} rel={:.3} txt={}",
host.rect.min.x,
host.rect.min.y,
y0,
y0 - host.rect.min.y,
draw_text.chars().take(16).collect::<String>()
),
);
}
if probe {
info!(
"seat probe: top={:.2} ink=[{:.2}..{:.2}] (rel {:.2}..{:.2}) {:?}",
host.rect.min.y,
y0,
y1,
y0 - host.rect.min.y,
y1 - host.rect.min.y,
draw_text.chars().take(20).collect::<String>()
);
}
}
out.extend(glyphs);
// The focused box's caret: a 1-px WHITE bar (the client's ctor `0xffffffff` caret texture —
+79 -65
View File
@@ -1,80 +1,35 @@
//! The fade kit's driver (`UIFrameFade` / `BenillaFadeDriver`, UiPanels.xml) — the park/wake
//! lifecycle that keeps the per-frame fade walk off the tick while nothing is fading. Split from
//! `panel_tests.rs` (1507's round): the slot manager and the fade kit share a source file, not a
//! concern.
//! The fade and flash kits — the stock `UIParent.lua`'s since 1988, walked from `UIParent`'s own
//! `<OnUpdate>` (`UIFrameFadeUpdate` / `UIFrameFlashUpdate`), which is what the reference does
//! too: no tick driver of ours, and a cinematic's `UIParent:Hide()` stops both walks the way it
//! stops the reference's. What these guard is the arithmetic and the list discipline — a fade
//! ramps linearly to its endAlpha and leaves `FADEFRAMES`; a flash alternates for its duration,
//! refuses a re-arm while flashing, and ends hidden when `showWhenDone` is nil.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// **The idle fade driver parks itself** (decision 1396's class, the audit's driver-hide item):
/// with `FADEFRAMES` empty there is nothing to walk, so the driver's OnUpdate hides its own frame
/// — and the engine dispatches OnUpdate only to visible frames (`tick.rs`), which stops the
/// per-frame walk cold. The probe wraps `UIFrameFadeUpdate` in a counter BEFORE the first tick:
/// ten idle ticks later it has never run, and the driver is hidden.
#[test]
fn an_idle_fade_driver_parks_itself_off_the_tick() {
fn a_started_fade_ramps_off_uiparents_tick_and_completes() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
s.resolve();
s.run(
"BENILLA_TEST_FADE_TICKS = 0\n\
local real = UIFrameFadeUpdate\n\
function UIFrameFadeUpdate(elapsed)\n\
BENILLA_TEST_FADE_TICKS = BENILLA_TEST_FADE_TICKS + 1\n\
real(elapsed)\n\
end",
)
.unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap(),
"an empty FADEFRAMES parks the driver"
);
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_FADE_TICKS").unwrap(),
0,
"a parked driver never runs the fade walk"
);
}
/// The control for the guard above: the gate must not cost the fades it gates. `UIFrameFade` (the
/// kit's single insertion point — `UIFrameFadeIn`/`Out` both land there) wakes the driver beside
/// its `table.insert`; the fade then ramps on real ticks, completes, and the driver re-parks on
/// the first empty tick after.
#[test]
fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s.resolve();
s.run(r#"CreateFrame("Frame", "BenillaFadeProbe")"#)
.unwrap();
for _ in 0..2 {
s.tick(0.016); // settle: the driver parks
s.resolve();
}
assert!(!s
.eval::<bool>("return BenillaFadeDriver:IsShown()")
.unwrap());
s.run("UIFrameFadeIn(BenillaFadeProbe, 1.0)").unwrap();
assert!(
s.eval::<bool>("return BenillaFadeDriver:IsShown()")
s.eval::<bool>("return UIFrameIsFading(BenillaFadeProbe) == 1")
.unwrap(),
"starting a fade wakes the driver"
"on the fade list"
);
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
@@ -97,12 +52,71 @@ fn a_started_fade_still_ramps_and_the_driver_reparks_after() {
1.0,
"the fade completes at its endAlpha"
);
s.tick(0.016); // the first empty tick re-parks the driver
s.resolve();
assert!(
!s.eval::<bool>("return BenillaFadeDriver:IsShown()")
!s.eval::<bool>("return UIFrameIsFading(BenillaFadeProbe) == 1")
.unwrap(),
"the driver re-parks once the list is empty"
"…and leaves the list"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The FLASH kit alternates and stops** — the fade kit's twin, transcribed from `UIParent.lua`
/// because two stock windows call it and nothing answered (1879).
///
/// Driven rather than merely loaded: a kit that is present but never exercised is exactly how
/// twelve faux lists shipped unable to scroll (1868), so this runs a real flash to completion.
#[test]
fn a_flash_alternates_then_stops() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s.resolve();
// Parked until something asks — the whole point of the driver over UIParent's own OnUpdate.
s.run(
r#"Probe = CreateFrame("Frame", "BenillaFlashProbe", UIParent)
Probe:SetWidth(10) Probe:SetHeight(10) Probe:SetPoint("TOPLEFT", 0, 0)
UIFrameFlash(Probe, 0.05, 0.05, 0.3, nil, 0, 0)"#,
)
.unwrap();
assert!(
s.eval::<bool>("return UIFrameIsFlashing(BenillaFlashProbe) == 1")
.unwrap(),
"the frame is on the flash list"
);
// Re-arming an already-flashing frame is a no-op, not a second entry (ref UIParent.lua:1234).
s.run("UIFrameFlash(BenillaFlashProbe, 1, 1, 1, nil, 0, 0)")
.unwrap();
assert_eq!(
s.eval::<i64>("return table.getn(FLASHFRAMES)").unwrap(),
1,
"already flashing: the reference returns rather than re-arming"
);
// Run past flashDuration: the frame leaves both lists, is restored to full alpha, and — with
// `showWhenDone` nil — ends hidden.
for _ in 0..40 {
s.tick(0.016);
s.resolve();
}
assert!(s.errors().is_empty(), "flashing raised: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return table.getn(FLASHFRAMES)").unwrap(),
0,
"the flash finished and the frame left the list"
);
assert!(
!s.eval::<bool>("return BenillaFlashProbe:IsShown()")
.unwrap(),
"showWhenDone was nil, so it ends hidden"
);
}
@@ -40,7 +40,7 @@ fn load_inline(s: &UiScript, xml: &str) {
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
@@ -304,20 +304,38 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
"the drag repainted the owner's list"
);
// A sub-row nudge rounds to the NEAREST row (`floor(v/itemHeight + 0.5)`) and lands on the
// same one, so the offset does not move.
// A sub-row nudge on the BAR reaches nothing at all: `FauxScrollFrame_Update` set the bar's
// step to one row, and `SetValue` quantises onto `min + n·step` before its change compare
// (2133, `0x789930`), so 51px resolves to the 48 the bar already holds — no
// `OnValueChanged`, no `SetVerticalScroll`, no repaint. This is the snap a 1.12 list
// scrollbar has and ours did not: the bar cannot come to rest between two rows.
let settled = s.eval::<i64>("return TestRepaints").unwrap();
s.run("TestScrollScrollBar:SetValue(51)").unwrap();
assert_eq!(
s.eval::<f64>("return TestScrollScrollBar:GetValue()")
.unwrap(),
48.0,
"51px is not a lattice point; the bar stays on row 3's"
);
assert_eq!(
s.eval::<i64>("return TestRepaints").unwrap(),
settled,
"and a value that did not move fires nothing"
);
// The unconditional repaint is still there — it lives one level up, on the path a wheel or a
// `SetVerticalScroll` takes. `FauxScrollFrame_OnVerticalScroll` ends in a bare
// `updateFunction();` (UIPanelTemplates.lua:228-232) with no compare against the previous
// offset, so a sub-row scroll through the FRAME repaints even though the bar does not move.
// Our deleted kit repainted only when the row actually changed; the reference does not, and
// that difference is the migration's, not a regression to chase (1860).
s.run("TestScroll:SetVerticalScroll(51)").unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
.unwrap(),
3,
"51px is still row 3 once rounded"
"51px is still row 3 once rounded (`floor(v/itemHeight + 0.5)`)"
);
// …but it STILL repaints. `FauxScrollFrame_OnVerticalScroll` ends in a bare
// `updateFunction();` (UIPanelTemplates.lua:228-233) — unconditional, with no compare against
// the previous offset. Our deleted kit repainted only when the row actually changed; the
// reference does not, and that difference is the migration's, not a regression to chase (1860).
assert!(
s.eval::<i64>("return TestRepaints").unwrap() > settled,
"the reference repaints on every scroll, changed row or not"
@@ -329,11 +347,11 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
/// on `FauxScrollFrameTemplate` whose `<OnVerticalScroll>` calls `FauxScrollFrame_OnVerticalScroll`,
/// driven by a drag of the shared bar.
///
/// The `SetScrollChild` line is the gap, made visible: the reference's template declares that child
/// as `<ScrollChild><Frame name="$parentScrollChildFrame">`, our XML loader has no `<ScrollChild>`
/// element, and `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]` — which is computed
/// from that child. Without it the range is 0, the handler fires with `arg1 = 0` and an addon's list
/// never leaves the top. With it, the whole reference path runs.
/// The `SetScrollChild` line predates 1205 (the loader's `<ScrollChild>`) and stays as the
/// addon-shaped way of seating a child by hand; the range it asserts is that child's overflow
/// (1338). The offset itself never depended on the range — the engine stores what the bar hands it
/// (decision 2017) — so what this drives is bar value → `SetVerticalScroll` → `<OnVerticalScroll>`
/// → `FauxScrollFrame_OnVerticalScroll`'s `floor(v / step + 0.5)`, the reference's path end to end.
#[test]
fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
let mut s = harness();
@@ -399,7 +417,7 @@ fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
#[test]
fn scrolling_edit_helpers_answer_bare_calls_from_a_handler() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
@@ -23,10 +23,10 @@
//! answers the same whichever way it came.
//!
//! **Divergences are an explicit list with a reason each, never a pattern.** [`KNOWN`] carries
//! them, in both directions; a new one cannot hide inside a tolerance. The list is where the
//! seven frames the reference makes interactive through *handlers we do not carry* are recorded —
//! those want the handler (and its tooltip), never a bare `enableMouse="true"` that would swallow
//! the click and give nothing back.
//! them, in both directions; a new one cannot hide inside a tolerance. **It is empty today** —
//! 1751's window migrations, 1795, 1970 and 1980 each retired a group, and the in-body notes are
//! the record of what each one was. The array stays because an empty list is the thing that
//! notices a divergence arriving.
//!
//! ## Four flags, and only four
//!
@@ -68,8 +68,8 @@
//!
//! The reference side is **XML only**: a `SetID` or `EnableMouse` the reference makes from Lua at
//! `OnLoad` is invisible here and reads as absent. That blind spot only ever under-reports — it
//! can hide a divergence, never invent one — so nothing it misses turns into a false failure; the
//! handful of frames it does hide are in `KNOWN` with that reason.
//! can hide a divergence, never invent one — so nothing it misses turns into a false failure. It
//! would be a `KNOWN` entry's reason if any frame still needed one.
//!
//! The whole module skips cleanly with no install — `_extracted_framexml/` is a gitignored
//! Blizzard asset, like every other client-data test here.
@@ -101,7 +101,6 @@ const FRAME_TAGS: &[&str] = &[
"Minimap",
"MovieFrame",
"WorldFrame",
"Cooldown",
];
/// The five `<Scripts>` handler names that auto-enable the MOUSE kind, and only those five
@@ -158,16 +157,18 @@ impl std::fmt::Display for Flag {
}
}
/// The accepted differences, in four groups: **seven frames the reference makes mouse-interactive
/// through HANDLERS we do not carry**, the merchant rows whose mouse we take where it does not,
/// the faux scroll panes we build from a different widget kind, and the ids the reference sets
/// from Lua. Every entry is a judgement someone made; none of them is a tolerance.
/// The accepted differences — **none, today**. The four groups this list once carried (the frames
/// the reference made mouse-interactive through handlers we did not carry, the merchant rows, the
/// faux scroll panes, the Lua-set ids) each retired with the window that owned them; the in-body
/// notes below name the record for each. An empty array is not an oversight: it is what fails the
/// gate the moment a new divergence appears, and every entry it ever holds is a judgement someone
/// made, never a tolerance.
const KNOWN: &[Known] = &[
// ── The reference has it, we do not: all seven are handler gaps, not flag gaps ─────────────
// ── The reference had it and we did not: seven handler gaps, not flag gaps (all retired) ───
//
// In each of these the reference's mouse comes from an `<OnEnter>`/`<OnLeave>` pair whose body
// we have not built, and the interaction the player would notice is the TOOLTIP those handlers
// show — not the click-blocking the flag gives. Declaring `enableMouse="true"` here would make
// In each of these the reference's mouse came from an `<OnEnter>`/`<OnLeave>` pair whose body
// we had not built, and the interaction the player would notice was the TOOLTIP those handlers
// show — not the click-blocking the flag gives. Declaring `enableMouse="true"` would have made
// the frame swallow the click and hand back nothing, which is worse than the gap.
// `PetPaperDollFrameExpBar` RETIRED here (decision 1751's character-sheet window). It read
// "the reference bar inherits TextStatusBar, whose OnEnter/OnLeave show the value text; ours is
@@ -180,122 +181,11 @@ const KNOWN: &[Known] = &[
// because OUR `ScrollFrame` ctor took the mouse and the reference's does not. Correcting the
// ctor list to the client's made both sides agree, and an accepted divergence that has been
// fixed is documentation claiming a defect we do not have.
Known {
frame: "ChatFrame1",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame2",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame3",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame4",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame5",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame6",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
Known {
frame: "ChatFrame7",
flag: Flag::Mouse,
why: "our chat window takes the mouse and the reference's does not. It was believed a \
`<ScrollingMessageFrame>` takes it BY CONSTRUCTION; wow-re's follow-up round \
(`68987021`) proved that ctor leaves the bit clear, and that a 1.12 chat link is \
clickable through one synthesised `CSimpleHyperlinkButton` child per link span. Our \
hit test now carries that law as a span disjunct, so the LINKS no longer need the \
flag — but ChatFrame.xml still sets it, and a documented chain of choices hangs off \
the old premise (no `SetFrameLevel-1` OnLoad because it would sink the resize grips \
under a mouse-enabled parent; `OnClick` dismisses a held spell, 0843). Unwinding \
that is the chat window's own piece of work, not this flag's.",
},
// The two `TargetofTarget*Bar` entries RETIRED here (the unit-frame migration), for the same
// reason their twin the pet page's XP bar went: they said our bars were plain StatusBars where
// the reference inherits `TextStatusBar` — true of our transcription, and no longer true of
// anything. `TargetFrame.xml` is the reference's own now and its ToT bars inherit
// `TextStatusBar` like every other unit bar. The gate found them itself, which is its job.
Known {
frame: "TradePlayerItem7",
flag: Flag::Mouse,
why:
"the enchant slot: ours inherits BenillaTradeEnchantItemTemplate, the reference's the \
ordinary PlayerTradeItemTemplate. A structural difference in our own trade window.",
},
Known {
frame: "WhoFrameDropDown",
flag: Flag::Mouse,
why: "the reference's /who sort dropdown carries its own handlers over the shared \
UIDropDownMenuTemplate; ours takes the template alone.",
},
Known {
frame: "WorldStateAlwaysUpFrame",
flag: Flag::Mouse,
why:
"the reference's PvP objective banner has OnEnter/OnLeave for its tooltip. Wants that \
handler, not the flag.",
},
// ── We take the mouse where the reference does not ─────────────────────────────────────────
//
// The merchant rows' divergence RETIRED (1751): our `MerchantFrame.xml` is gone and the
@@ -303,12 +193,9 @@ const KNOWN: &[Known] = &[
// entries here — twelve `MerchantItem<N>` plus `MerchantBuyBackItem` — saying ours took the
// mouse on the row itself where the reference splits each row into an inert container plus a
// `$parentItemButton`. That is exactly what this gate exists to notice going away.
Known {
frame: "WorldMapFrame",
flag: Flag::Mouse,
why: "our map body takes the mouse so a click on it cannot reach the world behind a \
FULLSCREEN_DIALOG window; the reference relies on WorldMapButton alone",
},
// The `WorldMapFrame` mouse entry RETIRED (1980): it said our map body took the mouse where
// the reference relies on `WorldMapButton` alone — true of our transcription, and the map is
// the reference's own file now.
// ── The faux scroll panes: a different WIDGET KIND, not a missing flag ─────────────────────
//
// The reference declares each of these `<ScrollFrame …inherits="FauxScrollFrameTemplate">` and
@@ -336,24 +223,13 @@ const KNOWN: &[Known] = &[
// frame it was built to report (1751 §5: the drift instruments retire with the copies).
// ── parent ─────────────────────────────────────────────────────────────────────────────────
//
// Three, and each is a seat inside the SAME tree the reference seats it in — which is the
// question this flag exists to ask (decision 1757). A frame whose seat crosses the boundary
// between UIParent's tree and the top level is a defect, because `SetFullScreenFrame` hides
// `UIParent` and everything below it; a frame seated one rung along inside that tree is not.
Known {
frame: "SendMailBodyEditBox",
flag: Flag::Parent,
why: "the reference interposes SendMailScrollChildFrame between the pane and its content; \
our mail panes are the render approximation MailFrame.xml names at the site (flat \
art, no live scrollbar), so the body hangs off SendMailScrollFrame directly. Wants \
the real scroll child, not a re-seat.",
},
Known {
frame: "OpenMailInvoiceFrame",
flag: Flag::Parent,
why: "as SendMailBodyEditBox — OpenMailScrollChildFrame is the scroll child we do not \
build",
},
// Empty since 1970. The rows that lived here were seats inside the SAME tree the reference
// seats them in — the question this flag exists to ask (decision 1757): a frame whose seat
// crosses the boundary between UIParent's tree and the top level is a defect, because
// `SetFullScreenFrame` hides `UIParent` and everything below it; a frame seated one rung
// along inside that tree is not. The last two were our mail transcription's flat body panes,
// which hung their content off the scroll frame with no scroll child between; the mail
// window is the reference's own now, scroll children and all.
];
/// The extracted reference FrameXML directory, or `None` when the install isn't there.
@@ -694,7 +570,15 @@ fn the_shipped_frames_carry_the_references_flags() {
}
assert!(
compared > 400,
// 1948 retired our ChatFrame.xml, 1952 our SpellBookFrame.xml, 1953 our PetActionBar.xml,
// 1956 our SkillFrame.xml, 1958 our UnitPopup.xml and 1959 our FriendsFrame.xml (their
// frames are the reference's own now), which took the paired count from the low 400s to
// the high 150s; 1966 (TradeFrame), 1968 (GameTooltip), 1969 (DressUpFrame) and 1970
// (MailFrame) to the high 80s, and 1980 (WorldMapFrame, whose 50-odd blip frames were
// most of what was left) to the high teens, and 1987 (the micro row) to nine. The floor
// guards the pairing, not the census — it comes down with every window that migrates,
// and reaches zero with the last file of ours that declares a reference-named frame.
compared > 5,
"only {compared} frames compared — the pairing broke, and the sweep guards nothing"
);
assert!(
+210 -183
View File
@@ -9,39 +9,32 @@
use benilla_ui::script::{FriendInfo, SocialRequest, SocialState, UiScript, WhoInfo};
use super::test_ui::load_ui_strict as load_xml;
/// The window's own manifest slice, in `load_default_ui` order.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
// The row right-click menu is the shared UnitPopup "FRIEND" menu, so the window's slice of
// the manifest includes it (it loads well before FriendsFrame.xml in the real order).
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
// The guild pane's frames inherit the reference's shared UIPanelButtonTemplate /
// UIPanelCloseButton / UIPanelScrollFrameTemplate rather than a private copy (decision 1257),
// so this file's slice needs the shared kit — and the strict `load_xml` above is what would
// otherwise let those frames load art-less and silent.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
// The social window's fourth tab lives in its own file, and it is part of THIS window's
// manifest slice now: `BENILLA_FRIENDS_SUBFRAMES` names "RaidFrame", and both
// `FriendsFrame_ShowSubFrame` and `FriendsFrame_OnHide` resolve every name in that list
// through `getglobal` and call `:Hide()` on it. The reference's list names it too and never
// guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in
// the harness's load order, not in shipped Lua defending against a state the client cannot be
// in (decision 1549).
load_xml(&s, "RaidFrame.xml");
super::test_ui::load_social_ui(&mut s);
s
}
/// One `/who` row as the app's feed would have resolved it.
fn who(name: &str, level: u32, class: &str, zone: &str) -> WhoInfo {
WhoInfo {
name: name.to_string(),
guild: String::new(),
level,
race: "Human".to_string(),
class: class.to_string(),
zone: zone.to_string(),
}
}
/// The name painted into who row `row`.
fn who_name(s: &UiScript, row: u32) -> String {
s.eval::<String>(&format!("return WhoFrameButton{row}Name:GetText()"))
.unwrap()
}
fn friend(name: &str, level: u32, class: &str, area: &str, connected: bool) -> FriendInfo {
FriendInfo {
name: name.to_string(),
@@ -126,6 +119,45 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
assert!(!s.eval::<bool>("return FriendsFrame:IsVisible()").unwrap());
}
/// **The social window's tabs fit their labels on the first show — through TWO inheritance hops.**
///
/// `FriendsFrameTab1..4` inherit `FriendsFrameTabTemplate`, which inherits
/// `CharacterFrameTabButtonTemplate` (the reference's own file, on the chain since 1993). The
/// middle template declares an `<OnClick>` and nothing else, and handler replacement is **per
/// handler name** (wow-re `template-onload-replacement-law.md`) — so the base template's
/// `<OnShow>` fit still runs, two hops down. That is the arrangement this pins: a row of tabs
/// still wearing the base template's authored 115 would mean the OnShow was lost on the way.
#[test]
fn the_social_tabs_fit_their_labels_on_the_first_show() {
let _data = benilla_formats::wow_data_or_skip!();
/// `2 * FriendsFrameTab1Left:GetWidth()` — the big tab's two 20-unit end slices.
const SIDES: f64 = 40.0;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The app installs `AtlasMeasurer`; the reference's fit is inline, so a harness that models
// the async round trip models a configuration the app does not have (1848's correction).
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
super::test_ui::load_social_ui(&mut s);
s.run("ToggleFriendsFrame(1)").unwrap();
s.resolve();
for i in 1..=4 {
let (label, width): (f64, f64) = s
.eval(&format!(
"return FriendsFrameTab{i}Text:GetStringWidth(), FriendsFrameTab{i}:GetWidth()"
))
.unwrap();
assert!(label > 0.0, "tab {i} measured its label");
assert_eq!(
width,
label + SIDES,
"tab {i} is its text plus the two end slices, from the base template's OnShow"
);
assert_ne!(width, 115.0, "tab {i} is still at the authored pre-fit");
}
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
/// A friend row shows name/zone/status on its top line and "Level N Class" underneath; an
/// OFFLINE friend takes the greyed offline template instead. This is the test that fails if
/// `GetFriendInfo`'s six returns ever come back in the wrong order.
@@ -151,17 +183,17 @@ fn friend_rows_render_the_online_and_offline_templates() {
);
assert_eq!(
s.eval::<String>("return FriendsFrameFriendButton1NameLocation:GetText()")
s.eval::<String>("return FriendsFrameFriendButton1ButtonTextNameLocation:GetText()")
.unwrap(),
"Onerogue |cffffffff- Elwynn Forest|r <AFK>"
);
assert_eq!(
s.eval::<String>("return FriendsFrameFriendButton1Info:GetText()")
s.eval::<String>("return FriendsFrameFriendButton1ButtonTextInfo:GetText()")
.unwrap(),
"Level 60 Rogue"
);
assert_eq!(
s.eval::<String>("return FriendsFrameFriendButton2NameLocation:GetText()")
s.eval::<String>("return FriendsFrameFriendButton2ButtonTextNameLocation:GetText()")
.unwrap(),
"|cff999999Twomage - Offline|r"
);
@@ -250,8 +282,19 @@ fn the_friend_buttons_queue_their_verbs() {
benilla_ui::script::PartyRequest::InviteName(n) if n == "Twomage"
)));
// Send Message is the reference's `ChatFrame_OpenChat("/w Twomage ")`: the box shows with
// the text PENDING (`editBox.setText = 1`), applied by its next OnUpdate, whose OnTextSet
// runs the parse that turns the line into a whisper to the friend (1959).
s.run("FriendsFrame_SendMessage()").unwrap();
assert_eq!(s.take_tell_requests(), vec!["Twomage".to_string()]);
s.tick(0.05);
assert!(s
.eval::<bool>("return ChatFrameEditBox:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<(String, String)>("return ChatFrameEditBox.chatType, ChatFrameEditBox.tellTarget")
.unwrap(),
("WHISPER".to_string(), "Twomage".to_string())
);
}
/// The Ignore toggle-tab swaps tab 1's list without leaving the tab, and the ignore rows render
@@ -284,12 +327,12 @@ fn the_ignore_list_is_the_other_half_of_tab_one() {
"Ignore List"
);
assert_eq!(
s.eval::<String>("return FriendsFrameIgnoreButton1Name:GetText()")
s.eval::<String>("return FriendsFrameIgnoreButton1ButtonTextName:GetText()")
.unwrap(),
"Spammer"
);
assert_eq!(
s.eval::<String>("return FriendsFrameIgnoreButton2Name:GetText()")
s.eval::<String>("return FriendsFrameIgnoreButton2ButtonTextName:GetText()")
.unwrap(),
"Ninja"
);
@@ -308,9 +351,12 @@ fn the_ignore_list_is_the_other_half_of_tab_one() {
assert!(s
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
// The reference's `ShowIgnorePanel` shows the WINDOW (its tab switch is commented out in the
// stock file), so the list that was up stays up — the friends list here (1959).
s.run("ShowIgnorePanel()").unwrap();
assert!(s.eval::<bool>("return FriendsFrame:IsVisible()").unwrap());
assert!(s
.eval::<bool>("return IgnoreListFrame:IsVisible()")
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
}
@@ -409,6 +455,60 @@ fn the_who_dropdown_switches_the_variable_column() {
);
}
/// **B365's retest, pinned.** The Who list's column headers sort, the same header clicked twice
/// REVERSES, and the earlier click survives as a tie-breaker — the reference's seven-slot chain
/// (`SortWho 0x5ad890`, decision 2030).
///
/// What makes this the *window's* test rather than the chain's: every assertion reads the painted
/// row straight after `Click()`, with **no feed tick in between**. That can only pass if
/// `WHO_LIST_UPDATE` fires synchronously from inside the binding, the way `SignalEvent` does — the
/// half of the bug the director could see, since our old sort redrew a tick later and only ever
/// ascended.
#[test]
fn the_who_headers_sort_and_a_repeated_click_reverses() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
push(
&mut s,
SocialState {
who: vec![
who("Galas", 60, "Warrior", "Elwynn Forest"),
who("Erdrin", 12, "Mage", "Elwynn Forest"),
],
who_total: 2,
..Default::default()
},
"WHO_LIST_UPDATE",
);
let _ = s.take_social_requests();
assert_eq!(who_name(&s, 1), "Galas", "the server's order, unsorted");
// Header 1 is Name (`FriendsFrame.xml:1313`).
s.run("WhoFrameColumnHeader1:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Erdrin", "Name, ascending");
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::SortWho("name".to_string())],
"and the app hears the click too"
);
s.run("WhoFrameColumnHeader1:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Galas", "the same header again reverses");
// Header 3 is Level (`:1394`) — ascending puts the level 12 first.
s.run("WhoFrameColumnHeader3:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Erdrin", "Level, ascending");
s.run("WhoFrameColumnHeader3:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Galas", "and Level reverses too");
// Back to Name: promoted from behind, it keeps the descending direction it was left in
// rather than flipping — the chain's memory, not a per-click toggle.
s.run("WhoFrameColumnHeader1:Click()").unwrap();
assert_eq!(who_name(&s, 1), "Galas", "Name, still descending");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The who buttons need a selected row, and selecting one enables both. A fresh answer clears
/// the selection — row 3 of the last query is not row 3 of this one.
#[test]
@@ -463,10 +563,13 @@ fn the_who_buttons_need_a_selected_row() {
},
"WHO_LIST_UPDATE",
);
// The reference keeps the selection across answers: `WhoList_Update` enables the buttons
// whenever `WhoFrame.selectedWho` is set, and its WHO_LIST_UPDATE arm clears nothing. (Our
// transcription dropped it; 1959.)
assert!(
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
.unwrap(),
"a fresh answer drops the old selection"
"a fresh answer keeps the selection"
);
}
@@ -507,57 +610,6 @@ fn the_who_edit_box_sends_its_filter() {
);
}
/// The slash bodies are the reference's: a NAMED `/friends` befriends, a bare one refreshes; a
/// bare `/who` opens the panel and fills the edit box with the default filter.
#[test]
fn the_slash_bodies_match_the_reference() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// The WhoFrame's OnLoad routes results to chat until it is shown; drain that so each
// assertion below sees only what its own call queued.
let _ = s.take_social_requests();
s.run("BenillaSlashFriends(\"Onerogue\")").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::AddFriend("Onerogue".to_string())]
);
s.run("BenillaSlashFriends(\"\")").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::RefreshFriends]
);
// `/ignore <name>` toggles rather than adds — the ref's AddOrDelIgnore.
s.run("BenillaSlashIgnore(\"Spammer\")").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::ToggleIgnore("Spammer".to_string())]
);
// A bare `/who` opens the Who panel and sends the default filter, which it also shows.
s.run("BenillaSlashWho(\"\")").unwrap();
assert!(s.eval::<bool>("return WhoFrame:IsVisible()").unwrap());
let requests = s.take_social_requests();
let sent = requests
.iter()
.find_map(|r| match r {
SocialRequest::Who(filter) => Some(filter.clone()),
_ => None,
})
.expect("a bare /who still sends a query");
assert!(
sent.starts_with("z-\""),
"the default filter is zone-scoped: {sent}"
);
assert_eq!(
s.eval::<String>("return WhoFrameEditBox:GetText()")
.unwrap(),
sent,
"and the edit box shows what was sent"
);
}
/// The Add Friend button with no friendly target opens the name-entry dialog — the first
/// customer of the popup engine's `hasEditBox` capability. Accepting sends what was typed.
#[test]
@@ -584,7 +636,9 @@ fn add_friend_without_a_target_opens_the_name_dialog() {
let _ = s.take_social_requests();
s.run("StaticPopup1EditBox:SetText(\"Onerogue\")").unwrap();
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
// Through the button: the stock dialog's OnAccept reads `this:GetParent()`, which only a
// real click seats.
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(
s.take_social_requests(),
vec![SocialRequest::AddFriend("Onerogue".to_string())]
@@ -664,7 +718,14 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
Some(1),
"the menu has a Whisper row"
);
assert_eq!(s.take_tell_requests(), vec!["Tigole".to_string()]);
// The stock WHISPER row is `ChatFrame_SendTell(name)`: the chat box opens on the tell, its
// pending text applied by the box's next OnUpdate (1959).
s.tick(0.05);
assert_eq!(
s.eval::<(String, String)>("return ChatFrameEditBox.chatType, ChatFrameEditBox.tellTarget")
.unwrap(),
("WHISPER".to_string(), "Tigole".to_string())
);
}
/// An OFFLINE friend's right-click opens nothing — there is no verb to offer them, which is the
@@ -723,103 +784,69 @@ fn selecting_a_row_reads_back_in_the_same_tick() {
.contains(&SocialRequest::SelectFriend(2)));
}
/// **The window's geometry, diffed against the reference FrameXML itself.**
///
/// Every number in `FriendsFrame.xml` is a transcription of one in the reference's own file, and a
/// wrong one is invisible to every other test here: the frame loads, the clicks work, only the
/// *look* is wrong — so it surfaces as a screenshot from the director, one tab at a time. Two
/// rounds of that is what this replaces. Four of the five it caught on its first run were the
/// FriendsListFrame's values that had leaked into the IgnoreListFrame subtree, which is exactly
/// the failure mode of building a sibling pane by copy-adapting instead of transcribing.
///
/// It scrapes both files for `<AbsDimension>` pairs per named element and compares the elements
/// that exist in both (ours carry a `Benilla` prefix). Deliberately narrow — it does not try to
/// understand the XML, only to notice that a number moved. Known-benign differences are listed
/// explicitly rather than filtered by a pattern, so a NEW difference can never hide inside an
/// exemption. Skips without the extracted reference.
/// **B363 — the who list reaches its last rows.** Liho's `/who` found 49, showed 17, and the knob
/// travelled while the rows stayed. On the stock window the mechanism is a one-row loss:
/// `WhoListScrollFrame` is 287 tall (stock `FriendsFrame.xml` l.1661) against seventeen rows of
/// sixteen, so the child's overflow past the frame, `n × 16 − 287`, sits fifteen pixels under the
/// bar's `(n − 17) × 16`, and an engine that clamped `SetVerticalScroll` into that overflow
/// stopped the row offset at `n − 18`. The reference stores the bar's value as given (decision
/// 2017). Drives the bar to its end and reads the seventeenth row: the forty-ninth name.
#[test]
fn the_window_geometry_matches_the_reference_framexml() {
fn the_who_list_reaches_its_last_row_at_the_bars_end() {
let _data = benilla_formats::wow_data_or_skip!();
let Some(reference) = super::framexml_diff::reference("FriendsFrame.xml") else {
eprintln!("skipping: no extracted FrameXML");
return;
};
let mut s = setup();
s.run("ShowWhoPanel()").unwrap();
let who: Vec<WhoInfo> = (1..=49)
.map(|i| WhoInfo {
name: format!("Who{i:02}"),
guild: String::new(),
level: 60,
race: "Human".to_string(),
class: "Warrior".to_string(),
zone: "Elwynn Forest".to_string(),
})
.collect();
push(
&mut s,
SocialState {
who,
who_total: 49,
..Default::default()
},
"WHO_LIST_UPDATE",
);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Differences that are ours on purpose. Each is a *deliberate* deviation with a reason, not a
// tolerance: the list is short and every entry names why.
const EXPECTED: &[&str] = &[
// The ref omits an all-zero <Offset>; we write none at all. Same anchor, fewer bytes.
"FriendsFrameFriendButton2",
"FriendsFrameFriendButton3",
"FriendsFrameFriendButton4",
"FriendsFrameFriendButton5",
"FriendsFrameFriendButton6",
"FriendsFrameFriendButton7",
"FriendsFrameFriendButton8",
"FriendsFrameFriendButton9",
"FriendsFrameFriendButton10",
"FriendsFrameIgnoreButton2",
"FriendsFrameIgnoreButton3",
"FriendsFrameIgnoreButton4",
"FriendsFrameIgnoreButton5",
"FriendsFrameIgnoreButton6",
"FriendsFrameIgnoreButton7",
"FriendsFrameIgnoreButton8",
"FriendsFrameIgnoreButton9",
"FriendsFrameIgnoreButton10",
"FriendsFrameIgnoreButton11",
"FriendsFrameIgnoreButton12",
"FriendsFrameIgnoreButton13",
"FriendsFrameIgnoreButton14",
"FriendsFrameIgnoreButton15",
"FriendsFrameIgnoreButton16",
"FriendsFrameIgnoreButton17",
"FriendsFrameIgnoreButton18",
"FriendsFrameIgnoreButton19",
"FriendsFrameIgnoreButton20",
"WhoFrameButton2",
"WhoFrameButton3",
"WhoFrameButton4",
"WhoFrameButton5",
"WhoFrameButton6",
"WhoFrameButton7",
"WhoFrameButton8",
"WhoFrameButton9",
"WhoFrameButton10",
"WhoFrameButton11",
"WhoFrameButton12",
"WhoFrameButton13",
"WhoFrameButton14",
"WhoFrameButton15",
"WhoFrameButton16",
"WhoFrameButton17",
"FriendsFrameToggleTab2",
"IgnoreFrameToggleTab2",
"FriendsFrameTopLeft",
"FriendsFrameTopRight",
"FriendsFrameBottomLeft",
"FriendsFrameBottomRight",
"WhoFrameAddFriendButton",
"WhoFrameWhoButton",
// The ref writes an all-zero <Offset> on these two row columns; we write none.
"FriendsFrameButtonTemplate/Info",
"FriendsFrameWhoButtonTemplate/Variable",
// Our column header carries the highlight's two anchors inline (the ref's sit in a
// separate <HighlightTexture> block the scrape attributes to the same element).
"WhoFrameColumnHeaderTemplate/Right",
// The three faux-scroll frames: the ref decorates each with two UI-Character-ScrollBar
// trough textures. benilla's FauxScrollFrameTemplate draws its own complete bar
// (decisions 0247/0250/0251), so the ref's loose art would double it.
"FriendsFrameFriendsScrollFrame",
"FriendsFrameIgnoreScrollFrame",
"WhoListScrollFrame",
// …and the guild pane's, the fourth of the same kind (decision 1257).
"GuildListScrollFrame",
// The window's own close button predates UIPanelTemplates.xml shipping the reference's
// `UIPanelCloseButton`, so it states inline the 32x32 that template confers. The guild
// pane's two close buttons inherit it and therefore need no entry here.
"FriendsFrameCloseButton",
];
// The reference's own numbers, and the control: the overflow is shorter than the bar.
let (_, bar_max) = s
.eval::<(f64, f64)>("return WhoListScrollFrameScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!(bar_max, 512.0, "(49 − 17) × 16");
let overflow = s
.eval::<f64>("return WhoListScrollFrame:GetVerticalScrollRange()")
.unwrap();
assert_eq!(
overflow, 497.0,
"49 × 16 − 287: the frame is taller than its rows"
);
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 210);
s.run("WhoListScrollFrameScrollBar:SetValue(512)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(WhoListScrollFrame)")
.unwrap(),
32
);
assert_eq!(
s.eval::<String>("return WhoFrameButton1Name:GetText()")
.unwrap(),
"Who33"
);
assert_eq!(
s.eval::<String>("return WhoFrameButton17Name:GetText()")
.unwrap(),
"Who49",
"the last hit is on the last row"
);
}
@@ -37,14 +37,18 @@ fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = [
"Fonts.xml",
"Interface\\FrameXML\\Fonts.xml",
// `GameMenuFrame` and the panels it opens declare `parent="UIParent"`, resolved at LOAD
// (decision 1734) — UIParent must already be there, as it is in the manifest.
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
]
.into_iter()
.chain(extra.iter().copied())
@@ -202,7 +206,14 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
#[test]
fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(&[], &["Interface\\FrameXML\\MerchantFrame.xml"]);
let mut s = bag_harness_with(
&[],
&[
"ScrollTemplates.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
@@ -239,7 +250,14 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
#[test]
fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(&[], &["Interface\\FrameXML\\MerchantFrame.xml"]);
let mut s = bag_harness_with(
&[],
&[
"ScrollTemplates.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("MainMenuBarBackpackButton:Click()").unwrap();
@@ -266,11 +284,15 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
let mut s = bag_harness_with(
&[],
&[
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
// The loot window is the reference's own since 1751 — `test_ui::LOOT_UI` carries
// what it needs and why, and PartyFrame's `MAX_PARTY_MEMBERS` is needed at LOAD.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
@@ -394,7 +416,7 @@ fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"20 seconds until logout",
"20 Seconds until logout",
"the countdown text is the engine's, from the server's 20 s clock"
);
// Cancel is the only button (the ref's CAMP_NOW is commented out in 1.12).
@@ -457,7 +479,7 @@ fn player_quiting_offers_exit_now_and_logout_cancel_closes_it() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"20 seconds until exit"
"20 Seconds until exit"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button1:GetText()")
@@ -556,12 +578,22 @@ fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness_with(&[
"GameTooltip.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"WorldMapFrame.xml",
// The stock map's OnShow/OnHide call `UpdateMicroButtons` unguarded (1980), which the
// micro menu defines over the action bar it sits in — the spellbook kit's cluster.
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
]);
// Opens normally, and takes the full-screen slot.
@@ -607,16 +639,29 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness_with(&[
"GameTooltip.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"WorldMapFrame.xml",
// The action-bar cluster the micro menu sits in — the stock map's OnShow/OnHide call
// `UpdateMicroButtons` unguarded (1980).
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\WorldMapFrame.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
// each of these buys; `PartyFrame`'s MAX_PARTY_MEMBERS is needed at LOAD time.
"UnitPopup.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
@@ -662,8 +707,24 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
// bar anchors into and seats itself above (`BenillaActionBarArt_SeatAbove`, nil-guarded in
// BagFrame.xml precisely because most harnesses load no bar).
let mut s = bag_harness_with(
&["Cooldown.xml", "ActionBar.xml"],
&["Interface\\FrameXML\\MerchantFrame.xml"],
&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
],
&[
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
@@ -752,6 +813,7 @@ fn backpack() -> ContainerState {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -787,7 +849,7 @@ fn backpack() -> ContainerState {
fn the_menu_rides_the_shared_era_window_scale() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with(&[
"GameTooltip.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
@@ -1,5 +1,5 @@
//! The group-loot roll popups (decision 0591, `assets/ui/GroupLootFrame.xml`): the four stacked
//! `GroupLootFrame`s that answer `START_LOOT_ROLL`/`CANCEL_LOOT_ROLL` off a pushed
//! The group-loot roll popups (decision 0591, stock `Interface\FrameXML\GroupLootFrame.xml`): the
//! four stacked `GroupLootFrame`s that answer `START_LOOT_ROLL`/`CANCEL_LOOT_ROLL` off a pushed
//! [`LootRollsState`] snapshot (the `loot_roll.rs` seam's own harness idiom, mirrored here the way
//! `loot_tests.rs` mirrors it for `set_loot`/`LootState`).
@@ -49,7 +49,7 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
/// `UIParent.xml` comes with it because the `START_LOOT_ROLL` router lives there now — the
/// reference's own slot for it — where our file used to carry a dedicated hidden driver frame.
fn load_group_loot(s: &UiScript) {
load_xml(s, "UIParent.xml");
// `UIParent.xml` is loaded by `setup()` above — the stock main bar needs it first (1938).
// `LootFrame.xml` brings the whole loot window, and its `GroupLootDropDown` calls
// `UIDropDownMenu_Initialize` from its own OnLoad — the dropdown kit is on the chain too.
load_xml(s, r"Interface\FrameXML\UIDropDownMenu.xml");
@@ -70,18 +70,28 @@ fn setup() -> UiScript {
// loader warns on a key with no global behind it rather than failing — which is exactly the
// kind of warning `load_ui_no_warnings` is here to catch (decision 1838).
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS + GameFontNormalSmall
// The loot window's slots inherit it — the same dependency the inspect window needed (1832).
load_xml(&s, "Interface\\FrameXML\\Fonts.xml"); // ITEM_QUALITY_COLORS + GameFontNormalSmall
// The loot window's slots inherit it — the same dependency the inspect window needed (1832).
load_xml(&s, r"Interface\FrameXML\ItemButtonTemplate.xml");
// `UIPanelCloseButton`, which the loot window's four close buttons inherit.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // PASS/NEED/GREED + item hovers
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the in-flight icon fallback) —
// buff_tests.rs's own load-order precedent for this same global.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // PASS/NEED/GREED + item hovers
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); // BENILLA_FALLBACK_ICON (the in-flight icon fallback) —
// buff_tests.rs's own load-order precedent for this same global.
s
}
@@ -144,7 +154,7 @@ fn rolls() -> LootRollsState {
fn shipped_group_loot_frame_loads_clean_and_starts_hidden() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
load_xml(&s, "UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\PartyMemberFrame.lua");
// The four roll popups arrive INSIDE the chain's loot window, so there is no exact frame count
@@ -435,25 +445,29 @@ fn in_flight_roll_does_not_error_and_falls_back() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The production ordering** — the one every other test in this file quietly assumes away.
/// **The chain frame paints once and never again — which is exactly why the app holds the event.**
///
/// `feed_loot_rolls` drains `rolls.opened` in the *same pass* that builds the snapshot, so the model
/// a `START_LOOT_ROLL` OnShow reads can never already contain the roll that just opened: the entry
/// is added to `LootRolls::active` and to `opened` in one call. Every other test here calls
/// `set_loot_rolls` **first** and so paints from a model the app would not have had yet — which is
/// exactly how a roll dialog that shipped "green" reached the director showing a `?` icon and a
/// blank name.
/// This is the Lua half of decision 2010's fix (bug B371). It drives the sequence the app used to
/// produce — `START_LOOT_ROLL` first, against a model that does not carry the roll yet — and pins
/// that *nothing repairs it*: the snapshot landing afterwards paints no name, no icon and no
/// decoration, for the life of the dialog. Our retired frame did repair it, from a benilla-only
/// `UPDATE_LOOT_ROLL(rollID)`; 1838 migrated to the stock frame, which has no such seam and never
/// listened, and 1883 removed the event.
///
/// The guarantee this pins is therefore not an ordering but a *repair*: whenever a roll's display
/// identity changes under an open frame — the snapshot finally arriving, or a late item template —
/// `UPDATE_LOOT_ROLL(rollID)` repaints it.
/// The reference is in the same position and answers it upstream, in C: `0x61b310` fires
/// `START_LOOT_ROLL` from the item-template cache's *arrival callback* (`0x61b460`) when the record
/// is not resident. So `feed_loot_rolls` holds the event until the template is in — pinned
/// app-side in `ui_loot_roll.rs`'s `the_roll_waits_for_its_item_template`, which is the test that
/// fails if the hold is lost. This one is its premise: it says what that hold is protecting
/// against, and it must keep reading blank — a repaint appearing here would mean a seam the 1.12
/// client does not have had grown back.
#[test]
fn a_roll_that_opens_before_its_snapshot_stays_blank() {
fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_group_loot(&s);
// The app's order: the event first, against a model that has no such roll at all.
// The order the app must never produce: the event first, against a model with no such roll.
s.fire_event(
"START_LOOT_ROLL",
vec![ScriptValue::Int(7), ScriptValue::Int(42_000)],
@@ -468,24 +482,23 @@ fn a_roll_that_opens_before_its_snapshot_stays_blank() {
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
None,
"nothing to paint yet — this is the state the director saw"
"nothing to paint yet — this is the state B371 reported"
);
// ...and then the snapshot carrying it lands.
s.set_loot_rolls(rolls());
s.fire_event("UPDATE_LOOT_ROLL", vec![ScriptValue::Int(7)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// **The repaint does not happen, and this is the migration's one real loss.** Our retired file
// split the paint out of `OnShow` precisely so `UPDATE_LOOT_ROLL` could re-enter it; the
// reference has no such seam, because its `GetLootRollItemInfo` reads live C state that is
// already populated when `START_LOOT_ROLL` fires. Ours reads a pushed snapshot, and while
// `feed_loot_rolls` does push it BEFORE firing, the item template can still be in flight.
// **The repaint does not happen — there is no path for it.** Our retired file split the paint
// out of `OnShow` precisely so an `UPDATE_LOOT_ROLL` could re-enter it; that is an event the
// 1.12 client does not have, and 1883 stopped firing it once the stock frame proved nothing
// listened. An adapter was tried before the hold and does not work either (1838): calling
// `GroupLootFrame_OnShow()` from inside a handler repaints nothing, and the `Hide()`/`Show()`
// round trip that does repaint only does so from a plain chunk.
//
// The fix is app-side ordering, not a Lua shim: hold the roll until its template resolves.
// Decision 1838 carries that, and the engine question an adapter ran into on the way — a
// `Hide()`/`Show()` round trip re-fires `OnShow` from a plain chunk but not from inside an
// event handler.
// So the ordering is the app's to get right, and it now is: `feed_loot_rolls` holds
// `START_LOOT_ROLL` until `Items::template` answers, which is the reference's own gate
// (decision 2010).
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
@@ -506,9 +519,8 @@ fn a_roll_that_opens_before_its_snapshot_stays_blank() {
}),
"and no icon arrives either — same missing repaint, same one cause"
);
// An update for a roll this frame does not hold still leaves it alone and still does not
// A snapshot naming a roll this frame does not hold still leaves it alone and still does not
// error — the half of this test that survives the migration intact.
s.fire_event("UPDATE_LOOT_ROLL", vec![ScriptValue::Int(999)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
@@ -532,19 +544,23 @@ fn managed_positions_engage_for_the_bare_frame_name() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
// UiPanels.xml before GroupLootFrame.xml, mirroring the shipped manifest order
// (`ui_script::load_default_ui`): the roll file's CONFIRM_LOOT_ROLL entry indexes
// `StaticPopupDialogs`, and indexing a nil there aborts the WHOLE inline <Script> chunk —
// taking every BenillaGroupLootFrame_* function down with it, not just the popup.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
// `TOOLTIP_DEFAULT_COLOR`, which the chain's dropdown backdrops read in their OnLoad — the
// dropdown kit rides in with the loot window now (1838), so this bespoke setup needs it too.
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_group_loot(&s);
let bottom = |s: &UiScript| s.eval::<f64>("return GroupLootFrame1:GetBottom()").unwrap();
@@ -559,19 +575,35 @@ fn managed_positions_engage_for_the_bare_frame_name() {
// The bar stubs carry a no-op SetPoint: `MultiBarBottomLeft` and `ShapeshiftBarFrame` are
// themselves rows in UIPARENT_MANAGED_FRAME_POSITIONS, so since those frames wear their
// reference names the pass positions them as well as reading their visibility.
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
// **The bottom-bar flags come from the SAVED GLOBALS, not from the frames.** The stock pass
// reads `SHOW_MULTI_ACTIONBAR_1`/`_2` (`UIParent.lua:1598-1606`) and never asks the bars
// whether they are shown — our retired copy asked, which is why this drive used to fake a
// frame with an `IsShown`. The fake also had no `IsObjectType`, which the pass calls on every
// row it seats (1988).
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 102.0, "60 + bottomEither 42 — the row engaged");
// The stance bar shows on top of them.
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
// The pass's shapeshift-appearance arm (the reference's own, UIParent.lua:1705-1732 — in ours
// since 1938) touches the bar's three shelf textures by name, unguarded as the reference has
// it; a stand-in frame needs stand-in textures.
s.run(
"local t = { Show = function() end, Hide = function() end } \
ShapeshiftBarLeft, ShapeshiftBarMiddle, ShapeshiftBarRight = t, t, t",
)
.unwrap();
s.run(
"ShapeshiftBarFrame = ShapeshiftBarFrame or CreateFrame(\"Frame\", \"ShapeshiftBarFrame\") \
ShapeshiftBarFrame:Show() UIParent_ManageFramePositions()",
)
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 144.0, "60 + 42 + pet 42");
// And it settles back when the stance bar hides.
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame:Hide() UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 102.0, "back to the multibar-only stack");
@@ -590,10 +622,17 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
load_group_loot(&s);
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink, the shared shift-insert helper
load_xml(&s, "DressUpFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml"); // UIParent + UIParent.lua, the reference's own (1988)
load_xml(&s, "Interface\\FrameXML\\DressUpFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
s.set_loot_rolls(rolls());
// Roll 8 (BoE Worn Shortsword) claims frame 1 — a non-BoP roll, so the dice below can land a
+20 -37
View File
@@ -22,8 +22,6 @@
use benilla_ui::script::{GuildState, ScriptValue, UiScript, UnitState};
use super::test_ui::load_ui_strict as load_xml;
/// The guild engine API, stood in for in Lua (see the module header).
///
/// One mutable table, `BenillaGuildFixture`, is the whole model; every getter reads it and every
@@ -203,25 +201,7 @@ fn setup() -> UiScript {
..UnitState::default()
}),
);
load_xml(&s, "Fonts.xml");
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
// The social window's fourth tab lives in its own file, and it is part of THIS window's
// manifest slice now: `BENILLA_FRIENDS_SUBFRAMES` names "RaidFrame", and both
// `FriendsFrame_ShowSubFrame` and `FriendsFrame_OnHide` resolve every name in that list
// through `getglobal` and call `:Hide()` on it. The reference's list names it too and never
// guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in
// the harness's load order, not in shipped Lua defending against a state the client cannot be
// in (decision 1549).
load_xml(&s, "RaidFrame.xml");
super::test_ui::load_social_ui(&mut s);
s
}
@@ -1256,9 +1236,10 @@ fn removing_a_member_names_them_in_the_confirm() {
/// 5.0→5.1 vararg swap.** The one place the window and the engine are coupled by *arity* rather
/// than by a name.
///
/// The reference walks the 5.0 vararg table with `for i = 1, arg.n`; ours walks
/// `for i = 1, select("#", ...)`. The two agree only while every one of the thirteen returns is
/// actually pushed — and with no rank loaded, ALL THIRTEEN ARE NIL (era booleans are `1`/`nil`).
/// The reference walks the 5.0 vararg table with `for i = 1, arg.n`, and that stock file is the
/// one this window loads, so the checkbox loop's bound *is* whatever the binding pushed. The two
/// agree only while every one of the thirteen returns is actually pushed — and with no rank
/// loaded, ALL THIRTEEN ARE NIL (era booleans are `1`/`nil`).
/// A binding that returned "as many values as are true" would look identical at every other
/// assertion in this file, load without an error, and leave checkboxes stale from the last rank.
/// So this one deliberately does NOT install the fixture: it asks the real `script::guild`.
@@ -1267,8 +1248,7 @@ fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select(\"#\", GuildControlGetRankFlags())")
.unwrap(),
s.arity("GuildControlGetRankFlags()").unwrap(),
13,
"an unloaded buffer is all-nil, and every one of the thirteen must still be pushed — \
`GuildControlCheckboxUpdate` drives checkbox i off argument i and nothing else"
@@ -1276,9 +1256,12 @@ fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() {
// …and each of them is nil, not `false`: the era boolean law, which `SetChecked` reads.
assert_eq!(
s.eval::<i64>(
"local n = 0 \
for i = 1, 13 do if select(i, GuildControlGetRankFlags()) ~= nil then n = n + 1 end end \
return n"
"local function count(...) \
local n = 0 \
for i = 1, 13 do if arg[i] ~= nil then n = n + 1 end end \
return n \
end \
return count(GuildControlGetRankFlags())"
)
.unwrap(),
0
@@ -1372,16 +1355,14 @@ fn the_roster_binding_answers_ten_values_and_the_tenth_is_status() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select(\"#\", GetGuildRosterInfo(1))")
.unwrap(),
s.arity("GetGuildRosterInfo(1)").unwrap(),
10,
"an out-of-range index still pushes all ten — the reference calls this with \
GetGuildRosterSelection(), which is 0 whenever nothing is selected, on every \
GuildStatus_Update pass before it ever checks `> 0`"
);
assert_eq!(
s.eval::<i64>("return select(\"#\", GetGuildRosterInfo(0))")
.unwrap(),
s.arity("GetGuildRosterInfo(0)").unwrap(),
10,
"…including index 0, the nothing-selected case the reference passes unguarded"
);
@@ -1422,11 +1403,13 @@ fn every_list_in_the_window_takes_the_mouse_wheel() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
open(&s);
// The reference's wheel lives on each list's FauxScrollFrameTemplate scroll frame, not on
// the pane (1959).
for frame in [
"FriendsListFrame",
"IgnoreListFrame",
"WhoFrame",
"GuildFrame",
"FriendsFrameFriendsScrollFrame",
"FriendsFrameIgnoreScrollFrame",
"WhoListScrollFrame",
"GuildListScrollFrame",
] {
assert!(
s.eval::<bool>(&format!(
@@ -9,7 +9,7 @@
//! anywhere in that chain files every ticket under the wrong heading with nothing on screen to
//! show for it.
use benilla_ui::script::{GmTicketIntent, GmTicketWrite, ScriptValue, UiScript};
use benilla_ui::script::{GmTicketIntent, ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
@@ -19,18 +19,39 @@ fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"BasicControls.xml",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"ScrollTemplates.xml",
"GameTooltip.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
// Before UiPanels.xml: the shared StaticPopup carries a `SmallMoneyFrameTemplate` coin
// row, whose OnLoad calls `SmallMoneyFrame_OnLoad` — the TOC's own order (1580's
// talent-wipe fixture hit this first).
"MoneyFrame.xml",
"UiPanels.xml",
"HelpFrame.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// The stock file's `HelpFrame_OnShow` calls `UpdateMicroButtons()` before
// `GetGMStatus()`, so without the micro row the OnShow raises and the status ask never
// happens. Ours never called it. The row needs the bar's button kit under it.
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
// `TicketStatusFrame_OnEvent` re-anchors TemporaryEnchantFrame before it arms the repoll
// timer, so without BuffFrame the handler raises and `refreshTime` is never set. This is
// the one ordering requirement benilla.toc already calls out for this window.
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\HelpFrame.xml",
] {
load_xml(&s, file);
}
@@ -70,107 +91,6 @@ fn open_ticket_args(
]
}
/// **The whole point of decision 1687: one click from Home to the text box.**
///
/// The category list and the per-category blurb page are gone, so this walks what is left — open
/// the window, press the one button, type, submit — and asserts the ticket goes out **uncategorised
/// (0)**. That last part is the assertion that actually pins 1687: 0 is what makes a GM's queue read
/// "Unknown" instead of a heading the player never chose.
#[test]
fn one_click_from_home_files_an_uncategorised_ticket() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("ToggleHelpFrame()").unwrap();
let _ = s.take_gm_ticket_intents(); // the OnShow GetGMStatus
assert!(
s.eval::<bool>("return HelpFrameHome:IsVisible()").unwrap(),
"the window opens on Home"
);
s.run("HelpFrameHomeIssues:Click()").unwrap();
assert!(
s.eval::<bool>("return HelpFrameOpenTicket:IsVisible()")
.unwrap(),
"one click lands on the editor — no category page in between"
);
s.run("HelpFrameOpenTicketText:SetText(\"My sword vanished.\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
assert_eq!(
s.take_gm_ticket_intents(),
vec![GmTicketIntent::Write(GmTicketWrite {
category: 0,
text: "My sword vanished.".into(),
is_new: true,
})],
"a create files under 0 — \"Unknown\" in the GM's queue"
);
}
/// **Auto-Unstuck sits on Home now**, beside the ticket button — it used to be the Stuck category's
/// action button, three pages down, which is nowhere.
///
/// Clicked through the real button rather than by calling the handler, because the button's
/// existence on Home *is* the change.
#[test]
fn auto_unstuck_is_a_button_on_home() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("ToggleHelpFrame()").unwrap();
s.run("BenillaHelpFrameHomeUnstick:Click()").unwrap();
assert_eq!(s.take_stuck_casts(), 1);
assert!(
!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap(),
"and it closes the window"
);
}
/// **The two Home buttons do not overlap, and both fit their labels.**
///
/// The layout is ours (the reference had one button here), and its whole trick is that the left
/// button hangs by its TOPRIGHT and the right one off that fixed edge, so fitting them to their
/// text grows them *away* from the gutter. Nothing else in the suite would notice them landing on
/// top of each other: a script error inside `OnShow` is swallowed into the VM's error record, so a
/// fit that never ran still leaves every other test green.
#[test]
fn the_two_home_buttons_sit_side_by_side_without_overlapping() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("ToggleHelpFrame()").unwrap();
s.resolve();
let edge = |frame: &str, edge: &str| {
s.eval::<f32>(&format!("return {frame}:Get{edge}()"))
.unwrap_or_else(|e| panic!("{frame}:Get{edge}(): {e}"))
};
let (ticket_l, ticket_r) = (
edge("HelpFrameHomeIssues", "Left"),
edge("HelpFrameHomeIssues", "Right"),
);
let (stuck_l, stuck_r) = (
edge("BenillaHelpFrameHomeUnstick", "Left"),
edge("BenillaHelpFrameHomeUnstick", "Right"),
);
assert!(
ticket_r <= stuck_l,
"the buttons overlap: ticket ends at {ticket_r}, unstick starts at {stuck_l}"
);
assert!(
ticket_r > ticket_l && stuck_r > stuck_l,
"both buttons must have width: {ticket_l}..{ticket_r}, {stuck_l}..{stuck_r}"
);
// The fit ran: an unfitted button keeps its authored 250px, and "Auto-Unstuck" is nowhere near
// that wide. This is what catches an OnShow that silently errored out.
assert!(
stuck_r - stuck_l < 200.0,
"the unstick button was never fitted to its label (width {})",
stuck_r - stuck_l
);
}
/// **`UPDATE_TICKET`'s two faces.** With a ticket the editor becomes an editor (Save Changes /
/// Exit); with the bare `arg1 = 0` it goes back to being a form (Submit / Cancel). The zero leg is
/// the one that would silently rot: it is the ordinary answer, so a window stuck in edit mode
@@ -219,81 +139,6 @@ fn an_open_ticket_turns_the_form_into_an_editor_and_a_zero_turns_it_back() {
);
}
/// The Submit button picks its verb from the window's own `hasTicket` — an edit after an answer is
/// an UPDATE, a submit before one is a CREATE — **and the two legs send different categories**.
///
/// That asymmetry is decision 1687's real content, and it is the half a suite would otherwise miss.
/// A create has nothing to go on and sends 0. An edit sends the server's own category straight
/// back, because `HandleGMTicketUpdateTextOpcode` overwrites the field with whatever arrives: send
/// 0 there and a GM who re-filed the ticket onto a real heading watches it drop to "Unknown" the
/// moment the player fixes a typo. Nothing on screen would ever show that.
#[test]
fn a_create_files_under_zero_and_an_edit_gives_the_server_its_category_back() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
let _ = s.take_gm_ticket_intents(); // the OnShow GetGMStatus
// A stale `ticketType` must NOT leak into a create — the window has no picker, so a create is
// uncategorised no matter what is lying around on the frame.
s.run("HelpFrameOpenTicket.ticketType = 3 HelpFrameOpenTicketText:SetText(\"a\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
let intents = s.take_gm_ticket_intents();
let [GmTicketIntent::Write(create)] = intents.as_slice() else {
panic!("expected exactly one write, got {intents:?}");
};
assert!(create.is_new, "no ticket known yet — this is a create");
assert_eq!(create.category, 0, "a create is uncategorised");
// The server answers: this ticket is filed under 4 (Item) — a GM moved it there.
s.fire_event("UPDATE_TICKET", open_ticket_args(4, "a", 0.1, 0.2, 0.01));
s.run("HelpFrameOpenTicketText:SetText(\"a, still\")")
.unwrap();
s.run("HelpFrameOpenTicketSubmit_OnClick()").unwrap();
let intents = s.take_gm_ticket_intents();
let [GmTicketIntent::Write(edit)] = intents.as_slice() else {
panic!("expected exactly one write, got {intents:?}");
};
assert!(!edit.is_new, "a ticket is known — this is an update");
assert_eq!(edit.text, "a, still");
assert_eq!(
edit.category, 4,
"the edit must hand the GM's own category back, not overwrite it with 0"
);
}
/// **The queue gate.** `UPDATE_GM_STATUS(0)` takes the petition queue down, and asking for the
/// editor then closes the window and says why instead of showing a form that cannot submit.
/// The `1` leg must put it back — a one-way gate would lock the player out for the session.
#[test]
fn a_downed_queue_refuses_the_editor_and_says_so_and_comes_back_up() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.fire_event("UPDATE_GM_STATUS", vec![ScriptValue::Int(0)]);
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
assert!(
!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap(),
"the window closes"
);
assert!(
s.eval::<bool>("return StaticPopup_Visible(\"HELP_TICKET_QUEUE_DISABLED\") ~= nil")
.unwrap(),
"and the dialog says why"
);
s.fire_event("UPDATE_GM_STATUS", vec![ScriptValue::Int(1)]);
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"OpenTicket\")")
.unwrap();
assert!(
s.eval::<bool>("return HelpFrameOpenTicket:IsVisible()")
.unwrap(),
"queue back up, editor opens"
);
}
/// The toast follows the ticket: up while one is open, gone when it is not. It is the only thing
/// on screen that says a ticket exists at all once the window is closed.
#[test]
@@ -345,23 +190,6 @@ fn the_toast_repolls_the_server_only_after_the_full_interval() {
);
}
/// Abandoning goes through a confirm, and only its Yes sends the delete. A dialog whose Yes did
/// nothing would look identical to one that worked, right up until the ticket reappeared.
#[test]
fn abandoning_a_ticket_confirms_first_and_only_yes_sends_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.run("StaticPopup_Show(\"HELP_TICKET_ABANDON_CONFIRM\")")
.unwrap();
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert!(s.take_gm_ticket_intents().is_empty(), "No sends nothing");
s.run("StaticPopup_Show(\"HELP_TICKET_ABANDON_CONFIRM\")")
.unwrap();
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_gm_ticket_intents(), vec![GmTicketIntent::Delete]);
}
/// `ToggleHelpFrame` is the micro button's whole wiring, and opening the window asks the server
/// for the queue status — without that ask the gate above would run on a stale assumption for the
/// life of the session.
@@ -385,88 +213,3 @@ fn toggling_the_window_opens_it_and_asks_for_the_queue_status() {
s.run("ToggleHelpFrame()").unwrap();
assert!(!s.eval::<bool>("return HelpFrame:IsVisible()").unwrap());
}
/// **The retail-only text is gone from the Home page** (director's call, 2026-08-29).
///
/// The page is entirely GlobalStrings off the player's own chain, so three of its strings still
/// pointed at `worldofwarcraft.com` — a dead PvP-policy link, and a closing paragraph directing the
/// player to Blizzard's forums and policy pages. The window overrides them before the markup
/// resolves.
///
/// This test exists because the failure mode is *invisible in a test suite*: nothing breaks if the
/// override is dropped, the page simply starts advertising dead links again. Asserted on the
/// rendered FontStrings rather than the globals, so it also catches the markup being re-pointed at
/// a different key.
#[test]
fn the_home_page_advertises_no_dead_retail_links() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
s.run("ShowUIPanel(HelpFrame) HelpFrame_ShowFrame(\"Home\")")
.unwrap();
for frame in [
"HelpFrameHomePvpPolicyUrl",
"HelpFrameHomeText2",
"HelpFrameHomeIssue3",
"HelpFrameHomeText1",
] {
let text = s
.eval::<String>(&format!("return {frame}:GetText() or \"\""))
.unwrap()
.to_lowercase();
for dead in [
"worldofwarcraft.com",
"http://",
"www.",
".shtml",
"the forums",
] {
assert!(
!text.contains(dead),
"{frame} still carries retail-only text ({dead:?}): {text:?}"
);
}
}
// The PvP guidance itself is KEPT — only the sentence introducing the link went. A test that
// let the whole bullet be emptied would pass on an over-correction, which is the other way to
// get this wrong.
let pvp = s
.eval::<String>("return HelpFrameHomeIssue3:GetText()")
.unwrap();
assert!(
pvp.contains("PVP game mechanics"),
"the PvP guidance must survive the link's removal: {pvp:?}"
);
}
/// **The geometry oracle** (decision 0675): every element the transcription shares with the
/// reference file carries the reference's own `<AbsDimension>` numbers.
///
/// **Verified to fail**, as 0675 requires, and re-verified after 1687 lowered `min_compared` from
/// 60 to 28: nudging `TicketStatusFrame`'s width 208 → 209 reports
/// `TicketStatusFrame: ours [(209.0, 52.0), …] != ref [(208.0, 52.0), …]` and fails. Re-checking
/// that after shrinking the floor is the point — a guard whose floor drops below what it actually
/// compares stops guarding and never says so.
#[test]
fn the_windows_geometry_matches_the_reference_file() {
let _data = benilla_formats::wow_data_or_skip!();
let Some(reference) = super::framexml_diff::reference("HelpFrame.xml") else {
return; // no install — this test is a no-op rather than a failure
};
/// Deliberate deviations, by REFERENCE name. Each earns its reason here; a tolerance would
/// let a real difference hide, so this is a list and stays a list.
const EXPECTED: &[&str] = &[
// The ticket editor's scroll frame carries the house kit's shared trough in place of the
// three loose bar textures the reference hangs beside it (HelpFrame.xml's header).
"HelpFrameOpenTicketScrollFrame",
// Home's button row is ours: the reference has one button centred under the text, we have
// two, so the left one hangs by its TOPRIGHT at -6 rather than centred at 0. Its SIZE still
// matches, which is why only the offset differs.
"HelpFrameHomeIssues",
];
// 1687 deleted the category list and the per-category page, and roughly seventy named elements
// went with them — hence 28 rather than 60. The floor still has to bite: it is what stops this
// guard quietly comparing nothing after the next window-shrinking change.
super::framexml_diff::assert_geometry_matches("HelpFrame.xml", &reference, EXPECTED, 28);
}
@@ -96,6 +96,11 @@ fn alliance_player() -> UnitState {
level: 60,
sex: 2,
faction_group: Some("Alliance".into()),
// The rank title's team digit is `pvp_team` (`0x5efe00`: race → ChrRaces →
// FactionTemplate), NOT `faction_group` (`UnitFactionGroup`: the live template). Human is
// race 1 → Alliance → 1. Both are seated because a real Alliance player has both; the GM
// test below is what drives them apart.
pvp_team: crate::ui_unit::race_pvp_team(1),
race: Some("Human".into()),
race_file: Some("Human".into()),
class: Some("Warrior".into()),
@@ -206,6 +211,68 @@ fn an_unranked_character_reads_none_and_shows_no_badge() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Report B378 — a GM-flagged Grand Marshal's Highest Rank read `None`.** The regression test
/// for decision 2227, and the one that holds the two sides apart.
///
/// vmangos forces a GM to faction template 35, whose group mask is 0, so `UnitFactionGroup`
/// genuinely answers nil — that half is faithful and the PvP icon is *supposed* to vanish with it.
/// The rank title is not on that wire at all: `0x5efe00` reads the unit's RACE through
/// `ChrRaces`/`FactionTemplate` (`[obj+0x110]+0x78`), and a GM's race does not change, which is why
/// the reporter's own 1.12 client on the same server read "Grand Marshal" from the same byte. We
/// had the title keyed off the faction group, so both rows went to NONE together.
///
/// The control is the pair: the two rank rows must name the rank **and** the bar must still take
/// the sideless arm's Horde red — a fix that "restored the side" would pass the first half and
/// silently break the second. Skips without client data.
#[test]
fn a_gm_flagged_player_keeps_his_rank_title_and_loses_only_the_faction_group() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_honor_page();
s.set_honor(Some(HonorState {
// Grand Marshal — internal 18, the top of the Alliance list.
rank: 18,
highest_rank: 18,
..state()
}));
s.set_unit(
"player",
Some(UnitState {
// `.gm on`: template 35, group mask 0 → no side.
faction_group: None,
faction_group_localized: None,
pvp_rank: 18,
..alliance_player()
}),
);
s.run("HonorFrame_Update(1)").unwrap();
s.resolve();
assert_eq!(
text(&mut s, "HonorFrameLifeTimeRankValue"),
"Grand Marshal",
"the Highest Rank row — the reported symptom"
);
assert_eq!(
text(&mut s, "HonorFrameCurrentPVPTitle"),
"Grand Marshal",
"and the title under the badge, which reads the same key"
);
assert!(
s.eval::<bool>(r#"return UnitFactionGroup("player") == nil"#)
.unwrap(),
"the side itself is still gone — that half of GM mode is the reference's"
);
// The control: the bar's colour is `UnitFactionGroup`'s, so a sideless player takes the
// `else` arm (Horde red, `HonorFrame.lua:68`) and must KEEP taking it.
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return HonorFrameProgressBar:GetStatusBarColor()")
.unwrap();
assert!(
r > g && r > b,
"a sideless player keeps the else-arm's red bar, got ({r}, {g}, {b})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bar takes the 0..1 fraction straight, and wears the player's own faction colour. Alliance
/// navy here; the `else` arm is Horde red for everyone else, including a unit whose side has not
/// resolved — a bar with no colour at all would read as a broken pane.
@@ -298,11 +365,11 @@ fn the_real_global_strings_name_the_rank() {
load_page(&s);
s.set_honor(Some(state()));
for (group, want) in [("Alliance", "Knight-Captain"), ("Horde", "Legionnaire")] {
for (team, want) in [(1i8, "Knight-Captain"), (0, "Legionnaire")] {
s.set_unit(
"player",
Some(UnitState {
faction_group: Some(group.into()),
pvp_team: team,
..alliance_player()
}),
);
@@ -312,7 +379,7 @@ fn the_real_global_strings_name_the_rank() {
assert_eq!(
text(&mut s, "HonorFrameCurrentPVPTitle"),
want,
"rank 12 to the {group}"
"rank 12 to team {team}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -366,27 +433,35 @@ fn shown_inspect_honor_page() -> UiScript {
s.set_screen_size(1024.0, 768.0);
s.run(RANK_GLOBALS).unwrap();
for file in [
"Fonts.xml",
"Interface\\FrameXML\\Fonts.xml",
// `TEXT`, the stock level line's formatter.
"BasicControls.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The inspect window's four tabs (`Blizzard_InspectUI`) inherit it (1993).
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// `InspectUnit`'s home since 1832.
"UIParent.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\GameTooltip.xml",
// Before the inspect addon — its honor page inherits this file's row templates and calls
// its two shared painters, and `inherits=` resolves at load (the manifest's own order).
// The stock inspect slot buttons inherit this (1832).
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\HonorFrame.xml",
"Interface\\AddOns\\Blizzard_InspectUI\\Blizzard_InspectUI.xml",
"Interface\\AddOns\\Blizzard_InspectUI\\InspectPaperDollFrame.xml",
"Interface\\AddOns\\Blizzard_InspectUI\\InspectHonorFrame.xml",
] {
load_xml(&s, file);
}
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `InspectFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI");
s.run("InspectFrame_LoadUI()").unwrap();
s.set_unit("target", Some(alliance_player()));
// The PLAYER snapshot is required even though this page is about somebody else, and the reason
// is the reference's own asymmetry: `BenillaHonorPane_SetRank` passes the inspected token for
+35 -15
View File
@@ -1,5 +1,5 @@
//! The player-UI input pass: [`feed_ui_input`] hit-tests the cursor and dispatches mouse/keyboard
//! events into the UI engine (after [`super::extract::drive_script`] has resolved the frame's
//! events into the UI engine (after [`super::extract::tick_script`] has resolved the frame's
//! rects), plus the action-bar key map. The OS pasteboard itself lives in [`crate::textinput`].
//! Split out of [`super`] purely for size — the plugin wiring and the extraction pass live there
//! and in [`super::extract`] respectively.
@@ -59,10 +59,10 @@ impl PointerFeed<'_> {
}
/// Feed the window's cursor + buttons + wheel + keyboard into the UI engine (after
/// [`super::extract::drive_script`] has resolved this frame's rects), firing
/// [`super::extract::tick_script`] has resolved this frame's rects), firing
/// OnEnter/OnLeave/OnClick/OnMouseWheel and the EditBox
/// char/key dispatch, publishing [`PlayerUiHover`] (so the pointer arbiter yields world-pick/camera to
/// the UI) and [`UiKeyboardCapture`] (so gameplay/dev keyboard readers yield to a focused box).
/// the UI) and [`UiKeyboardCapture`] (so a key a box or frame ate never also fires its binding).
///
/// Runs in [`UiInput`], before `WorldStage::Input` and every other keyboard reader — a key a focused
/// box consumes must never also reach the world in the same frame.
@@ -110,12 +110,14 @@ pub(super) fn feed_ui_input(
let Some(mut script) = script else {
capture.typing = false;
capture.arrows_fall_through = false;
capture.consumed.clear();
payload_held.0 = false;
return;
};
let Ok((window, raw_handle)) = window.single() else {
capture.typing = false;
capture.arrows_fall_through = false;
capture.consumed.clear();
payload_held.0 = false;
return;
};
@@ -137,8 +139,14 @@ pub(super) fn feed_ui_input(
// nobody can see must not eat the click or arm a tooltip, and the else-arm below is exactly the
// "no pointer here" bookkeeping (leave the hovered frame once, disarm any press/drag every
// frame) that keeps a stale gesture from firing when the UI comes back.
// The headless hover probe's aim stands in for a cursor the window does not have (2250/2255),
// so `PointerOverUi` rises over a panel and falls off it in an automated run exactly as it does
// for a person — which is what lets a rig run reproduce "open the map, close it, and the world
// under it goes quiet". A person's pointer always wins; an unarmed probe answers `None` and
// nothing here changes.
if let Some(cursor) = window
.cursor_position()
.or_else(crate::target::hover_probe_point)
.filter(|_| !ui_hidden && !synthetic)
{
// Window cursor is logical px, y-down from top-left; the UI is y-up 768-virtual units
@@ -156,7 +164,12 @@ pub(super) fn feed_ui_input(
let metering =
*HIT_COST.get_or_init(|| std::env::var("WOW_HIT_COST").as_deref() == Ok("1"));
let t0 = metering.then(std::time::Instant::now);
hover.0 = script.mouse_move(x, y);
// The world frame is mouse-enabled by construction and so a legitimate hover target for
// an addon's handlers, but its hit is the WORLD's (decision 1983): camera look, world
// clicks and hover targeting stay live over it.
hover.0 = script
.mouse_move(x, y)
.filter(|id| !script.is_world_frame(*id));
if let Some(t0) = t0 {
use std::sync::atomic::{AtomicU64, Ordering};
static ACC_US: AtomicU64 = AtomicU64::new(0);
@@ -224,6 +237,8 @@ pub(super) fn feed_ui_input(
// box) but BEFORE feeding keys (an Escape that clears focus is still "captured" this frame, so the
// world doesn't also act on it). Gameplay/dev readers run after `UiInput` and see this value.
capture.typing = script.has_keyboard_focus();
// The per-key half is this frame's alone (a frame's existence gate ate THIS key).
capture.consumed.clear();
// ── The alt-arrow exemption (wow-re `ignorearrows-alt-arrow-gate.md`, §5 VERIFIED) ─────────
// A focused EditBox in alt-arrow mode (`ignoreArrows` in XML, `SetAltArrowKeyMode` in Lua)
// does NOT consume LEFT/UP/RIGHT/DOWN unless ALT is held: the reference's handler returns 0
@@ -286,7 +301,10 @@ pub(super) fn feed_ui_input(
};
if let Some(name) = frame_named {
if script.frame_key_input(name) {
capture.typing = true; // consumed by a frame: its binding must not also fire
// Consumed by a frame: THIS key's binding must not also fire. Not `typing` — a
// frame eating a key is not a text box taking focus, and reading it as one is what
// stopped a held W dead when the world map ate the `M` that closed it (2196).
capture.consumed.push(ev.key_code);
continue;
}
}
@@ -303,14 +321,15 @@ pub(super) fn feed_ui_input(
// handler has to call `RunBinding("SCREENSHOT")` **by hand** to get one key back. It would
// not need to if unhandled keys fell through to their bindings.
//
// Consumption sets the capture gate and nothing else: it suppresses the binding and the
// world/gameplay readers, and deliberately does NOT suppress `char_input` below — `OnChar`
// is a separate channel off a separate dispatcher (`0x765df0`), which is exactly how the
// stack-split spinner receives a digit whose key-down its own `OnKeyDown` already ate.
// Consumption suppresses THE KEY'S BINDING and nothing else. It deliberately does NOT
// suppress `char_input` below — `OnChar` is a separate channel off a separate dispatcher
// (`0x765df0`), which is exactly how the stack-split spinner receives a digit whose
// key-down its own `OnKeyDown` already ate. And it is emphatically not a focus change: it
// releases nothing that is already held (2196).
else if named.is_none() {
if let Some(token) = crate::bindings::chord::key_token(ev.key_code) {
if script.frame_key_input(token) {
capture.typing = true;
capture.consumed.push(ev.key_code);
}
}
}
@@ -321,11 +340,12 @@ pub(super) fn feed_ui_input(
// dispatch (decision 0997, `crate::bindings`), which runs right after this pass and
// reads the capture gate written above, so a key a focused box consumed this frame
// never also fires its binding — the real client's ESC precedence, table-wide.
// Consumption suppresses the binding — a keyboard FRAME that took this key counts
// exactly as a focused box does (decision 1319; `capture.typing` above already covers the
// box case, and this adds the frame one).
// Consumption suppresses this key's binding — a keyboard FRAME that took the key
// counts as the focused box does for *that key* (decision 1319; `capture.typing` above
// covers the box, which takes every key for as long as it holds focus, and this adds
// the frame's one-key case).
if script.key_input(name) {
capture.typing = true;
capture.consumed.push(ev.key_code);
}
} else if let Some(chord) = chord {
// The gate, on the KEY rather than the action: a gated arrow is not handed to the box
@@ -377,7 +397,7 @@ pub(super) fn feed_ui_input(
// row IS a binding (the action buttons), so a digit typed into a keyboard frame —
// the stack-split spinner — must not also fire action button 3 (decision 1319).
if script.char_input(text) {
capture.typing = true;
capture.consumed.push(ev.key_code);
}
}
}
@@ -1,7 +1,7 @@
//! The shipped **inspect window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/InspectFrame.xml` loaded behind `Fonts.xml`/`UiPanels.xml`/`GameTooltip.xml` and fed a
//! synthetic target snapshot + a foreign equipment view (decision 0631). `character_tests.rs`'s
//! harness, turned onto the other paper doll.
//! The reference's own `Blizzard_InspectUI` addon loaded behind
//! `Fonts.xml`/`UIParent.xml`/`GameTooltip.xml` and fed a synthetic target snapshot + a foreign
//! equipment view (decision 0631). `character_tests.rs`'s harness, turned onto the other paper doll.
//!
//! What these are here to falsify, in order of how quietly it could have shipped broken:
//!
@@ -114,17 +114,22 @@ fn armed() -> UiScript {
// `PLAYER_LEVEL`, the template the stock level line formats through — the reference keeps its
// strings here, and our retired file had the sentence written into it (1832).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// The inspect window's four tabs (`Blizzard_InspectUI`) inherit it (1993).
load_xml(&s, r"Interface\FrameXML\CharacterFrameTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
@@ -134,18 +139,11 @@ fn armed() -> UiScript {
// this entry far above the inspect window all along (decision 1832).
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\Blizzard_InspectUI.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectPaperDollFrame.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectHonorFrame.xml",
);
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `InspectFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI");
s.run("InspectFrame_LoadUI()").unwrap();
s.set_unit("target", Some(target_unit()));
s.set_inspect(Some(inspect_view("target")));
// 4 yards away (d² = 16) — comfortably inside the verified 100.0.
@@ -157,21 +155,26 @@ fn armed() -> UiScript {
#[test]
fn shipped_inspect_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
let mut s = UiScript::new().unwrap();
// `PLAYER_LEVEL`, the template the stock level line formats through — the reference keeps its
// strings here, and our retired file had the sentence written into it (1832).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// The inspect window's four tabs (`Blizzard_InspectUI`) inherit it (1993).
load_xml(&s, r"Interface\FrameXML\CharacterFrameTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
@@ -181,18 +184,11 @@ fn shipped_inspect_frame_loads_clean() {
// this entry far above the inspect window all along (decision 1832).
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\Blizzard_InspectUI.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectPaperDollFrame.xml",
);
load_xml(
&s,
"Interface\\AddOns\\Blizzard_InspectUI\\InspectHonorFrame.xml",
);
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `InspectFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI");
s.run("InspectFrame_LoadUI()").unwrap();
// All 19 slots exist and carry their GetInventorySlotInfo id (1..=19, no ammo slot).
// The stock `InspectPaperDollItemSlotButton_OnLoad` puts it on the frame's own ID
// (`this:SetID(id)`); our retired file kept it in an `invSlotId` field of its own (1832).
@@ -15,6 +15,7 @@ use super::test_ui::load_ui as load_xml;
/// `ResetInstances` a queue.
#[test]
fn the_three_bindings_have_the_reference_shapes() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// Nothing pushed yet: the app has not said where we are.
@@ -66,13 +67,17 @@ fn the_three_bindings_have_the_reference_shapes() {
/// and Yes is the only thing that sends.
#[test]
fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The row labels a bare harness has no GlobalStrings.lua for. Verbatim 1.12 values
// (`RESET_INSTANCES` l.3342, `CONFIRM_RESET_INSTANCES` l.851, `YES` l.5463, `NO` l.2794) —
// production runs the player's own string table at boot.
// (`RESET_INSTANCES` l.3342, `CONFIRM_RESET_INSTANCES` l.851, `YES` l.5463, `NO` l.2794,
// `GROUP` l.2029) — production runs the player's own string table at boot.
s.run(
r#"
-- The stock raid pane concatenates this into each of its eight group headers inside
-- their own OnLoad, so it has to exist before the addon loads (1874).
GROUP = "Group"
RESET_INSTANCES = "Reset all instances"
CONFIRM_RESET_INSTANCES = "Do you really want to reset all of your instances?"
YES = "Yes"
@@ -82,15 +87,19 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
)
.unwrap();
for file in [
"Fonts.xml",
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua", // `TEXT`, which StaticPopup.lua and UnitPopup.lua read at file scope
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
@@ -100,10 +109,15 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
"RaidFrame.xml",
r"Interface\FrameXML\RaidFrame.xml",
] {
load_xml(&s, file);
}
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `RaidFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_RaidUI");
s.run("RaidFrame_LoadUI()").unwrap();
s.resolve();
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
@@ -16,10 +16,11 @@
//! Keybindings redirect head; and the action bar's abbreviation law (`GetBindingText`
//! transcribed in UIParent.xml) reads `s-2`, the ref's own Lua.
//!
//! Labels here are the RAW tokens (`BINDING_HEADER_MOVEMENT`, `BUTTON3`): the harness loads no
//! GlobalStrings, exercising the page's `getglobal(...) or raw` fallback — the app's VM
//! executes the real 1.12 GlobalStrings.lua at boot, which turns them into "Movement Keys" /
//! "Middle Mouse". One test seeds a handful of the real strings to pin that path too.
//! Labels are read the way the page reads them — `KeyBindings_String(token, raw)`, the
//! GlobalStrings global with the raw token as fallback ([`label`]). The harness carries the
//! real 1.12 GlobalStrings.lua since the dialog engine became the reference's file (1960; the
//! stock StaticPopup.lua reads the strings at load), so a label here is the app's own
//! "Movement Keys" / "Jump", never the raw token the strings-less harness used to show.
use benilla_ui::script::keybind::{KeybindCommand, KeybindRequest};
use benilla_ui::script::{QuadContent, UiScript};
@@ -43,15 +44,19 @@ pub(crate) fn harness() -> UiScript {
s.register_bindings(&cmds);
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"UIParent.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"GameMenuFrame.xml",
@@ -74,34 +79,40 @@ pub(crate) fn harness() -> UiScript {
s
}
/// A label as the page resolves it: the GlobalStrings global named `token`, else `raw`.
pub(crate) fn label(s: &UiScript, token: &str, raw: &str) -> String {
s.eval::<String>(&format!(r#"return KeyBindings_String("{token}", "{raw}")"#))
.unwrap()
}
/// Open the options window on the Keybindings page.
pub(crate) fn on_page(s: &mut UiScript) {
s.run(r#"ShowUIPanel(OptionsFrame); OptionsFrame_SelectCategory("Keybindings")"#)
s.run(r#"ShowUIPanel(BenillaOptionsFrame); BenillaOptionsFrame_SelectCategory("Keybindings")"#)
.unwrap();
assert!(s.errors().is_empty(), "on page: {:?}", s.errors());
}
const ROW: &str = "OptionsFrameContainerBodyKeybindingsRow";
const ROW: &str = "BenillaOptionsFrameContainerBodyKeybindingsRow";
#[test]
fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
let mut s = harness();
on_page(&mut s);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(
s.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsVisible()")
.unwrap(),
"Unbind Key exists on this page"
);
assert!(
!s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
!s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"…disabled until a capsule is selected"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The section tree: the registry's category tokens, first-appearance order — exactly
// 1.12's file order — every section a COLLAPSED header row (the era default).
@@ -115,7 +126,7 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
let text = s
.eval::<String>(&format!("return {ROW}{}HeaderText:GetText()", i + 1))
.unwrap();
assert_eq!(&text, token, "section {} (raw-token fallback)", i + 1);
assert_eq!(text, label(&s, token, token), "section {}", i + 1);
}
assert!(
!s.eval::<bool>(&format!("return {ROW}{}:IsVisible()", expected.len() + 1))
@@ -129,14 +140,14 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
assert_eq!(
s.eval::<String>(&format!("return {ROW}2Description:GetText()"))
.unwrap(),
"MOVEANDSTEER"
label(&s, "BINDING_NAME_MOVEANDSTEER", "MOVEANDSTEER")
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}2Key1ButtonText:GetText()"))
.unwrap(),
"BUTTON3"
label(&s, "KEY_BUTTON3", "BUTTON3")
);
// With the real strings present (the app's GlobalStrings), the same rows read 1.12 text.
// The same rows with the strings pinned by hand, so the expectation is literal 1.12 text.
s.run(
r#"BINDING_HEADER_MOVEMENT = "Movement Keys"
BINDING_NAME_MOVEANDSTEER = "Move and Steer"
@@ -164,15 +175,16 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
assert_eq!(
s.eval::<String>(&format!("return {ROW}2HeaderText:GetText()"))
.unwrap(),
"BINDING_HEADER_CHAT"
label(&s, "BINDING_HEADER_CHAT", "BINDING_HEADER_CHAT")
);
// Leaving the page hides its body and the Unbind button.
s.run(r#"OptionsFrame_SelectCategory("Controls")"#).unwrap();
s.run(r#"BenillaOptionsFrame_SelectCategory("Controls")"#)
.unwrap();
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerUnbind:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -191,7 +203,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"a selected capsule arms the capture"
);
assert!(
s.eval::<bool>("return OptionsFrameContainerUnbind:IsEnabled() ~= 0")
s.eval::<bool>("return BenillaOptionsFrameContainerUnbind:IsEnabled() ~= 0")
.unwrap(),
"Unbind arms with the selection"
);
@@ -216,7 +228,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"Key Bound Successfully"
);
@@ -228,10 +240,11 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
s.eval::<String>(r#"return GetBindingAction("T")"#).unwrap(),
"MOVEFORWARD"
);
let victim = label(&s, "BINDING_NAME_ATTACKTARGET", "ATTACKTARGET");
assert!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap()
.contains("ATTACKTARGET"),
.contains(&victim),
"the newly-bare victim is named"
);
// **The wheel binds like any other key** (B265, decision 1295) — including onto MOVEFORWARD,
@@ -248,9 +261,12 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// so in red, exactly as it would for any other stolen key. That the wheel's own default
// victim is a real command is half of why the old refusal read as plausible.
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"|cffff0000CAMERAZOOMIN Function is Now Unbound!|r"
format!(
"|cffff0000{} Function is Now Unbound!|r",
label(&s, "BINDING_NAME_CAMERAZOOMIN", "CAMERAZOOMIN")
)
);
// The refusal that IS there is the key-string validator, and this is the page arm that
// reports it: a chord the engine will not take restores the slot's old key and says why
@@ -263,7 +279,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
"the refused slot restored its key"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindingsOutput:GetText()")
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
"Can't bind mousewheel to actions with up and down states"
);
@@ -277,7 +293,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
// otherwise: the armed seam swallows all input with no window on screen).
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
s.run("HideUIPanel(OptionsFrame)").unwrap();
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
assert!(!s.bind_capture_armed(), "OnHide disarms");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -293,10 +309,10 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
assert_eq!(
s.eval::<String>(&format!("return {ROW}9Description:GetText()"))
.unwrap(),
"JUMP"
label(&s, "BINDING_NAME_JUMP", "JUMP")
);
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run("OptionsFrameContainerUnbind:Click()").unwrap();
s.run("BenillaOptionsFrameContainerUnbind:Click()").unwrap();
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("JUMP"); return k1 == "NUMPAD0" and k2 == nil"#
@@ -308,7 +324,7 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("G")"#).unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
s.run("OptionsFrameCloseButton:Click()").unwrap();
s.run("BenillaOptionsFrameCloseButton:Click()").unwrap();
assert_eq!(
s.eval::<String>(r#"return GetBindingAction("G")"#).unwrap(),
"JUMP",
@@ -317,7 +333,8 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
// Reset To Default: the page's Defaults button behind the era confirm — JUMP's real
// defaults return and the reset itself commits.
on_page(&mut s);
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(s
@@ -336,20 +353,22 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
// The ladder (the ESC binding's own body): the options rung hides the window — since 1008
// that IS the whole gesture for keybinds too (live-commit; nothing to revert).
s.run("ToggleGameMenu()").unwrap();
assert!(!s.eval::<bool>("return OptionsFrame:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap());
// The character-specific checkbox (1.12's set model, era confirm-on-uncheck-only law):
// CHECK switches to the character set and saves it into existence at once.
on_page(&mut s);
s.take_keybind_requests();
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
assert_eq!(s.current_binding_set(), 2);
assert!(s.character_bindings_exist());
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(2)]);
// UNCHECK is destructive: the box springs back and the 1.12 confirm decides. Cancel
// first — still on the character set.
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
@@ -357,7 +376,7 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
);
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyKeybindingsCharacterRowCheck:GetChecked() ~= nil"
"return BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:GetChecked() ~= nil"
)
.unwrap(),
"the box springs back until the popup decides"
@@ -367,7 +386,7 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
assert!(s.character_bindings_exist());
// Accept: back to the account set, the character set dropped (load-then-save order — the
// account file must not inherit the character binds).
s.run("OptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(s.current_binding_set(), 1);
@@ -383,29 +402,32 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
let mut s = harness();
s.run(r#"BINDING_NAME_JUMP = "Jump""#).unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.take_keybind_requests();
// A query that matches one binding and no CVar row: the Keybindings group head shows,
// with the match painted LIVE on the search pool (the era reflows its real rows the
// same way).
s.run(r#"OptionsFrameSearchBox:SetText("jump")"#).unwrap();
s.run(r#"BenillaOptionsFrameSearchBox:SetText("jump")"#)
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
.unwrap(),
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Description:GetText()"
)
.unwrap(),
"Jump"
);
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch2:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindSearch2:IsVisible()")
.unwrap());
// The result row is LIVE: its capsule arms, the bind lands and commits, the row relabels.
s.run("OptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
s.run("BenillaOptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
.unwrap();
assert!(s.bind_capture_armed());
s.run(r#"KeyBindings_OnHostKey("H")"#).unwrap();
@@ -415,24 +437,26 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
);
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
.unwrap(),
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()"
)
.unwrap(),
"H"
);
// The head is the era redirect: clicking it ends the search on the Keybindings page.
s.run("OptionsFrameContainerBodySearchHeadKeybindings:Click()")
s.run("BenillaOptionsFrameContainerBodySearchHeadKeybindings:Click()")
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert_eq!(
s.eval::<String>("return OptionsFrame.selectedCategory")
s.eval::<String>("return BenillaOptionsFrame.selectedCategory")
.unwrap(),
"Keybindings"
);
assert!(s
.eval::<bool>("return OptionsFrameContainerBodyKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindings:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return OptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindSearch1:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -503,7 +527,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
KeyBindingsPage_Update()"#,
)
.unwrap();
const SF: &str = "OptionsFrameContainerBodyKeybindingsScrollFrame";
const SF: &str = "BenillaOptionsFrameContainerBodyKeybindingsScrollFrame";
assert!(s
.eval::<bool>(&format!("return {SF}ScrollBar:IsVisible()"))
.unwrap());
@@ -527,12 +551,12 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
// What B217 actually cleared — a second bar in the shared gutter — still holds, and that is
// what is asserted.
assert!(
!s.eval::<bool>("return OptionsFrameContainerScrollBar:IsVisible()")
!s.eval::<bool>("return BenillaOptionsFrameContainerScrollBar:IsVisible()")
.unwrap(),
"only one bar is on screen: the list's own owns the gutter"
);
let body_right = s
.eval::<f64>("return OptionsFrameContainerBodyKeybindings:GetRight()")
.eval::<f64>("return BenillaOptionsFrameContainerBodyKeybindings:GetRight()")
.unwrap();
let rows_right = s.eval::<f64>(&format!("return {ROW}1:GetRight()")).unwrap();
let bar_left = s
@@ -554,7 +578,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
// overhanging it 21/20 so each arrow drops into the 16-tall socket the art carries (B224; the
// law and its ref citation are in BenillaScrollTrough_Seat). Asserted off the ARROWS here,
// which is the half the eye judges: 5 units of cap above the up arrow, 4 below the down arrow.
const TROUGH: &str = "OptionsFrameContainerBodyKeybindingsScrollFrameScrollBarTrough";
const TROUGH: &str = "BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBarTrough";
let bar = format!("{SF}ScrollBar");
let g = |f: &str, m: &str| s.eval::<f64>(&format!("return {f}:{m}()")).unwrap();
assert!(
@@ -593,11 +617,12 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
s.resolve();
// A spin over a row NAME — the dead zone when the handler lived on the sibling.
let steer = label(&s, "BINDING_NAME_MOVEANDSTEER", "MOVEANDSTEER");
let quads = s.extract();
let (wx, wy) = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t == "MOVEANDSTEER" => q
QuadContent::Text { text: Some(t), .. } if *t == steer => q
.rect
.map(|r| ((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)),
_ => None,
@@ -706,21 +731,30 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
);
// Page side: search finds it and paints a live row with the capsule filled.
s.run(r#"OptionsFrameSearchBox:SetText("bonusactionbutton1")"#)
.unwrap();
// The search tags are the DISPLAY names uppercased (era AddSearchTags), so the query is the
// label the app's strings give the row, not its token.
let query = label(&s, "BINDING_NAME_BONUSACTIONBUTTON1", "BONUSACTIONBUTTON1").to_lowercase();
s.run(&format!(
r#"BenillaOptionsFrameSearchBox:SetText("{query}")"#
))
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.eval::<bool>("return BenillaOptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
.unwrap(),
"BONUSACTIONBUTTON1",
"the raw-token fallback — the app's GlobalStrings read 'Secondary Action Button 1'"
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Description:GetText()"
)
.unwrap(),
label(&s, "BINDING_NAME_BONUSACTIONBUTTON1", "BONUSACTIONBUTTON1"),
"the row wears the app's own string"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
.unwrap(),
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()"
)
.unwrap(),
"CTRL-1"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
File diff suppressed because it is too large Load Diff
+110 -11
View File
@@ -478,7 +478,8 @@ fn shipped_loot_pushed_to_center_by_merchant() {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// Loot opens onto the empty left slot.
@@ -594,11 +595,11 @@ fn the_loot_window_draws_over_the_party_frames() {
/// previews the row's item in the dressing room (decision 1060), SHIFT posts its link into an open
/// chat edit box (decision 1059) — and **neither loots**.
///
/// That last clause is the whole point of the test, and it is ours to get right rather than the
/// reference's: in the real client the loot itself is the C `LootButton` widget's click behaviour
/// (l.94's `button:SetSlot(slot)`), so its Lua arms fall through harmlessly; ours owns the
/// `LootSlot` call, so the arms had to grow a `return` the reference does not have. The unmodified
/// click still loots — the regression that would otherwise ship silently.
/// That last clause is the whole point of the test, and it is the WIDGET's to get right: the loot
/// itself is the `LootButton` kind's click behaviour (`benilla-ui` `script/button.rs`, decision
/// 1799 — `l.94`'s `button:SetSlot(slot)` is what arms it), gated on no shift/ctrl/alt, which is
/// why the stock `LootFrameItem_OnClick` never calls a take itself and needs no `return`. The
/// unmodified click still loots — the regression that would otherwise ship silently.
#[test]
fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
const WOOL_LINK: &str = "|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r";
@@ -608,11 +609,18 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
load_xml(&s, f);
}
for file in [
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
r"Interface\FrameXML\UIParent.xml", // UIParent + UIParent.lua, the reference's own (1988)
"Interface\\FrameXML\\LootFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
@@ -1132,8 +1140,11 @@ fn loot_row_awaiting_its_template_opens_clean() {
.unwrap();
assert_eq!((item.as_str(), quantity, quality), ("", 1, -1));
assert!(
s.eval::<bool>("return ITEM_QUALITY_COLORS[select(4, GetLootSlotInfo(1))] ~= nil")
.unwrap(),
s.eval::<bool>(
"local _, _, _, quality = GetLootSlotInfo(1) \
return ITEM_QUALITY_COLORS[quality] ~= nil"
)
.unwrap(),
"the cache-miss quality must be a real row of ITEM_QUALITY_COLORS"
);
@@ -1191,3 +1202,91 @@ fn loot_row_awaiting_its_template_opens_clean() {
"the resolved row is white, got {white:?}"
);
}
/// **A `<LootButton>` wears `ItemButtonTemplate`'s three state textures, and the highlight tracks
/// the cursor.** The loader's Button leg used to gate on the two tags `Button`/`CheckButton`, so
/// the stock rows — whose tag is `LootButton` — were built with no `<NormalTexture>`, no
/// `<PushedTexture>` and no `<HighlightTexture>` at all: no Quickslot border on the icons, and
/// nothing to light under the mouse. The gate is wrong about the reference: `CLootButton`'s
/// geometry vtable differs from `CSimpleButton`'s in exactly one slot — the destructor thunk — and
/// `LoadXML` is not it, so `0x7788c0` parses a `<LootButton>` element verbatim (wow-re
/// `ui/scratch/lootbutton-widget-type.md` §4).
///
/// Asserted through the ENGINE's hover path rather than off the state: what a player sees is the
/// emitted quad, and the quad is what was missing.
#[test]
fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The inherited template's own art reached every visible row.
s.resolve();
let border = |quads: &[ExtractedQuad]| {
quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-Quickslot2"))
})
.count()
};
assert_eq!(
border(&s.extract()),
3,
"ItemButtonTemplate's <NormalTexture> is the Quickslot border on each of the three rows"
);
// The highlight is not on screen until the cursor is on a row.
let hilite = |quads: &[ExtractedQuad]| {
quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("ButtonHilight-Square") =>
{
q.rect
}
_ => None,
})
.collect::<Vec<_>>()
};
assert!(
hilite(&s.extract()).is_empty(),
"no row is lit with the mouse away"
);
// Hover the second row: exactly one highlight, over THAT row's icon.
let icon = |quads: &[ExtractedQuad], needle: &str| {
quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
})
.and_then(|q| q.rect)
.unwrap_or_else(|| panic!("no icon quad for {needle}"))
};
let wool = icon(&s.extract(), "INV_Fabric_Wool_01");
super::test_ui::hover(&mut s, "LootButton2");
s.resolve();
let lit = hilite(&s.extract());
assert_eq!(lit.len(), 1, "one row lights, not three: {lit:?}");
assert_eq!(
lit[0], wool,
"the highlight covers the hovered row's icon square"
);
// And it leaves with the cursor.
super::test_ui::unhover(&mut s);
s.resolve();
assert!(
hilite(&s.extract()).is_empty(),
"the highlight is not latched: it goes out when the cursor leaves"
);
}
+67 -19
View File
@@ -1,4 +1,4 @@
//! The shipped `assets/ui/MacroFrame.xml` — the macro editor + its name/icon popup (decision
//! The reference's own `Blizzard_MacroUI` addon — the macro editor + its name/icon popup (decision
//! 0983), driven against the engine's own macro table.
//!
//! What these guard, end to end through the real file: the window loads clean in its real
@@ -89,13 +89,15 @@ fn harness_with(player: &str) -> UiScript {
// reader cannot name it (decision 1848).
for file in [
r"Interface\FrameXML\GlobalStrings.lua",
"Fonts.xml",
"BasicControls.xml", // `TEXT`
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// The chain's `PanelTemplates_SelectTab` reaches for `GameTooltip` unguarded.
"GameTooltip.xml",
"UIParent.xml", // `ShowMacroFrame` lives here now
"Interface\\FrameXML\\GameTooltip.xml",
// **ScrollTemplates BEFORE UIPanelTemplates, the manifest's own order.** Ours still
// carries dead `FauxScrollFrame_*` copies the chain overrides by loading after (1846's
// step 3, deliberately not done); the other way round OUR copies win — the silent
@@ -105,11 +107,15 @@ fn harness_with(player: &str) -> UiScript {
r"Interface\FrameXML\UIPanelTemplates.xml",
// `ClassTrainerListScrollFrameTemplate` — the icon chooser's scroll frame inherits it.
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
"MicroMenu.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button
r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button
] {
super::test_ui::load_ui(&s, file);
}
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `MacroFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_MacroUI");
s.run("MacroFrame_LoadUI()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The icon chooser's list is the app's push; three entries is enough to index into.
s.set_macro_icons(vec![
@@ -548,8 +554,11 @@ fn the_tab_row_settles_and_stays_inside_the_window() {
settled.iter().all(|w| *w == last),
"the tab fit never settles — it changes every frame: {widths:?}"
);
// 344, not 384: this window's art stops 40 units short of its frame rect (MacroFrame.xml's
// `benillaTabRightInset`), and the frame rect is not what the player sees.
// 344, not 384: this window's art stops 40 units short of its frame rect, and the frame
// rect is not what the player sees. It holds off the reference's own numbers — the −15
// padding and the 150 cap the two tabs pass `TabResize` — with no clamp of ours in the way;
// 1002's was retired with our tab template (1993) and never reached these tabs anyway, which
// are `TabButtonTemplate`'s.
assert!(
last.2 <= 344.0,
"tab 2 ends at {} of a window whose plate stops at 344: {widths:?}",
@@ -558,15 +567,20 @@ fn the_tab_row_settles_and_stays_inside_the_window() {
no_errors(&s, "tab settle");
}
/// **The structural cap: no character name can push the tab row off the window** — the guarantee
/// the reference does not have (its 150 is a number hand-tuned to its own font) and the one the
/// director asked for by name. A tab clamps at its parent's right edge whatever it was asked for,
/// so this holds for a name no window author could have anticipated.
/// **No character name can push the tab row off the window** — checked against a 64-character
/// one, because `CHARACTER_SPECIFIC_MACROS` formats the player's name into tab 2's label and no
/// window author can know its width.
///
/// What holds it is the reference's own `150` cap, passed to `TabResize` by tab 2's own OnLoad.
/// 1002 added a structural clamp on top of that — a tab may never grow past its window's drawn
/// right edge — and 1993 retired it with our tab template; it never reached these tabs anyway
/// (they are `TabButtonTemplate`'s, and the clamp rode our `CharacterFrameTabButtonTemplate`).
/// So this is the reference's guarantee, asserted at the drawn edge.
#[test]
fn no_character_name_can_push_the_tab_row_off_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
// The `capped = false` arm is gone with decision 1848: it stripped a `benillaTabMaxWidth`
// field of our own, and the reference has no such switch — its cap is the literal `150` passed
// The `capped = false` arm is gone with decision 1848: it stripped a benilla-only max-width
// field, and the reference has no such switch — its cap is the literal `150` passed
// to `PanelTemplates_TabResize` in the tab's OnLoad, which nothing can turn off. So the
// guarantee is now just the guarantee, checked against four names including a 64-character one.
for name in ["Ai", "Onehunter", "Bartholomewthethird", &"W".repeat(64)] {
@@ -604,8 +618,8 @@ fn no_character_name_can_push_the_tab_row_off_the_window() {
#[test]
fn the_tab_highlight_is_exactly_its_tab() {
let _data = benilla_formats::wow_data_or_skip!();
// A name under the reference's cap, one over it, and one long enough that the structural
// drawn-edge clamp (1002) is what sets the width — all three must hold the same property.
// A name under the reference's cap, one over it, and a 40-character one — all three must
// hold the same property.
for name in ["Ai", "Onehunter", &"W".repeat(40)] {
// The name is chosen at construction (the stock tab labels itself in its own OnLoad) and
// there is no measure round trip to pump — decision 1848. The frame loop stays: the
@@ -672,3 +686,37 @@ fn the_tab_highlight_is_exactly_its_tab() {
no_errors(&s, "tab highlight");
}
}
/// Clicking "Change Name/Icon" opens the icon picker.
///
/// **This harness cannot catch the defect that made it raise in-game**, and that is worth saying
/// out loud: a harness lists its own dependencies, so it had
/// `ClassTrainerFrameTemplates.xml` while the shipped manifest did not. The window worked here and
/// was broken in the client. What catches THAT is
/// `every_template_the_manifest_inherits_is_declared_by_the_manifest`, plus the end-to-end drive in
/// `shipped_xml_tests`. Decision 1862.
///
/// The raise it reproduced:
///
/// ```text
/// Blizzard_MacroUI.lua:233: attempt to perform arithmetic on local 'macroPopupOffset' (a nil value)
/// MacroPopupFrame_Update <- MacroPopupFrame_OnShow <- MacroEditButton_OnClick
/// ```
#[test]
fn the_icon_picker_opens_without_raising() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
select_first(&s);
let _ = s.errors();
s.run("MacroEditButton:Click()").unwrap();
assert!(
s.errors().is_empty(),
"opening the icon picker must not raise: {:?}",
s.errors()
);
assert!(
s.eval::<bool>("return MacroPopupFrame:IsShown() and true or false")
.unwrap(),
"the picker is up"
);
}
+159 -11
View File
@@ -55,7 +55,7 @@ pub(super) fn shipped_manifest_files() -> Vec<String> {
/// Run decision 0272's load-time `UIParent_ManageFramePositions()` pass.
///
/// Only meaningful once the frames that table names exist, so the font-registry-only load
/// ([`load_font_registry`]) skips it. It is defined in `UIParent.xml`, which is in the deferred
/// ([`load_font_registry`]) skips it. It is defined in the stock `UIParent.lua`, which is in the deferred
/// half; calling it after `Fonts.xml` alone is a nil-global error, not a no-op.
///
/// The ref applies `UIPARENT_MANAGED_FRAME_POSITIONS` once at load, then re-fires from the bottom
@@ -74,9 +74,62 @@ fn bootstrap_positions(script: &UiScript) -> Vec<String> {
error!("ui_script: buff-duration layout: {e}");
return vec![format!("buff-duration layout: {e}")];
}
if let Err(e) = install_chat_plate_guard(script) {
error!("ui_script: chat plate guard: {e}");
return vec![format!("chat plate guard: {e}")];
}
Vec::new()
}
/// **A stated repair of a reference defect, installed rather than edited in** (decision 1998):
/// the chat plate's hover fade must survive a quick exit and re-entry.
///
/// The stock `FCF_OnUpdate` (FloatingChatFrame.lua l.809-987) keeps per-window state across
/// ticks: `hover` (the mouse is over the window), `oldAlpha` (the alpha the plate returns to, and
/// the gate the fade-in needs — `oldAlpha < DEFAULT_CHATFRAME_ALPHA`), and `hasBeenFaded`. Two of
/// its arms disagree about who owns them. The leave arm clears `hover` only inside the textures'
/// fade-out condition (l.913-918); the tabs' fade-out is queued with `FCF_ChatTabFadeFinished`
/// as its `finishedFunc` (l.931/973), which fires `CHAT_FRAME_FADE_TIME` (0.15 s) later and sets
/// `oldAlpha = nil` (l.991) — unconditionally. Re-enter the window inside that 0.15 s and the
/// re-entry's hover-start arm has already run (it keeps `oldAlpha`, still valid); then the tab's
/// fade completes, `oldAlpha` goes nil under a live hover, the plate arm (`chatFrame.oldAlpha
/// and chatFrame.oldAlpha < DEFAULT_CHATFRAME_ALPHA`, l.873) never passes again, and every later
/// leave skips the arm that would clear `hover` — so the hover-start re-read of `oldAlpha` (l.907)
/// never runs either. The tab keeps fading in; the plate never does, for the rest of the session.
/// `FCF_SetWindowAlpha` (the tab menu's opacity slider) reseats `oldAlpha` and is the one way
/// out, which is the shape the director reported: an existing window shows no plate on hover, a
/// new window (opened at `DEFAULT_CHATFRAME_ALPHA`, no fade needed) shows one, and "setting the
/// background to zero once" makes the hover work from then on. The gesture that arms it is
/// ordinary: the scroll buttons sit 32 units outside the window's left edge
/// (`FCF_SetButtonSide`, l.1038) and the hover box reaches only 5 (`MouseIsOver(chatFrame, 45,
/// -10, -5, 5)`), so a flick from the text to a scroll button and back does it.
///
/// The repair is the smallest one: the tab's finished callback leaves `oldAlpha` alone while the
/// window is hovered (`hover` set), and behaves as the reference's when it is not. The Lua is the
/// reference's own and the engine verbs it uses (`GetCursorPosition`, a texture's `GetAlpha`, the
/// OnUpdate `elapsed`) are settled, so the trap is inferred to be 1.12's as well — a client-side
/// A/B is the director's to run (`./run-ref-client.sh`; the record has the script). Installed from
/// Rust for the durability hook's reasons above: `assets/ui` does not grow (1779), and a repair of
/// a reference defect is not a `ContainerFrameAdapters`-class engine-difference shim (1751 §2).
///
/// Re-stated rather than wrapped (the reference body is two lines), so re-running it after a
/// `ReloadUI` re-defines the same function instead of stacking. Guarded on the function's
/// presence: the font-registry-only load has no chat files.
pub(super) fn install_chat_plate_guard(script: &UiScript) -> Result<(), String> {
script.run(CHAT_PLATE_GUARD).map_err(|e| e.to_string())
}
const CHAT_PLATE_GUARD: &str = r#"
if FCF_ChatTabFadeFinished then
function FCF_ChatTabFadeFinished(chatTab, chatFrame)
chatTab:Hide()
if not chatFrame.hover then
chatFrame.oldAlpha = nil
end
end
end
"#;
/// **Apply `SHOW_BUFF_DURATIONS` to the buff bar once, at load** (1751 window 18).
///
/// The reference's `BuffFrame_OnLoad` seats the duration FontStrings and nothing else — the row
@@ -112,8 +165,8 @@ pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
/// that home rather than a FrameXML one: `assets/ui` does not grow (1779), and this is the wrong
/// side of the line for a `ContainerFrameAdapters`-class shim anyway — that clause is for a genuine
/// engine difference (1751 §2), and this is a deliberate repair of a reference defect. Rust is also
/// the durable home: our `UIParent.xml` is itself a transcription awaiting its own window, and a
/// hook parked there would be homeless again the day it migrates.
/// the durable home: our `UIParent.xml` was a transcription awaiting its own window (it migrated
/// with 1988), and a hook parked there would have been homeless that day.
///
/// Idempotent by its own latch, so a `ReloadUI` cannot stack wrappers. The reference's handler
/// still runs first and unchanged: `this`, `event` and `arg1` are globals the engine has already
@@ -154,6 +207,18 @@ end
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
// **The client's CVar table first, because the interface reads CVars AT LOAD** (decision
// 2115). The app registers `crate::cvars::REGISTERED` at startup, long before world entry, so
// in the running client this call finds every name already there and only refreshes its
// default — it never clobbers a live value ([`benilla_ui::script::UiScript::register_cvars`]).
// What it buys is that a **probe** VM is the same client: `UiScript::new()` carries only
// `benilla-ui`'s own CVars, and the stock `UIOptionsFrame.xml`'s two camera dropdowns read
// theirs inside their own `OnLoad`
// (`getglobal("OPTION_TOOLTIP_CAMERA"..UIDropDownMenu_GetSelectedID(this))`, which is nil and
// then a concat error when `GetCVar("cameraSmoothStyle")` answers nil). Both of those CVars
// have been registered here with real consumers since 1493/1502; the probes simply never had
// them, and 218 tests found that out the hour this row went on the manifest.
script.register_cvars(crate::cvars::registered_pairs());
// **Silent, the way the client's own load is.** `0x48fbf0` brackets ITSELF in the counted
// sound-suppression scope — `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on
// exit — across the TOC walk, Bindings.xml and the AddOns, so both of its callers (login
@@ -168,6 +233,19 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
failures.extend(bootstrap_positions(script));
script.pop_sound_suppression();
// **A handler that raised DURING the walk is a load failure.** The loader's own report holds
// the raises it dispatched itself (a `<Script file=>` chunk, an OnLoad); a raise one call
// deeper — an OnLoad that `Show()`s a frame whose OnShow indexes a global its file has not
// loaded yet — lands in the VM's error list instead, and until 2001 nothing read that list
// here: the stance bar's load-order defect shipped with the manifest reporting clean and a
// WARN line the smoke does not fail on. The errors stay in the VM (the player's dialog and
// the retained log still get them); this is the walk's own verdict growing the row.
failures.extend(
script
.errors()
.into_iter()
.map(|e| format!("a handler raised during the load walk: {e}")),
);
failures
}
@@ -178,11 +256,6 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
/// per-entry rather than per-run so the manifest's order is the load order verbatim — the whole
/// reason the list is a manifest and not two lists.
///
/// Both stores answer `<Include>` / `<Script file=>` the same way: the loader resolves a reference
/// against the *including document's own* directory in its source's path space (1186), which for a
/// chain document means `Interface\FrameXML\ContainerFrame.xml`'s `<Script
/// file="ContainerFrame.lua"/>` reaches `Interface\FrameXML\ContainerFrame.lua` without anything
/// here knowing about it.
fn load_manifest(script: &UiScript, files: &[String]) -> Vec<String> {
let builtin = Addon::builtin();
let reference = reference_ui::addon(
@@ -279,7 +352,7 @@ mod tests {
assert_eq!(toc.directive("Title"), Some("benilla"));
assert_eq!(
toc.files.first().map(String::as_str),
Some("Fonts.xml"),
Some("Interface\\FrameXML\\Fonts.xml"),
"the font registry is the manifest's first entry — the loader splits there"
);
}
@@ -345,16 +418,91 @@ mod tests {
/// not fail, it *half-works* — the frame keeps drawing, keeps answering `IsShown`, and simply
/// never joins the cascade `UIParent:Hide()` walks. That is precisely how it would be missed.
///
/// It nearly was: `UIParent.xml` sat below `UiPanels.xml` until decision 1734, so restoring
/// It nearly was: our `UIParent.xml` sat below `UiPanels.xml` until decision 1734, so restoring
/// `StaticPopup1`/`StaticPopup2`'s parents there would have written two declarations that did
/// nothing at all. The reference's own order is the fix (FrameXML.toc: BasicControls.xml l.6,
/// UIParent.xml l.8), and this keeps it.
/// **Every load-time runner of `UIParent_ManageFramePositions` follows every frame the pass
/// reads.** The stock pass (`UIParent.lua:1592-1775`) indexes a dozen HUD frames unguarded —
/// `ReputationWatchBar`, `QuestTimerFrame`, `QuestWatchFrame`, `MinimapCluster`,
/// `DurabilityFrame`, the bars — and several stock files run it from an OnLoad or the OnShow
/// of a frame their OnLoad shows. In the reference's toc the readers all precede the runners;
/// ours had the stance bar (a runner, through `ShapeshiftBar_OnLoad` → `Show` → `OnShow`)
/// three hundred lines above `ReputationFrame.xml` (a read), so every login raised at
/// `UIParent.lua:1618` and the shelf was never seated — the director's sliver above Defensive
/// Stance (decision 2001). The pairs below are the reference's own dependency, read off the
/// pass's body; a new runner or a new read joins the table, not a comment.
#[test]
fn every_load_time_runner_of_the_managed_pass_follows_what_it_reads() {
let files = manifest_files();
let at = |leaf: &str| {
files
.iter()
.position(|f| f.ends_with(&format!("\\{leaf}")))
.unwrap_or_else(|| panic!("the manifest lists {leaf}"))
};
// What the pass reads by name (`UIParent.lua:1598-1775`), and the file that declares it.
const READS: &[(&str, &str)] = &[
(
"MultiBarLeft / MultiBarRight / MultiBarBottomLeft",
"MultiActionBars.xml",
),
(
"PetActionBarFrame + SlidingActionBarTexture0/1",
"PetActionBarFrame.xml",
),
("ReputationWatchBar", "ReputationFrame.xml"),
(
"MainMenuExpBar / MainMenuBarMaxLevelBar / MainMenuBar",
"MainMenuBar.xml",
),
("CastingBarFrame", "CastingBarFrame.xml"),
("QuestTimerFrame", "QuestTimerFrame.xml"),
("QuestWatchFrame", "QuestLogFrame.xml"),
("DurabilityFrame + its three glyphs", "DurabilityFrame.xml"),
("MinimapCluster", "Minimap.xml"),
(
"ChatFrame1 / ChatFrame2 (+ FCF_DockUpdate)",
"FloatingChatFrame.xml",
),
// The shapeshift-appearance arm (l.1705-1732): a runner's file can be a READ too —
// the stance bar declares these and runs the pass, so it precedes the other runner.
(
"ShapeshiftBarLeft / Middle / Right",
"BonusActionBarFrame.xml",
),
];
// Who runs it at LOAD: an OnLoad, or the OnShow of a frame its OnLoad shows.
const RUNNERS: &[(&str, &str)] = &[
(
"ShapeshiftBar_OnLoad → Show → OnShow",
"BonusActionBarFrame.xml",
),
("WorldStateAlwaysUpFrame OnLoad", "WorldStateFrame.xml"),
];
for (what, runner) in RUNNERS {
for (name, read) in READS {
if read == runner {
continue; // its own declarations precede its own OnLoad
}
assert!(
at(read) < at(runner),
"{runner} ({what}) loads at {} but reads {name}, declared by {read} at {} — \
the pass raises at load and never seats the frame it was run for. Move the \
runner below the read, as the reference's toc has it.",
at(runner),
at(read)
);
}
}
}
#[test]
fn nothing_declares_a_uiparent_child_before_uiparent_itself_loads() {
let files = manifest_files();
let at = files
.iter()
.position(|f| f == "UIParent.xml")
.position(|f| f == r"Interface\FrameXML\UIParent.xml")
.expect("the manifest lists UIParent.xml");
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
for early in &files[..at] {
@@ -1,6 +1,6 @@
//! The shipped **merchant window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/MerchantFrame.xml` (+ `GameTooltip.xml` for the hover chain) loaded behind
//! `UiPanels.xml` and fed synthetic stock — the phase-4 vendor arc's machine checks (decision
//! The **merchant window** driven end-to-end, engine-only (no Bevy): the stock
//! `Interface\FrameXML\MerchantFrame.xml` (+ `GameTooltip.xml` for the hover chain) loaded behind
//! `UIParent.xml` and fed synthetic stock — the phase-4 vendor arc's machine checks (decision
//! 0081/0084). Split from `panel_tests` (which keeps gossip + the slot manager itself) along the
//! folder's one-file-per-window convention.
@@ -40,7 +40,7 @@ fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Re
.unwrap_or_else(|| panic!("no bare-frame quad sized {w}x{h}"))
}
/// Load the real `assets/ui/MerchantFrame.xml` (the shipped vendor window) behind `UiPanels.xml`
/// Load the stock `Interface\FrameXML\MerchantFrame.xml` behind `UIParent.xml`
/// into a bare engine and drive it with a synthetic 2-item stock + a purse — the whole phase-4
/// chain minus Bevy (decision 0081), now over the UIPanel slot manager (decision 0084): the
/// hidden→shown lifecycle on MERCHANT_SHOW goes through ShowUIPanel (landing at the left slot),
@@ -343,6 +343,7 @@ fn merchant_show_hide_plays_open_and_close_kits() {
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
@@ -392,6 +393,8 @@ fn vendor_open_opens_the_backpack_and_layers_the_sound() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
equip_bag(&mut s, 0, "Backpack", 16);
@@ -442,6 +445,8 @@ fn vendor_leaves_an_already_open_backpack_alone() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
equip_bag(&mut s, 0, "Backpack", 16);
@@ -490,6 +495,8 @@ fn vendor_opens_and_closes_all_equipped_bags() {
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
equip_bag(&mut s, 0, "Backpack", 16);
@@ -526,6 +533,7 @@ fn merchant_switch_plays_close_then_open_and_queues_the_consumable_close() {
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// Vendor A open.
@@ -565,6 +573,10 @@ fn merchant_switch_plays_close_then_open_and_queues_the_consumable_close() {
fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The stock tooltip declares no size: it sizes from its lines through the font engine, as
// the client's does (1968) — a harness that reads its rect needs one; the fixed-width
// font is that engine here.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
@@ -575,6 +587,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
// every FrameXML file loads before any hover fires, so load it here too — with the dropdown
// kit its GroupLootDropDown initializes against at load (benilla.toc l.64 vs 383).
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_merchant(Some(MerchantState {
@@ -696,13 +709,20 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
for line in [
"Vendor Blade",
"Main Hand",
"Sword",
"5 - 9 Damage",
"Speed 2.60",
"(2.7 damage per second)",
] {
assert!(has_text(&quads, line), "tooltip line {line:?} missing");
}
// The type cell ("Sword") is deliberately NOT here. This row renders through the stat-head
// FALLBACK — the shape a hover takes only while the full template view is still in flight —
// and the type word is `ItemSubClass.dbc`'s DisplayName, which only the app can resolve. The
// head carries `class`/`subclass` numbers and no name, so the cell stands empty rather than
// being composed from a table in the engine (the renderer's hand-typed copy of that DBC is
// gone — decision 2080's shape). A hover whose template HAS landed takes `view_of` and prints
// it; that path is pinned in `benilla-ui`'s own item-law tests.
assert!(!has_text(&quads, "Sword"), "the head carries no type word");
// Two "Vendor Blade" quads exist — the merchant row's own gold name and the tooltip's header
// line; the tooltip's is the quality-green one.
let name_colors: Vec<[f32; 4]> = quads
@@ -757,18 +777,13 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
);
s.resolve();
let quads = s.extract();
for line in [
"Chipped Buckler",
"Off Hand",
"Shield",
"85 Armor",
"1 Block",
] {
for line in ["Chipped Buckler", "Off Hand", "85 Armor", "1 Block"] {
assert!(
has_text(&quads, line),
"shield tooltip line {line:?} missing"
);
}
assert!(!has_text(&quads, "Shield"), "the head carries no type word");
assert!(!has_text(&quads, "Main Hand"), "row 1's tooltip cleared");
// Leave the window entirely: tooltip + highlight gone.
@@ -814,6 +829,7 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(500);
@@ -924,14 +940,25 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The tabs fit their labels — the ref's OnShow text-fit (PanelTemplates_TabResize(0): tab width
/// = text + the two 20px end slices, middle slices stretched to the text), run from the
/// template's OnUpdate once the async text measure lands. Fixed 115px tabs looked wrong against
/// the ref (director pass 2026-07-05): too wide, and the −16 overlap lost its nestle gap.
/// The two tabs fit their labels, from the reference's own `<OnShow>` — `PanelTemplates_TabResize(0)`
/// on `CharacterFrameTabButtonTemplate`: tab width = text + the two 20px end slices, middle slices
/// stretched to the text. Fixed 115px tabs looked wrong against the ref (director pass 2026-07-05):
/// too wide, and the −16 overlap lost its nestle gap.
///
/// **Driven through a SYNCHRONOUS measurer, with no round trip to pump** — the same correction 1848
/// made to the macro harness, arriving here with the template (decision 1993). This used to feed
/// two widths through `set_measured_text_unwrapped` and then tick, because our own template re-fit
/// from `OnUpdate` until the measure settled. The reference's file fits once, in `OnShow`; a client
/// whose measure is still pending at that moment could never size its tabs at all, and the app is
/// not such a client — it installs `AtlasMeasurer`. Modelling the async path here was modelling a
/// configuration the app does not have.
#[test]
fn merchant_tabs_fit_their_labels() {
/// `2 * $parentLeft:GetWidth()` — the template's two 20-unit end slices.
const SIDES: f64 = 40.0;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
@@ -941,33 +968,21 @@ fn merchant_tabs_fit_their_labels() {
s.fire_event("MERCHANT_SHOW", vec![ScriptValue::Str("Vendor".into())]);
s.resolve();
// Answer the measure round-trip for the two labels (the app's font-atlas job in-game).
let measures: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.into_iter()
.filter(|r| r.text == "Merchant" || r.text == "Buyback")
.map(|r| {
let w = if r.text == "Merchant" { 58.0 } else { 52.0 };
(r.id, w, 10.0, r.key)
})
.collect();
assert!(measures.len() >= 2, "both tab labels request a measure");
s.set_measured_text_unwrapped(&measures);
s.tick(0.016); // the template OnUpdate sees the settled width and runs the fit
s.resolve();
// Tab width = text + 2×20 end slices; the middle slices carry exactly the text width.
let (w1, w2): (f64, f64) = s
.eval("return MerchantFrameTab1:GetWidth(), MerchantFrameTab2:GetWidth()")
.unwrap();
assert_eq!((w1, w2), (98.0, 92.0), "text + 40, not the fixed 115");
let (m1, m2): (f64, f64) = s
let (l1, l2, w1, w2, m1, m2): (f64, f64, f64, f64, f64, f64) = s
.eval(
"return MerchantFrameTab1MiddleDisabled:GetWidth(), \
MerchantFrameTab2Middle:GetWidth()",
"return MerchantFrameTab1Text:GetStringWidth(), MerchantFrameTab2Text:GetStringWidth(), \
MerchantFrameTab1:GetWidth(), MerchantFrameTab2:GetWidth(), \
MerchantFrameTab1MiddleDisabled:GetWidth(), MerchantFrameTab2Middle:GetWidth()",
)
.unwrap();
assert_eq!((m1, m2), (58.0, 52.0), "middle slices stretch to the text");
assert!(l1 > 0.0 && l2 > 0.0, "both labels measured synchronously");
assert_eq!(
(w1, w2),
(l1 + SIDES, l2 + SIDES),
"text + 40, not the fixed 115"
);
assert_eq!((m1, m2), (l1, l2), "middle slices stretch to the text");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -984,6 +999,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// A purse of 50c: row 1 (25c) is affordable, row 2 (100c) is not.
@@ -1077,8 +1093,12 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() {
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the BenillaMoney_* helpers
load_xml(&s, "TradeFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // MerchantFrame:IsShown(), read on the slot click
// TradeFrame's money entry is the chain's own since 1882 — its OnLoad calls MoneyInputFrame_*.
load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.lua");
load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TradeFrame.xml");
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
let target = benilla_ui::script::TradeSideState {
@@ -1133,11 +1153,18 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
load_xml(&s, f);
}
for file in [
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
r"Interface\FrameXML\UIParent.xml", // UIParent + UIParent.lua, the reference's own (1988)
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"Interface\\FrameXML\\MerchantFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
// (the panel templates, UiPanels and the dialog engine are MERCHANT_UI's already — a
// second load of the stock money kit's frames trips its own global-named update)
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
}
@@ -1,85 +1,119 @@
//! The shipped `assets/ui/MicroMenu.xml` — the eight micro buttons in the main bar's right-hand
//! recess — loaded behind `ActionBar.xml` (their anchor target `MainMenuBarArtFrame` and the
//! `BenillaActionBarArt_SeatAbove` seating helper) into a bare engine.
//! The stock `Interface\FrameXML\MainMenuBarMicroButtons.xml` — the eight micro buttons in the
//! main bar's right-hand recess — off the player's chain behind the stock bar (their anchor
//! target `MainMenuBarArtFrame`) into a bare engine (decision 1987).
//!
//! What these guard, in order: the row's ref geometry (29×58 at art-frame BOTTOMLEFT +(552,2), a
//! 26 px stride) — which is the whole point of the slice, since the gap the row fills is exactly
//! that panel of empty bar art; the `HitRectInsets` header that keeps a button's transparent top
//! from eating the mouse; `UpdateMicroButtons` tracking a panel's visibility; and
//! `UpdateTalentButton`'s level-10 gate closing the row up behind the hidden talent button.
//! What these guard, in order: the row's geometry (29×58 at art-frame BOTTOMLEFT +(552,2), a
//! 26 px stride, and the reference's own 1 px nudge once the talent gate has run); the
//! `HitRectInsets` header that keeps a button's transparent top from eating the mouse; the
//! character button's portrait crop; `UpdateTalentButton`'s level-10 gate closing the row up
//! behind the hidden talent button; the two-line hover with the bound key in its label; and,
//! under the whole shipped manifest, `UpdateMicroButtons` following a real panel and the
//! level-up pulse the reference hangs on the talent button.
use benilla_ui::script::{QuadContent, ScriptValue, TexCoords, UiScript};
use benilla_ui::script::{QuadContent, ScriptValue, TexCoords, UiScript, UnitState};
/// Load `ActionBar.xml` then `MicroMenu.xml` into a 1024×768 engine, asserting both load clean.
/// 1024 wide is deliberate: the 1024-wide bar then centers at x=0, so every ref offset below is
/// The eight, in row order.
const ROW: [&str; 8] = [
"CharacterMicroButton",
"SpellbookMicroButton",
"TalentMicroButton",
"QuestLogMicroButton",
"SocialsMicroButton",
"WorldMapMicroButton",
"MainMenuMicroButton",
"HelpMicroButton",
];
/// The stock bar, then the stock row, into a 1024×768 engine, asserting both load clean. 1024
/// wide is deliberate: the 1024-wide bar then centers at x=0, so every reference offset below is
/// also an absolute screen coordinate.
fn harness() -> UiScript {
harness_with(&[])
}
/// …with more shipped files behind the row — the tooltip test needs the real plate.
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = [
"Fonts.xml",
"Cooldown.xml",
"ActionBar.xml",
"MicroMenu.xml",
]
.into_iter()
.chain(extra.iter().copied())
.collect();
for file in files {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
for file in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
r"Interface\FrameXML\UIParent.xml",
// The eight labels and their NEWBIE_TOOLTIP_* lines, read at each button's OnLoad…
"Interface\\FrameXML\\GlobalStrings.lua",
// …through `TEXT()`.
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
] {
super::test_ui::load_ui(&s, file);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// The row's geometry, quoted from ref-MainMenuBarMicroButtons.xml: CharacterMicroButton at the
/// art frame's BOTTOMLEFT +(552,2), each button 29×58, the rest chained BOTTOMLEFT-to-previous-
/// BOTTOMRIGHT +(-3,0) ⇒ a 26 px stride, so the eight run x 552..757 — inside the 1024-wide bar,
/// between the page arrows (x≈522) and the bag cluster at the far right.
/// Set the player's level and fire the `UNIT_LEVEL` the talent gate listens for.
fn set_player_level(s: &mut UiScript, level: u32) {
s.set_unit(
"player",
Some(UnitState {
exists: true,
level,
..Default::default()
}),
);
s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]);
}
fn left_of(s: &UiScript, name: &str) -> f64 {
s.eval::<f64>(&format!("return {name}:GetLeft()"))
.unwrap_or_else(|e| panic!("{name}: {e}"))
}
/// The row's geometry, the reference's own numbers: CharacterMicroButton at the art frame's
/// BOTTOMLEFT +(552,2), each button 29×58, the rest chained BOTTOMLEFT-to-previous-BOTTOMRIGHT
/// +(-3,0) ⇒ a 26 px stride, so the eight run x 552..757 as declared — between the page arrows
/// (x≈522) and the bag cluster at the far right. **As declared** is the operative phrase: the XML
/// seats the quest-log button at −3 and `UpdateTalentButton` re-seats it at −2
/// (`MainMenuBarMicroButtons.lua:139`), so the first level pass at 10+ nudges the tail right by
/// one pixel. A real row is 1 px wider past its first `PLAYER_ENTERING_WORLD` than its XML
/// says; that is the client's, and it is asserted here as the reference's law rather than
/// sanded off (our retired file wrote −3 both ways).
#[test]
fn the_micro_row_sits_where_the_reference_puts_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Past level 10 so all eight are shown and the row is unbroken.
set_player_level(&mut s, 10);
s.resolve();
let names = [
"CharacterMicroButton",
"SpellbookMicroButton",
"TalentMicroButton",
"QuestLogMicroButton",
"SocialsMicroButton",
"WorldMapMicroButton",
"MainMenuMicroButton",
"HelpMicroButton",
];
for (i, name) in names.iter().enumerate() {
// As declared, before any level pass: a clean 26 px stride.
for (i, name) in ROW.iter().enumerate() {
let (left, bottom, w, h) = s
.eval::<(f64, f64, f64, f64)>(&format!(
"return {name}:GetLeft(), {name}:GetBottom(), {name}:GetWidth(), {name}:GetHeight()"
))
.unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((w, h), (29.0, 58.0), "{name} size");
assert_eq!(left, 552.0 + 26.0 * i as f64, "{name} left edge");
assert_eq!(
left,
552.0 + 26.0 * i as f64,
"{name} left edge, as declared"
);
assert_eq!(bottom, 2.0, "{name} sits 2 above the bar's bottom");
}
// Past level 10 the gate re-seats the quest-log button at −2: the tail moves 1 px right.
set_player_level(&mut s, 60);
s.resolve();
for (i, name) in ROW.iter().enumerate().skip(3) {
assert_eq!(
left_of(&s, name),
553.0 + 26.0 * i as f64,
"{name} after the talent gate's own −2"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -88,8 +122,8 @@ fn the_micro_row_sits_where_the_reference_puts_it() {
/// bar's XP strip and the sliver of world above it.
#[test]
fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_player_level(&mut s, 10);
s.resolve();
// Mid-button horizontally (552 + 14), inside the art band (y 2..42) ⇒ captures.
@@ -98,7 +132,7 @@ fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() {
Some("CharacterMicroButton"),
"the art band takes the mouse"
);
// Same column, in the dead 18-unit header (y 42..60) ⇒ nothing of ours captures it.
// Same column, in the dead 18-unit header (y 42..60) ⇒ nothing of the row captures it.
assert_ne!(
s.hit_test_name(566.0, 50.0).as_deref(),
Some("CharacterMicroButton"),
@@ -107,13 +141,14 @@ fn the_transparent_top_of_a_micro_button_does_not_capture_the_mouse() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The character button's face: the region binds the SAME `"player"` portrait slot the unit frame
/// samples, and carries the ref's crop window out to the renderer. Both halves matter — the crop is
/// what makes an 18×25 rectangle show a face instead of the whole square bake squashed into it, and
/// the extract path used to pin portrait quads to full UVs and drop it (fixed with this slice).
/// Pushing the button swaps the window and dims it (ref CharacterMicroButton_SetPushed).
/// The character button's face: `MicroButtonPortrait` binds the SAME `"player"` portrait slot
/// the unit frame samples, and carries the reference's crop window out to the renderer. Both
/// halves matter — the crop is what makes an 18×25 rectangle show a face instead of the whole
/// square bake squashed into it. Pushing the button swaps the window and dims it
/// (`CharacterMicroButton_SetPushed`, `MainMenuBarMicroButtons.lua:109-117`).
#[test]
fn the_character_button_carries_the_player_portrait_through_the_reference_crop() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.resolve();
@@ -136,8 +171,8 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert_eq!(unit, "player");
assert!(
circular,
"the ref's round stencil lives in the bake's own UV space, so the crop below yields a \
rectangular slice OF a masked face — not an ellipse fitted to this 18x25 region"
"the reference's round stencil lives in the bake's own UV space, so the crop below yields \
a rectangular slice OF a masked face — not an ellipse fitted to this 18x25 region"
);
let round4 = |c: TexCoords| match c {
TexCoords::Rect(e) => e.map(|v| (v * 10_000.0).round() / 10_000.0),
@@ -146,7 +181,7 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert_eq!(
round4(coords.expect("a crop window")),
[0.2, 0.8, 0.0666, 0.9],
"the normal window (ref CharacterMicroButton_SetNormal)"
"the normal window (CharacterMicroButton_SetNormal)"
);
s.run("CharacterMicroButton_SetPushed()").unwrap();
@@ -155,7 +190,7 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert_eq!(
round4(coords.expect("a crop window")),
[0.2666, 0.8666, 0.0, 0.8333],
"the held-down window (ref CharacterMicroButton_SetPushed)"
"the held-down window (CharacterMicroButton_SetPushed)"
);
assert_eq!(
s.eval::<f64>("return MicroButtonPortrait:GetAlpha()")
@@ -166,50 +201,12 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UpdateMicroButtons` (ref MainMenuBarMicroButtons.lua l.20-84): a button is PUSHED exactly
/// while its panel is up. Driven here with a stand-in frame under the real panel's name rather
/// than the whole character window — this is the visibility contract, not that window's.
#[test]
fn a_micro_button_pushes_while_its_panel_is_open() {
let s = harness();
s.run(
r#"
local f = CreateFrame("Frame", "CharacterFrame")
f:SetPoint("CENTER", 0, 0); f:SetSize(100, 100); f:Hide()
"#,
)
.unwrap();
let state = |s: &UiScript| {
s.eval::<String>("return CharacterMicroButton:GetButtonState()")
.unwrap()
};
s.run("UpdateMicroButtons()").unwrap();
assert_eq!(state(&s), "NORMAL", "closed panel ⇒ button up");
s.run("CharacterFrame:Show(); UpdateMicroButtons()")
.unwrap();
assert_eq!(state(&s), "PUSHED", "open panel ⇒ button held down");
s.run("CharacterFrame:Hide(); UpdateMicroButtons()")
.unwrap();
assert_eq!(state(&s), "NORMAL", "closed again ⇒ button pops back");
// A button whose panel doesn't exist at all (the three unwired ones, and any panel a test
// harness didn't load) never pushes — and never errors.
assert_eq!(
s.eval::<String>("return SocialsMicroButton:GetButtonState()")
.unwrap(),
"NORMAL"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UpdateTalentButton` (ref l.133-141): under level 10 the talent button is hidden and the quest
/// log button slides onto its seat, so the row has no hole in it; at 10 the button returns and the
/// tail shifts back out by one stride.
/// `UpdateTalentButton` (l.133-141): under level 10 the talent button is hidden and the quest
/// log button slides onto its seat, so the row has no hole in it; at 10 the button returns and
/// the tail shifts back out — by one stride less two, the gate's own −2.
#[test]
fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
set_player_level(&mut s, 9);
@@ -220,13 +217,9 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
"no talents before 10"
);
// The quest log button takes the talent button's own seat — slot 3, not slot 4.
assert_eq!(left_of(&s, "QuestLogMicroButton"), 552.0 + 26.0 * 2.0);
assert_eq!(
s.eval::<f64>("return QuestLogMicroButton:GetLeft()")
.unwrap(),
552.0 + 26.0 * 2.0
);
assert_eq!(
s.eval::<f64>("return HelpMicroButton:GetLeft()").unwrap(),
left_of(&s, "HelpMicroButton"),
552.0 + 26.0 * 6.0,
"the whole tail moves up one slot with it"
);
@@ -239,46 +232,84 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
"the button returns at 10"
);
assert_eq!(
s.eval::<f64>("return QuestLogMicroButton:GetLeft()")
.unwrap(),
552.0 + 26.0 * 3.0
left_of(&s, "QuestLogMicroButton"),
553.0 + 26.0 * 3.0,
"…and the tail goes back out, at the gate's own −2"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hover (ref MainMenuBarMicroButton's OnEnter, l.13): every button's plate is the ref's
/// TWO-line `GameTooltip_AddNewbieTip` — the label, then that button's own `NEWBIE_TOOLTIP_*`
/// explanation in gold. 1.12 ships detailed tips ON (`SHOW_NEWBIE_TIPS = "1"`, ref
/// UIOptionsFrame.lua l.100), so the paragraph is the DEFAULT hover; this file used to open-code
/// the tips-off branch and show a bare label. Decision 0661.
///
/// Checked across all eight, because the explanation is threaded per button through OnLoad and a
/// single missing argument is invisible until someone hovers that one button.
/// The hover (the template's OnEnter, `MainMenuBarMicroButtons.xml:12-14`): every button's plate
/// is the reference's TWO-line `GameTooltip_AddNewbieTip` — the label, then that button's own
/// `NEWBIE_TOOLTIP_*` explanation in gold. 1.12 ships detailed tips ON (`SHOW_NEWBIE_TIPS = "1"`,
/// `UIOptionsFrame.lua:100`; ours sits in OptionsFrame.xml's uvar block), so the paragraph is the
/// DEFAULT hover (decision 0661). Through the engine's own hover, because the stock OnEnter reads
/// the firing frame off `this`. For the bound ones the label carries the key the way the
/// reference prints it — `GetBindingKey`'s raw token in `NORMAL_FONT_COLOR_CODE` parentheses
/// (`MicroButtonTooltipText`, l.11-18) — re-read on `UPDATE_BINDINGS`, which is the event
/// `bindings.rs` fires after every (re)load of the table.
#[test]
fn every_micro_button_hovers_with_its_reference_explanation() {
let mut s = harness_with(&["UIParent.xml", "GameTooltip.xml"]);
set_player_level(&mut s, 10);
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
// A key the character button's label can carry: the registry, then the reference's own
// `SetBinding`, then the event the row re-reads its labels on.
s.register_bindings(&crate::bindings::registry_commands());
assert_eq!(
s.eval::<Option<i64>>("return SetBinding(\"C\", \"TOGGLECHARACTER0\")")
.unwrap(),
Some(1)
);
s.fire_event("UPDATE_BINDINGS", vec![]);
s.resolve();
for (button, newbie) in [
("CharacterMicroButton", "NEWBIE_TOOLTIP_CHARACTER"),
("SpellbookMicroButton", "NEWBIE_TOOLTIP_SPELLBOOK"),
("TalentMicroButton", "NEWBIE_TOOLTIP_TALENTS"),
("QuestLogMicroButton", "NEWBIE_TOOLTIP_QUESTLOG"),
("SocialsMicroButton", "NEWBIE_TOOLTIP_SOCIAL"),
("WorldMapMicroButton", "NEWBIE_TOOLTIP_WORLDMAP"),
("MainMenuMicroButton", "NEWBIE_TOOLTIP_MAINMENU"),
("HelpMicroButton", "NEWBIE_TOOLTIP_HELP"),
for (button, label, newbie) in [
(
"CharacterMicroButton",
"CHARACTER_BUTTON",
"NEWBIE_TOOLTIP_CHARACTER",
),
// The spellbook's own OnEnter (l.79-87) picks its label by `PlayerHasSpells()`.
(
"SpellbookMicroButton",
"PlayerHasSpells() and SPELLBOOK_ABILITIES_BUTTON or ABILITYBOOK_BUTTON",
"NEWBIE_TOOLTIP_SPELLBOOK",
),
(
"TalentMicroButton",
"TALENTS_BUTTON",
"NEWBIE_TOOLTIP_TALENTS",
),
(
"QuestLogMicroButton",
"QUESTLOG_BUTTON",
"NEWBIE_TOOLTIP_QUESTLOG",
),
(
"SocialsMicroButton",
"SOCIAL_BUTTON",
"NEWBIE_TOOLTIP_SOCIAL",
),
(
"WorldMapMicroButton",
"WORLDMAP_BUTTON",
"NEWBIE_TOOLTIP_WORLDMAP",
),
(
"MainMenuMicroButton",
"MAINMENU_BUTTON",
"NEWBIE_TOOLTIP_MAINMENU",
),
("HelpMicroButton", "HELP_BUTTON", "NEWBIE_TOOLTIP_HELP"),
] {
s.run(&format!("BenillaMicroButton_OnEnter({button})"))
.unwrap();
super::test_ui::hover(&mut s, button);
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
2,
"{button}: the label, then the explanation"
);
// Line 1 is the button's own label (plus a `|cff…(key)|r` suffix on the five bound ones).
let label = s.eval::<String>(&format!("return {button}.label")).unwrap();
let label = s.eval::<String>(&format!("return {label}")).unwrap();
let line1 = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
@@ -290,26 +321,130 @@ fn every_micro_button_hovers_with_its_reference_explanation() {
s.eval::<String>("return GameTooltipTextLeft2:GetText()")
.unwrap(),
s.eval::<String>(&format!("return {newbie}")).unwrap(),
"{button}: line 2 is the ref's {newbie}, verbatim"
"{button}: line 2 is the reference's {newbie}, verbatim"
);
assert_eq!(
s.eval::<i64>("return GameTooltip.default").unwrap(),
1,
"{button}: the default-corner anchor, not ANCHOR_RIGHT off the button"
);
super::test_ui::unhover(&mut s);
}
// The bound one carries its key the reference's way: the raw token, gold, in parentheses,
// after a space — `MicroButtonTooltipText`'s own concatenation.
super::test_ui::hover(&mut s, "CharacterMicroButton");
let line1 = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert_eq!(
line1,
s.eval::<String>(
"return CHARACTER_BUTTON .. \" \" .. NORMAL_FONT_COLOR_CODE .. \"(C)\" .. FONT_COLOR_CODE_CLOSE"
)
.unwrap(),
"the key read on UPDATE_BINDINGS"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Set the player's level and fire the `UNIT_LEVEL` the talent gate listens for.
fn set_player_level(s: &mut UiScript, level: u32) {
/// Under the whole shipped manifest — the only VM in which every panel `UpdateMicroButtons`
/// (l.20-84) reads exists — a button is PUSHED exactly while its panel is up, the character
/// button's face dims with it, and the level-up pulse the reference hangs on the talent button
/// (`TalentMicroButton_OnEvent`, l.119-124: sixty seconds, unless the character sheet is open)
/// is live — 0304's "no microbutton pulse" scope-out, retired by the file that owns the pulse.
#[test]
fn a_micro_button_pushes_while_its_panel_is_open_and_the_talent_button_pulses_on_a_ding() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// A player always exists by the time the manifest loads (1051/1848) — with a race and a
// class, which the sheet's own `PaperDollFrame_SetLevel` formats into its title on show.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
Some(UnitState {
exists: true,
level,
name: Some("Probefour".into()),
level: 60,
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
sex: 2,
is_player: true,
player_controlled: true,
..Default::default()
}),
);
s.fire_event("UNIT_LEVEL", vec![ScriptValue::Str("player".into())]);
// The reference's strings, which the app runs ahead of the manifest (lifecycle.rs) — the
// ding's chat lines below format `SPELL_STAT0_NAME`..`4` through them.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
assert!(super::load_default_ui(&s).is_empty());
s.resolve();
let state = |s: &UiScript, button: &str| {
s.eval::<String>(&format!("return {button}:GetButtonState()"))
.unwrap()
};
let face = |s: &UiScript| {
s.eval::<f64>("return MicroButtonPortrait:GetAlpha()")
.unwrap()
};
assert_eq!(state(&s, "CharacterMicroButton"), "NORMAL");
assert_eq!(face(&s), 1.0);
// The button's own click (its OnClick, l.49-51) — the sheet's OnShow calls back.
s.run("ToggleCharacter(\"PaperDollFrame\")").unwrap();
assert_eq!(
state(&s, "CharacterMicroButton"),
"PUSHED",
"open sheet ⇒ button held down"
);
assert_eq!(
face(&s),
0.5,
"…and the face dims (CharacterMicroButton_SetPushed)"
);
s.run("ToggleCharacter(\"PaperDollFrame\")").unwrap();
assert_eq!(state(&s, "CharacterMicroButton"), "NORMAL");
assert_eq!(face(&s), 1.0);
// The reference's own full-screen panel; its OnShow/OnHide call back the same way.
s.run("ToggleWorldMap()").unwrap();
assert_eq!(state(&s, "WorldMapMicroButton"), "PUSHED");
s.run("ToggleWorldMap()").unwrap();
assert_eq!(state(&s, "WorldMapMicroButton"), "NORMAL");
// A ding with the sheet closed: sixty seconds on the pulse list (UiPanels.xml's
// SetButtonPulse, the reference's UIParent.lua machinery). The nine args are the ones
// `ui_unit` fires (level, health, power, talent points, five stats).
s.fire_event(
"PLAYER_LEVEL_UP",
vec![
ScriptValue::Int(61),
ScriptValue::Int(12),
ScriptValue::Int(0),
ScriptValue::Int(1),
ScriptValue::Int(1),
ScriptValue::Int(1),
ScriptValue::Int(1),
ScriptValue::Int(0),
ScriptValue::Int(0),
],
);
assert_eq!(
s.eval::<f64>("return TalentMicroButton.pulseTimeLeft")
.unwrap(),
60.0,
"the sixty-second pulse"
);
assert!(
s.eval::<bool>(
"for _, b in ipairs(PULSEBUTTONS) do if b == TalentMicroButton then return true end end \
return false"
)
.unwrap(),
"on the pulse list"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+380 -33
View File
@@ -1,4 +1,4 @@
//! The shipped `MinimapCluster.xml` driven engine-only: the +/- zoom buttons must re-sync their
//! The stock `Minimap.xml` (1974) driven engine-only: the +/- zoom buttons must re-sync their
//! enabled state when the active zoom index switches (stepping inside/outside a WMO flips to the
//! other, independent level). Regression guard for the director-caught stale-button bug (2026-07-09):
//! `ZoomIn` greyed from an outdoor max-zoom stayed greyed indoors at the middle default level.
@@ -21,11 +21,17 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// The shipped load order provides GameTooltip before the cluster; Minimap_Update's tooltip
// half (the PVP tint slice, decision 0287) touches it from OnLoad on.
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
// OnLoad seeds the +/- state from the live zoom. The CVar default is 3 — a middle level, so
// BOTH buttons start enabled (the old hardcoded `MinimapZoomOut:Disable()` assumed zoom 0).
@@ -86,9 +92,15 @@ fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
let vis = |s: &UiScript| {
s.eval::<bool>("return MiniMapTrackingFrame:IsVisible()")
@@ -132,11 +144,17 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript {
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Localization.xml");
s.run("LocalizeFrames()").unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT()`, which the reference's own tooltip and label formatting passes every string through.
load_xml(&s, "BasicControls.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
load_xml(&s, "Interface\\FrameXML\\GameTime.xml");
s
}
@@ -245,9 +263,15 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
s.resolve();
let cx = s
@@ -261,14 +285,16 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
// Nothing is parked until somebody clicks.
assert_eq!(s.take_minimap_ping_request(), None);
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc.
// A click 20 UI units right and 12 up of the centre — inside the 70-unit disc. The stock
// `Minimap_OnClick` adds its `CURSOR_OFFSET_X/Y` (−7, −9) to the cursor before subtracting
// the centre, so what it parks is the click less that offset.
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", true);
s.mouse_button(cx + 20.0, cy + 12.0, "LeftButton", false);
let (dx, dy) = s
.take_minimap_ping_request()
.expect("OnMouseUp → Minimap_OnClick → PingLocation");
assert!((dx - 20.0).abs() < 0.01, "x right of centre: {dx}");
assert!((dy - 12.0).abs() < 0.01, "y UP from centre: {dy}");
assert!((dx - 13.0).abs() < 0.01, "x right of centre, less 7: {dx}");
assert!((dy - 3.0).abs() < 0.01, "y UP from centre, less 9: {dy}");
// Draining is a drain: the app must not see the same click twice.
assert_eq!(s.take_minimap_ping_request(), None);
@@ -278,27 +304,35 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
assert_eq!(s.take_minimap_ping_request(), None, "off-widget is no ping");
}
/// `Minimap:GetPingPosition()` answers **nil** with no ping, and the live pair once the app
/// publishes one — never `(0, 0)` for "there isn't one", which a caller cannot tell from a ping
/// under its own feet.
/// `Minimap:GetPingPosition()` answers **two numbers always** (the reference recomputes them
/// from statics nothing clears — wow-re `minimap-ping-law.md`), and the stock
/// `Minimap_OnUpdate` leans on exactly that: while its 5 s timer runs it multiplies the answer
/// with no nil test. The pair is the app's last publish, `(0, 0)` before any.
#[test]
fn get_ping_position_is_nil_until_there_is_a_ping() {
fn get_ping_position_answers_two_numbers_always() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "MinimapCluster.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
assert_eq!(s.arity("Minimap:GetPingPosition()").unwrap(), 2);
assert_eq!(
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
.unwrap(),
None
s.eval::<f32>("return (Minimap:GetPingPosition())").unwrap(),
0.0,
"before any ping: a number, not nil"
);
s.set_minimap_ping(Some((0.25, -0.125)));
s.set_minimap_ping((0.25, -0.125));
let x = s
.eval::<f32>("local x = Minimap:GetPingPosition(); return x")
.unwrap();
@@ -309,10 +343,323 @@ fn get_ping_position_is_nil_until_there_is_a_ping() {
(x - 0.25).abs() < 1e-6 && (y + 0.125).abs() < 1e-6,
"{x} {y}"
);
s.set_minimap_ping(None);
assert_eq!(
s.eval::<Option<f32>>("return (Minimap:GetPingPosition())")
.unwrap(),
None
// The stock lifetime, end to end: MINIMAP_PING shows the model frame and starts the 5 s
// timer; OnUpdate re-seats it from GetPingPosition every frame; past 5 s it "fades" and
// hides. The frame's own file draws in it (`crate::ui_models`, decision 2008).
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"hidden until a ping"
);
s.fire_event(
"MINIMAP_PING",
vec![
benilla_ui::script::ScriptValue::Str("player".into()),
benilla_ui::script::ScriptValue::Number(0.25),
benilla_ui::script::ScriptValue::Number(-0.125),
],
);
assert!(s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap());
s.tick(1.0);
assert!(
s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"held at 1 s"
);
let cx = s
.eval::<f32>(
"local x = MiniMapPing:GetCenter(); local mx = Minimap:GetCenter(); return x - mx",
)
.unwrap();
assert!(
(cx - 0.25 * 140.0).abs() < 0.5,
"re-seated at the normalized offset times the width: {cx}"
);
s.tick(4.2);
s.tick(0.6);
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"5 s hold + 0.5 s fade, then hidden"
);
}
/// The meeting-stone icon (stock `Minimap.xml`, 1974): hidden until `IsInMeetingStoneQueue()`
/// answers across `MEETINGSTONE_CHANGED`, its hover the cached status text, hidden again when
/// the queue empties — and a click raises the leave-queue question.
#[test]
fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\BattlefieldFrame.xml",
"Interface\\FrameXML\\Minimap.xml",
] {
load_xml(&s, f);
}
s.resolve();
let vis = |s: &UiScript| {
s.eval::<bool>("return MiniMapMeetingStoneFrame:IsVisible()")
.unwrap()
};
assert!(!vis(&s), "hidden at load");
s.set_meeting_stone(1519, Some("Looking for more for Stormwind City".into()));
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
assert!(vis(&s), "a queued area shows the icon");
s.run("this = MiniMapMeetingStoneFrame; MiniMapMeetingStoneFrame:GetScript('OnEnter')()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Looking for more for Stormwind City"
);
s.run("MiniMapMeetingStoneFrame:Click()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a click asks CONFIRM_LEAVE_QUEUE"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(
s.take_meeting_stone_cancels(),
1,
"Accept is CancelMeetingStoneRequest()"
);
s.set_meeting_stone(0, Some("Looking for more for Unknown".into()));
s.fire_event("MEETINGSTONE_CHANGED", vec![]);
assert!(!vis(&s), "area 0 hides it again");
}
/// **The ping's pixels are the stock `<Model>`'s own file** (decision 2008): shown by
/// `MINIMAP_PING` with the file's facts landed, the extract publishes ONE tile request for the
/// pane — at its device size, the render law's unit ladder, and the composite's rect/key/alpha
/// — and pushes no quad of its own. Once the renderer has handed back a cell, the cell draws as
/// one premultiplied quad over the pane's rect **on the very next frame, with nothing else in
/// the interface moving** (decision 2023): the composite is the renderer's per-frame output in
/// the overlay lane, never a product of the memoized conversion — which is exactly what the
/// first shape got wrong, and why this test used to re-ping to "move the pane" before asking
/// for the quad. Drives the real UI pass in the headless harness the clip-plumb tests
/// use.
#[test]
fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
use crate::ui_models::{Cell, UiModelTiles};
use crate::ui_pass::UiQuads;
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BattlefieldFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Minimap.xml");
// The file's facts, as `benilla-extract m2seq` reads them off the install: the ping's
// `SetSequence(0)` from its OnLoad was queued behind them and replays now.
const PING: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
let seq = |anim_id, duration_ms, looping| SequenceFacts {
anim_id,
duration_ms,
looping,
};
s.set_model_facts(
PING,
ModelFileFacts {
sequences: vec![seq(127, 1333, false), seq(0, 833, true), seq(1, 333, false)],
bbox: ([0.0; 3], [0.0; 3]),
cameras: 0,
},
);
assert!(
s.visible_model_panes().is_empty(),
"hidden until a ping: nothing to paint"
);
let ping_at = |s: &mut UiScript, nx: f64, ny: f64| {
s.fire_event(
"MINIMAP_PING",
vec![
benilla_ui::script::ScriptValue::Str("player".into()),
benilla_ui::script::ScriptValue::Number(nx),
benilla_ui::script::ScriptValue::Number(ny),
],
);
};
ping_at(&mut s, 0.25, -0.125);
let panes = s.visible_model_panes();
assert_eq!(panes.len(), 1, "the ping pane is on the paint list");
let pane = panes[0];
assert_eq!(
pane.play.map(|p| (p.anim_id, p.cursor_ms)),
Some((0, 0)),
"the OnLoad's SetSequence(0) replayed over the seed: Stand, at 0"
);
let mut app = App::new();
app.insert_non_send_resource(s);
app.init_resource::<UiQuads>();
// The pass's own handover (2168) — the tick half writes it, the paint half reads it.
app.init_resource::<crate::ui_script::UiPassState>();
app.init_resource::<Assets<Image>>();
app.init_resource::<crate::portrait::PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<UiModelTiles>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
app.init_resource::<Time<Real>>();
app.init_resource::<crate::ui_script::UiClock>();
app.init_resource::<crate::ui_script::UiScaleCvar>();
app.world_mut().spawn((
Window {
resolution: UVec2::new(1024, 768).into(),
..default()
},
PrimaryWindow,
));
// The extract, then the composite off the bridge — the app's own order (the appender runs
// in the `UiQuadAppend` window, after `sync_tiles`, which packs the cells this harness hands
// over by hand below). The lane is cleared by hand between frames, as `clear_ui_overlays`
// does at the top of every append window.
app.add_systems(
Update,
(
(super::extract::tick_script, super::extract::paint_script).chain(),
crate::ui_models::compose_tiles,
)
.chain(),
);
app.update();
let premultiplied = |app: &App| {
let quads = app.world().resource::<UiQuads>();
assert!(
quads.quads.iter().all(|q| !q.premultiplied),
"the base lane never carries a tile: the composite is not the extract's"
);
quads
.overlays
.iter()
.filter(|q| q.premultiplied)
.cloned()
.collect::<Vec<_>>()
};
let next_frame = |app: &mut App| {
app.world_mut().resource_mut::<UiQuads>().overlays.clear();
app.update();
};
{
let tiles = app.world().resource::<UiModelTiles>();
let req = tiles
.requests
.get(&pane.handle)
.expect("one request for the ping pane");
assert_eq!(req.path, PING);
// `<Size>50×50</Size>` at s = 1 (768-tall window), DPI 1: 50 device px a side.
assert_eq!(req.size_px, UVec2::new(50, 50));
// `scale="0.4"`: 1280 · 0.4 = 512 px per model unit — 1596's own "1 model unit at 512 px".
assert!(
(req.px_per_unit - 512.0).abs() < 1e-3,
"{}",
req.px_per_unit
);
// A layout unit, and a particle's unit: 768 · √((4/3)² + 1) = 1280 at 4:3.
assert!(
(req.pos_px_per_unit - 1280.0).abs() < 0.1,
"{}",
req.pos_px_per_unit
);
assert!(
(req.star_px_per_unit - 1280.0).abs() < 0.1,
"{}",
req.star_px_per_unit
);
assert_eq!(req.icon, None);
// The composite's own inputs: the pane's rect in the quad pass's space (y-down window
// px), its callback rank, its own alpha.
assert!(
(req.rect.width() - 50.0).abs() < 1e-3 && (req.rect.height() - 50.0).abs() < 1e-3,
"{:?}",
req.rect
);
assert!((req.alpha - 1.0).abs() < 1e-6);
assert!(premultiplied(&app).is_empty(), "no cell yet: nothing drawn");
}
// The renderer hands a cell back. NOTHING else changes — no ping, no frame moves, the
// memoized conversion skips — and the next frame draws the cell anyway.
{
let atlas = app
.world_mut()
.resource_mut::<Assets<Image>>()
.add(Image::default());
let mut tiles = app.world_mut().resource_mut::<UiModelTiles>();
tiles.atlas = Some(atlas);
tiles.atlas_size = UVec2::splat(512);
tiles.cells.insert(
pane.handle,
Cell {
origin: UVec2::new(2, 2),
size: UVec2::new(50, 50),
},
);
}
next_frame(&mut app);
let drawn = premultiplied(&app);
assert_eq!(drawn.len(), 1, "the cell, once, on a quiet frame");
let q = &drawn[0];
assert!((q.rect.width() - 50.0).abs() < 1e-3 && (q.rect.height() - 50.0).abs() < 1e-3);
let [tl, _, br, _] = q.uv.corners;
assert!((tl[0] - 2.0 / 512.0).abs() < 1e-6 && (tl[1] - 2.0 / 512.0).abs() < 1e-6);
assert!((br[0] - 52.0 / 512.0).abs() < 1e-6 && (br[1] - 52.0 / 512.0).abs() < 1e-6);
assert_eq!(q.color, [1.0, 1.0, 1.0, 1.0], "the frame's own alpha");
assert!(q.texture.is_some());
// And every quiet frame after it — the lane is re-emitted per frame, not on change.
next_frame(&mut app);
assert_eq!(
premultiplied(&app).len(),
1,
"still drawn on the frame after"
);
// A fresh ping moves the pane: the conversion runs again, the request follows the rect, and
// the composite follows the request — still exactly one quad.
{
let mut script = app.world_mut().non_send_resource_mut::<UiScript>();
// The stock handler seats the frame from `GetPingPosition()`, not the event's args.
script.set_minimap_ping((-0.125, 0.25));
ping_at(&mut script, -0.125, 0.25);
}
next_frame(&mut app);
let moved = premultiplied(&app);
assert_eq!(moved.len(), 1, "one quad after the pane moved");
assert_ne!(moved[0].rect, q.rect, "the composite followed the pane");
}
@@ -15,18 +15,22 @@ use super::test_ui::load_ui as load_xml;
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// The bars' START driver: `UIParent_OnEvent`'s MIRROR_TIMER_START arm is what calls
// `MirrorTimer_Show`, which is where the reference keeps it (UIParent.lua l.97 + l.374-377)
// and where window 7 moved ours back to. Without this file the event reaches nothing.
load_xml(&s, "UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
// `STATICPOPUP_NUMDIALOGS`, which the reference's own `MirrorTimer_Show` bounds its free-bar
// search by (MirrorTimer.lua l.32 — a copy-paste from StaticPopup.lua, and its own bug). A
// session without it searches `1, nil` and finds no free bar at all, so this is not scenery.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\MirrorTimer.xml");
s
}
@@ -78,12 +82,14 @@ fn bar_color(s: &UiScript, frame: &str) -> (f64, f64, f64) {
.unwrap()
}
fn caption(s: &UiScript, frame: &str) -> String {
s.eval::<String>(&format!("return {frame}Text:GetText()"))
/// `Option`, not `String`: a blank caption reads back **nil**, because `FontString:GetText
/// 0x79d690` substitutes nil for an empty string (decision 2110).
fn caption(s: &UiScript, frame: &str) -> Option<String> {
s.eval::<Option<String>>(&format!("return {frame}Text:GetText()"))
.unwrap()
}
/// One tick of the app's real order (`drive_script`): OnUpdate, resolve, then the draw list.
/// One tick of the app's real order (`tick_script` then `paint_script`): OnUpdate, resolve, then the draw list.
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
s.tick(dt);
s.resolve();
@@ -122,7 +128,7 @@ fn breath_takes_the_first_bar_in_the_reference_blue() {
assert!(shown(&s, "MirrorTimer1"));
assert!(!shown(&s, "MirrorTimer2"), "only one timer is running");
assert_eq!(caption(&s, "MirrorTimer1"), "Breath");
assert_eq!(caption(&s, "MirrorTimer1").as_deref(), Some("Breath"));
// Milliseconds on the wire, seconds in the bar — the reference's `/1000` on both.
assert_eq!(bar_value(&s, "MirrorTimer1"), 45.0);
assert_eq!(bar_max(&s, "MirrorTimer1"), 60.0);
@@ -143,7 +149,7 @@ fn fatigue_is_the_reference_yellow_under_its_own_caption() {
start(&mut s, "EXHAUSTION", 60_000, 60_000, -1, "Fatigue");
assert!(shown(&s, "MirrorTimer1"));
assert_eq!(caption(&s, "MirrorTimer1"), "Fatigue");
assert_eq!(caption(&s, "MirrorTimer1").as_deref(), Some("Fatigue"));
let (r, g, b) = bar_color(&s, "MirrorTimer1");
assert!(
(r - 1.0).abs() < 1e-6 && (g - 0.9).abs() < 1e-6 && (b - 0.0).abs() < 1e-6,
@@ -249,8 +255,8 @@ fn two_timers_stack_and_restate_in_place() {
assert!(shown(&s, "MirrorTimer1") && shown(&s, "MirrorTimer2"));
assert!(!shown(&s, "MirrorTimer3"));
assert_eq!(caption(&s, "MirrorTimer1"), "Fatigue");
assert_eq!(caption(&s, "MirrorTimer2"), "Breath");
assert_eq!(caption(&s, "MirrorTimer1").as_deref(), Some("Fatigue"));
assert_eq!(caption(&s, "MirrorTimer2").as_deref(), Some("Breath"));
// The server re-sends breath on every change; it must reuse frame 2, not take frame 3.
start(&mut s, "BREATH", 30_000, 60_000, -1, "Breath");
@@ -279,8 +285,8 @@ fn stop_hides_that_timer_and_frees_its_frame() {
assert!(shown(&s, "MirrorTimer1"));
assert_eq!(
caption(&s, "MirrorTimer1"),
"",
"no FEIGNDEATH_LABEL exists in the 1.12 GlobalStrings"
None,
"no FEIGNDEATH_LABEL exists in the 1.12 GlobalStrings, and a blank caption reads nil"
);
}
+299 -26
View File
@@ -126,7 +126,7 @@ pub(crate) struct InspectMode {
pub(crate) enabled: bool,
}
/// One frame's UI-pass phase split, in μs — written by [`extract::drive_script`] under the same
/// One frame's UI-pass phase split, in μs — written by [`extract::tick_script`] and [`extract::paint_script`] under the same
/// marks the `[ui-cost]` line prints. **Owned by the producer** (decision 1174): the split is a
/// fact this pass publishes about itself, so it must exist whether or not the recorder that reads
/// it (`hover_log`) is compiled in. Its consumers are instruments; its writer is not.
@@ -178,16 +178,33 @@ pub(crate) struct UiCostWanted(pub(crate) bool);
#[derive(Resource, Default)]
pub(crate) struct PlayerUiHover(pub(crate) Option<u32>);
/// Set each frame by [`feed_ui_input`] to [`UiScript::has_keyboard_focus`]: true while an EditBox owns
/// keyboard focus and is eating every key. The gameplay/dev keyboard readers (movement, the Z sheath,
/// the HUD/panel/inspect toggles, chat-open, target-clear) gate on it so a key typed into a focused box
/// never also drives the world — the app-side twin of the client's `DAT_00cf4dc8 != 0` gate (RF-0082
/// §1). One mechanism, written once here; every reader is ordered after `UiInput` so it sees this
/// frame's value, not last frame's.
/// **Who owns this frame's keys** — written by [`feed_ui_input`], read by the binding dispatch and the
/// dev keyboard readers, all of which are ordered after `UiInput` so they see this frame's value and
/// not last frame's. The app-side twin of the client's `DAT_00cf4dc8 != 0` gate (RF-0082 §1).
///
/// **Two fields because the reference has two mechanisms, and collapsing them into one boolean was
/// bug 2196.** A focused EditBox swallows *every* key for as long as it holds focus
/// ([`typing`](Self::typing)); a shown keyboard-enabled *frame* swallows *the one key* its
/// existence gate ate this frame ([`consumed`](Self::consumed)). Both suppress the key's binding —
/// and nothing more. Neither releases anything already held: the only things that clear the
/// reference's direction bits are the OS **window deactivate** (`0x514490`, whose sole caller
/// `0x493058` hangs off the WM_ACTIVATE callback slot) and the world-enter cascade (`0x5144c0`).
/// A UI focus change clears nothing — wow-re `rf79-autorun-cancel-set.md`, and the reason holding
/// W keeps you running while you type or read the map. See `decisions/2196`.
#[derive(Resource, Default)]
pub(crate) struct UiKeyboardCapture {
/// True while a focused EditBox is eating every key.
/// True while a focused EditBox is eating every key (`0x77b35e` returns 1 on every path but
/// the alt-arrow one below). Whole-frame, because there is at most one focused box.
pub(crate) typing: bool,
/// The keys a shown keyboard-enabled **frame** consumed this frame (decision 1319's existence
/// gate, wow-re `frame-key-script-delivery.md` §3) — `WorldMapFrame`'s fullscreen `OnKeyDown`,
/// `CinematicFrame`, the stack-split spinner. **Per key, not per frame**: the map eating its
/// own `M` must not also suppress an unrelated binding, and — the bug this list exists for —
/// must not be mistaken for a text box taking focus.
///
/// Raw [`bevy::input::keyboard::KeyCode`]s, as the message carried them (the binding dispatch
/// normalizes for chord lookup, but matches this list on the raw code it read).
pub(crate) consumed: Vec<bevy::input::keyboard::KeyCode>,
/// **The four arrow keys are exempt this frame** — the focused box is in alt-arrow mode
/// (`ignoreArrows` / `SetAltArrowKeyMode`) and ALT is not held, so the reference's own key
/// handler declines LEFT/UP/RIGHT/DOWN at `0x77b1c4` and the strata walk carries them down to
@@ -218,6 +235,57 @@ pub(crate) struct PlayerUiClickConsumed(pub(crate) bool);
#[derive(Resource, Default)]
pub(crate) struct CursorPayloadHeld(pub(crate) bool);
/// What [`extract::tick_script`] hands [`extract::paint_script`] — the UI pass is two systems, and
/// this is the frame they agree on (decision 2168).
///
/// `live` is the load-bearing field: the tick half returns early with no VM or no window, and the
/// paint half must then do nothing at all rather than re-derive a seam for a frame the VM never
/// had. Everything else is a value the two halves must not compute twice, because computing it
/// twice is how they would come to disagree.
#[derive(Resource, Default)]
pub(crate) struct UiPassState {
/// The tick half ran to completion this frame.
pub(crate) live: bool,
/// The 768-virtual seam scale (decision 0582/0584) this frame was ticked under.
pub(crate) seam: f32,
/// The window's device scale factor.
pub(crate) dpi: f32,
/// The meter's first three phases, so one `[ui-cost]` row still describes one frame.
pub(crate) us_tick: u128,
pub(crate) us_resolve: u128,
pub(crate) us_measure: u128,
/// The layout counters as they stood before the tick, so the row's `solves`/`derives` cover
/// BOTH halves' resolves.
pub(crate) solves_before: u64,
pub(crate) derives_before: u64,
}
/// **The pointer is over UI CHROME** — [`PointerOverUi`] minus the nameplates.
///
/// A plate is real mouse-enabled UI (2148/2159): it takes the hover, fires an addon's `OnEnter`,
/// and a click on it selects. It is not a *panel*, though, and the two consumers that mean "the
/// player is working in the interface, keep the world's hands off the mouse" have to say so:
///
/// - the camera's world-mouse latch — a right-drag that starts on a plate must still turn the view
/// (decision 2159 §2, which argued it from the reference's own `0x60f830`);
/// - the WHEEL — scrolling with the cursor on a plate must still zoom.
///
/// Both were regressions of the day a plate became a widget, and both are the same mistake, so
/// they are one named concept rather than two `&& plate_hover.is_none()`s.
///
/// **This is a narrowing of a coarse flag, not a fidelity claim.** The reference has no
/// "pointer over UI" boolean at all: it walks the strata for a frame that handles the message, and
/// a notch over any frame with no `OnMouseWheel` falls through to `CGWorldFrame` — an action button
/// included. Ours is one bit for the whole interface, and the plate is where that bit is visibly
/// wrong. Widening it to the reference's per-frame dispatch is its own arc.
#[derive(Resource, Default)]
pub(crate) struct PointerOverUiPanel(pub(crate) bool);
/// The player-UI PAINT pass — the quad walk ([`extract::paint_script`]), ordered after the world's
/// camera update so that world-anchored widgets are drawn from this frame's camera (decision 2168).
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct UiPaint;
/// The player-UI input pass — hit-testing + handler firing. Ordered before [`WorldStage::Input`] so
/// the [`PlayerUiHover`] it produces is folded into `PointerOverUi` before `player::control` reads it.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -226,7 +294,7 @@ pub(crate) struct UiInput;
/// The frame's atomic (`Instant`, `GetTime`) clock pair — the ONE lawful base for mapping a
/// store-side `Instant` onto the VM's `GetTime` clock (`CooldownInfo::ui_triple` and kin).
///
/// Written at the single `script.tick` site ([`extract::drive_script`]): `ui_now` is the value the
/// Written at the single `script.tick` site ([`extract::tick_script`]): `ui_now` is the value the
/// VM clock just advanced to, `anchor` is `Time<Real>`'s own `last_update()` — the exact instant
/// whose frame-to-frame differences ARE the deltas the VM clock accumulates. Because both legs
/// advance in lockstep by construction, a conversion `ui_now - (anchor - start)` yields the SAME
@@ -235,14 +303,23 @@ pub(crate) struct UiInput;
/// shape) re-measures the tick→feed scheduling gap every frame and wobbles the derived start by
/// that jitter (±12 ms observed live), turning every running cooldown into a per-frame "changed"
/// triple — the diff churn 0375 existed to kill.
///
/// **Both legs run on the PROCESS's clock and neither restarts with the VM** (decision 2116). A
/// rebuilt VM (any logout/login, any `ReloadUI`) is handed the running clock by
/// [`lifecycle::seed_vm_clock`], which writes this pair in the same breath — the reference's
/// `GetTime` is `KERNEL32!GetTickCount`, an OS clock, and stock `Cooldown.lua` gates on
/// `start > 0`, so a clock that went back to zero at the character screen pushed every already-
/// running cooldown into the past and hid its sweep.
#[derive(Resource)]
pub(crate) struct UiClock {
/// The `Instant` leg: `Time<Real>::last_update()` at the tick that produced [`Self::ui_now`].
pub(crate) anchor: std::time::Instant,
/// The `GetTime` leg: the VM clock's value after that tick (seconds).
/// The `GetTime` leg: the clock's value after that tick (seconds since this process started).
pub(crate) ui_now: f64,
}
/// The pre-boot pair, replaced by [`lifecycle::seed_vm_clock`] the moment the first VM exists —
/// `init_resource` needs it, nothing else should build one.
impl Default for UiClock {
fn default() -> Self {
Self {
@@ -337,7 +414,7 @@ impl Plugin for UiScriptPlugin {
app.insert_resource(UiScaleCvar(default_ui_scale()))
// The UI pass publishes its per-frame phase split here every frame the cost meter or
// the hover recorder is armed; the producer owns the resource so any minimal app that
// runs `drive_script` (the extract tests) has it.
// runs the UI pass (the extract tests) has it.
.init_resource::<UiFrameCost>()
.init_resource::<UiCostWanted>()
.init_resource::<PointerOverUi>()
@@ -377,6 +454,11 @@ impl Plugin for UiScriptPlugin {
OnExit(crate::char_select::ClientState::InWorld),
end_ui_session,
)
// The world latch (2239) and its create-side arm. The resource is `init_` rather than
// `insert_` here and in [`crate::ui_unit::UiUnitPlugin`], because both of that law's
// producers live in different plugins and either may be built alone in a test.
.init_resource::<LeavingWorldArmed>()
.add_systems(Update, lifecycle::arm_leaving_world_on_self_create)
// A queued `ReloadUI()` runs in `PreUpdate` — one whole frame after the drain that
// queued it (the reference's own deferral, `0x495590`), and BEFORE every `Update`
// system, so no per-VM seed or feed can run against the dying VM in the reload frame
@@ -395,15 +477,17 @@ impl Plugin for UiScriptPlugin {
// `GetFramerate()`'s host half (decision 1195), before the VM ticks so an `OnUpdate`
// handler reads this frame's number rather than the previous one's.
.add_systems(Update, feed_framerate.before(UiInput))
// `drive_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// `tick_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
// pass is in-world only (decision 0193): the character-select glue screen owns the
// pointer + keyboard there (its exit edge resets the latches this pass normally drives).
.init_resource::<UiPassState>()
.init_resource::<PointerOverUiPanel>()
.add_systems(
Update,
(
extract::drive_script,
extract::tick_script,
input::feed_ui_input.run_if(in_state(crate::char_select::ClientState::InWorld)),
)
.chain()
@@ -414,6 +498,19 @@ impl Plugin for UiScriptPlugin {
.before(crate::bindings::BindingSet)
.before(WorldStage::Input),
)
// **The paint half, after the camera** (decision 2168): the quad walk runs once the
// world's own update has happened, so a widget anchored to the world — the nameplates,
// which since 2148 are real `WorldFrame` children seated by `vplates::drive_vplates`
// out of THIS frame's camera — is drawn where it belongs instead of a frame behind.
// Bounded on both sides: after the plate driver that writes those anchors, and before
// the append lane, whose minimap producer fills the widget slot this pass parks.
.add_systems(
Update,
extract::paint_script
.in_set(UiPaint)
.after(crate::vplates::VPlateSet)
.before(crate::ui_pass::UiQuadAppend),
)
// Combine the two pointer contributions (dev overlay + player UI) into the single
// `PointerOverUi` source of truth, after the hover is known and before gameplay reads it.
.add_systems(
@@ -439,9 +536,11 @@ impl Plugin for UiScriptPlugin {
/// Should this CAPTURE include the player UI (+ the synthetic unit snapshot)? The visual harness's
/// baselines must stay UI-free (they regression-test the WORLD render), so captures skip the UI
/// unless `WOW_CAPTURE_UI=1` opts in. Normal runs always load the UI and never take synthetic data.
/// unless it is opted in — [`crate::run_mode::capture_ui_opted_in`], the SAME predicate the UI load and the
/// window sizing use, because the three disagreeing is how `ui-unitframes` ends up photographing
/// empty frames. Normal runs always load the UI and never take synthetic data.
fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool {
capture.is_some() && std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
capture.is_some() && crate::run_mode::capture_ui_opted_in()
}
/// The pointer arbiter (decision 0026): `PointerOverUi = egui dev overlay ∨ player-UI hover`. Runs
@@ -451,7 +550,9 @@ fn capture_ui_active(capture: Option<Res<crate::run_mode::CaptureMode>>) -> bool
fn arbitrate_pointer_over_ui(
egui: Option<Res<EguiPointerOver>>,
hover: Res<PlayerUiHover>,
plate_hover: Res<crate::vplates::PlateHover>,
mut over: ResMut<PointerOverUi>,
mut panel: ResMut<PointerOverUiPanel>,
) {
// A cinematic used to need a third term here, and no longer does — the deletion is the point
// of decision 1734. `CinematicFrame` is `setAllPoints` + `enableMouse="true"`, so while it is
@@ -459,19 +560,23 @@ fn arbitrate_pointer_over_ui(
// every other frame out of it. The hit test now arrives at that on its own, so
// [`PlayerUiHover`] carries it and the special case is gone.
over.0 = egui.is_some_and(|e| e.0) || hover.0.is_some();
// A nameplate is mouse-enabled UI and it is not CHROME — see [`PointerOverUiPanel`]. The plate
// hover is last frame's (the plate driver runs after the camera), which is the same vintage the
// camera's own plate exception has always used.
panel.0 = over.0 && plate_hover.0.is_none();
}
/// The session lifecycle — the VM's birth, identity, death, and the reload. Every edge function
/// lives there; this module keeps the per-frame bridge. See its header.
mod lifecycle;
pub(crate) use lifecycle::{
end_ui_session, ingame_ui_pending, run_pending_reload, setup_script, AddOnIdentity,
PendingEntryUiLoad, ReloadUiPending,
end_ui_session, ingame_ui_up, run_pending_reload, setup_script, AddOnIdentity,
LeavingWorldArmed, PendingEntryUiLoad, ReloadUiPending,
};
// Consumed only from other modules' test code (the emote-table checks, the harness's UI-init
// tail, the quit-once pin) — a plain re-export would warn unused in a non-test build.
#[cfg(test)]
use lifecycle::load_ingame_ui_on_world_entry;
pub(crate) use lifecycle::load_ingame_ui_on_world_entry;
use lifecycle::shutdown_on_exit;
#[cfg(test)]
pub(crate) use lifecycle::{
@@ -486,7 +591,7 @@ pub(crate) use lifecycle::{
/// that nobody can take a value off. The pole is a one-second time constant — fast enough that a
/// real stall is visible within a frame or two, slow enough to read.
///
/// `Time<Real>` deliberately, the same choice `drive_script` documents: this is a *frame rate*, and
/// `Time<Real>` deliberately, the same choice `tick_script` documents: this is a *frame rate*, and
/// a virtual clock that clamps its delta would report a rate the machine is not achieving.
fn feed_framerate(
script: Option<NonSendMut<UiScript>>,
@@ -649,7 +754,7 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
),
(80, "Interface\\Icons\\INV_Misc_Food_16", 0x80, 117, 5),
(84, "Interface\\Icons\\Spell_Holy_SealOfMight", 0x00, 103, 0),
// The always-on multibars (MultiBars.xml): BottomLeft = actions 61..72, BottomRight
// The always-on multibars (stock MultiActionBars.xml): BottomLeft = actions 61..72, BottomRight
// = 49..60. A few occupied wells on each so the capture shows both rows seated
// (empty multibar wells hide — the ref's own default — so without these the rows
// would be invisible).
@@ -694,7 +799,7 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
}),
);
}
// The stance bar (StanceBar.xml): the synthetic warrior's three stances, battle active —
// The stance bar (stock BonusActionBarFrame.xml): the synthetic warrior's three stances, battle active —
// matches the bonus offset 1 above (battle stance page). Defensive shows the not-castable
// grey; berserker a running cooldown swipe.
script.set_shapeshift_forms(vec![
@@ -735,10 +840,16 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
// choice about what to photograph, not a default: `MultiActionBar_Update` is the same
// function the row calls, so nothing here is a private door into the bars. Guarded because
// this feed also runs with `WOW_CAPTURE_UI` unset, where no interface has been loaded.
let _ = script.run(
"SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 \
if MultiActionBar_Update then MultiActionBar_Update() end",
);
// `WOW_DEMO_BOTTOM_BARS=0` leaves them down — the shipped default a player logs in to
// (1500), and the only state in which the stance SHELF draws at all: the manage pass
// hides the shelf art whenever the bottom-left bar is up, so a capture of the shelf
// (decision 2000's hairline lived on it) needs the bars where the player has them.
if std::env::var("WOW_DEMO_BOTTOM_BARS").as_deref() != Ok("0") {
let _ = script.run(
"SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 \
if MultiActionBar_Update then MultiActionBar_Update() end",
);
}
script.fire_event("PLAYER_XP_UPDATE", vec![]);
for token in ["player", "target"] {
script.fire_event("UNIT_HEALTH", vec![ScriptValue::Str(token.into())]);
@@ -767,7 +878,54 @@ impl benilla_ui::script::TextMeasure for FixedWidthFont {
}
#[cfg(test)]
mod test_ui;
pub(crate) mod test_ui;
/// The chat loader's two login events (wow-re chat-cache-grammar.md §8; `ui_chat::settings`):
/// `UPDATE_CHAT_WINDOWS` once, then `UPDATE_CHAT_COLOR` for every registry entry. The reference's
/// `FloatingChatFrame_Update` docks, hides and colours the windows on the first and
/// `ChatFrame_OnEvent` fills `ChatTypeInfo` from the second, so a test VM that loads the
/// reference's chat files needs both before the windows look or route like a client's.
#[cfg(test)]
pub(crate) fn fire_chat_login(s: &mut benilla_ui::script::UiScript) {
// `FCF_OnUpdate` reads `UIOptionsFrame:IsShown()` every frame — the reference's options
// window, which OptionsFrame.xml aliases and a chat test VM does not load. A hidden stand-in
// is what a closed options window answers.
s.run("if not UIOptionsFrame then UIOptionsFrame = CreateFrame('Frame') UIOptionsFrame:Hide() end")
.expect("the options stand-in");
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
let renorm = |b: u8| f64::from(b as f32 * (1.0f32 / 255.0f32));
for entry in s.chat_colors() {
s.fire_event(
"UPDATE_CHAT_COLOR",
vec![
benilla_ui::script::ScriptValue::Str(entry.name),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[0])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[1])),
benilla_ui::script::ScriptValue::Number(renorm(entry.rgb[2])),
],
);
}
}
/// The `benilla_formats::TokenContext::text` seam (decision 2045): resolve a `GlobalStrings` key
/// out of the VM and fill its `%d` holes through the one shared filler.
///
/// The split it implements is deliberate. `benilla-formats` walks the spell's columns and knows
/// which key and which numbers a `$d` or `$s` token wants; it has no business knowing about a
/// script VM or carrying a printf-family formatter, and it depends on neither crate that has one.
/// So it names the key, and this renders it.
pub(crate) fn token_text(
script: &benilla_ui::script::UiScript,
) -> impl Fn(&str, &[i64]) -> Option<String> + '_ {
|key: &str, args: &[i64]| {
let template = benilla_ui::strings::global(script.lua(), key)?;
let args: Vec<_> = args
.iter()
.map(|n| benilla_ui::strings::Arg::D(*n))
.collect();
Some(benilla_ui::strings::fill(&template, &args))
}
}
/// [`test_ui::load_ui`] for a test module OUTSIDE `ui_script` — `ui_action::feed_tests` drives the
/// real `UIErrorsFrame` end to end and needs the same both-stores reader everything else uses.
@@ -818,6 +976,9 @@ mod pet_bar_tests;
#[cfg(test)]
mod pet_frame_tests;
#[cfg(test)]
mod pet_stable_tests;
#[cfg(test)]
mod tot_frame_tests;
@@ -853,6 +1014,17 @@ mod panel_template_tests;
#[cfg(test)]
mod shape_gate;
/// The event ARGUMENT-shape gate (decision 2140) — the same question one API over: the two event
/// gates in `reference_ui` compare names, and nothing compared what a fire site pushes.
#[cfg(test)]
mod event_shape_gate;
/// The VERB-FIRED event gate (decision 2251) — the third question on the same seam: 1883/1889
/// compare names and 2140 compares arguments; this asks WHO fires it, because a stock file that
/// calls a verb for its side effect of an event repaints nothing when the verb fires nothing.
#[cfg(test)]
mod verb_event_gate;
/// The reference's BasicControls.xml — TEXT/message/_ERRORMESSAGE and the ScriptErrors dialog,
/// none of which benilla itself calls: every test enters from Lua the way an addon does.
#[cfg(test)]
@@ -884,6 +1056,11 @@ mod group_loot_tests;
#[cfg(test)]
mod chat_tests;
/// The chat bubble's `UIMenu` kit driven as a menu — the rows' label/shortcut anchoring
/// (decision 1996), kept apart from `chat_tests` because it is the kit under test, not the window.
#[cfg(test)]
mod ui_menu_tests;
/// The chat tab's options menu, end to end (decision 1589 / B246) — its own file because it needs
/// the whole dropdown + colour-picker stack under `ChatFrame.xml`, where `chat_tests` deliberately
/// runs on the window alone.
@@ -994,6 +1171,12 @@ mod quest_tests;
#[cfg(test)]
mod quest_timer_tests;
#[cfg(test)]
mod battlefield_tests;
#[cfg(test)]
mod tutorial_tests;
#[cfg(test)]
mod durability_tests;
// `pub(crate)` for its `harness`/`push`/`row` helpers: `perf::hud`'s own test drives the readout
@@ -1065,6 +1248,9 @@ mod quiver_tests;
#[cfg(test)]
mod world_entry_tests;
/// The guild tabard designer — the stock `TabardFrame.xml` off the chain (decision 1977).
#[cfg(test)]
mod tabard_tests;
/// The world map's POI pool — the guard's directions marker today, the AreaPOI landmarks later.
#[cfg(test)]
mod world_map_tests;
@@ -1086,3 +1272,90 @@ mod seam_scale_tests {
assert_eq!(seam_scale(0.0, 0.9), 1.0);
}
}
#[cfg(test)]
mod pointer_arbiter_tests {
use super::*;
/// A world it can run the arbiter in: the three inputs and the two outputs, nothing else.
fn app() -> App {
let mut app = App::new();
app.init_resource::<PlayerUiHover>()
.init_resource::<crate::vplates::PlateHover>()
.init_resource::<PointerOverUi>()
.init_resource::<PointerOverUiPanel>()
.add_systems(Update, arbitrate_pointer_over_ui);
app
}
/// **A plate is UI, and it is not chrome.** The camera's world-mouse latch and the wheel's zoom
/// binding both read the second bit; both were dead over a plate until they did (2168).
#[test]
fn a_hovered_plate_is_ui_but_not_chrome() {
let mut app = app();
let plate = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.world_mut()
.resource_mut::<crate::vplates::PlateHover>()
.0 = Some(plate);
app.update();
assert!(
app.world().resource::<PointerOverUi>().0,
"the plate takes the pointer"
);
assert!(
!app.world().resource::<PointerOverUiPanel>().0,
"…and the WHEEL looks straight through it"
);
}
/// …and what each of the two bits is now FOR, which is the half 2233 moved.
///
/// The camera reads the raw flag: a press landing on a plate is the plate's, because
/// `0x7662c0` delivers a mouse-down to exactly one frame and stops the bus walk, so the
/// binding that starts mouselook is never reached. The wheel reads the chrome flag: it is the
/// one genuine fall-through in the frame system and walks **past** a frame that merely takes
/// the mouse, so scroll-zoom still works with the cursor on a plate. Two different reference
/// laws, which is why there are two bits and not one — and 2159 had the camera on the wrong
/// one for two days.
#[test]
fn the_camera_yields_to_a_plate_and_the_wheel_does_not() {
let mut app = app();
let plate = app.world_mut().spawn_empty().id();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.world_mut()
.resource_mut::<crate::vplates::PlateHover>()
.0 = Some(plate);
app.update();
// `latch_world_mouse` reads this one; `world_press` is `!over_ui`, so the press never
// becomes the world's and no look session starts.
assert!(
app.world().resource::<PointerOverUi>().0,
"the camera must yield the press to the plate"
);
// …and `bindings`' wheel branch reads this one.
assert!(
!app.world().resource::<PointerOverUiPanel>().0,
"the wheel must still reach the world over a plate"
);
}
/// An ordinary frame is both, which is the whole point of keeping two bits rather than one.
#[test]
fn a_hovered_panel_is_both() {
let mut app = app();
app.world_mut().resource_mut::<PlayerUiHover>().0 = Some(7);
app.update();
assert!(app.world().resource::<PointerOverUi>().0);
assert!(app.world().resource::<PointerOverUiPanel>().0);
}
/// And empty world under the pointer is neither.
#[test]
fn no_hover_is_neither() {
let mut app = app();
app.update();
assert!(!app.world().resource::<PointerOverUi>().0);
assert!(!app.world().resource::<PointerOverUiPanel>().0);
}
}
@@ -1,12 +1,11 @@
//! The shipped `assets/ui/MoneyFrame.xml` — the reference's `MoneyFrameTemplate` /
//! The stock `Interface\FrameXML\MoneyFrame.xml` — the reference's `MoneyFrameTemplate` /
//! `SmallMoneyFrameTemplate` kit and the `MoneyTypeInfo` table behind it (decision 1190: a name the
//! shipped 1.12 UI defines is a name we publish too — under our own implementation of it, 1260).
//!
//! Nothing benilla ships consumes this file yet — our own eight coin displays run on
//! MerchantFrame.xml's `BenillaMoney_*` slot kit, and MoneyFrame.xml's header is where the two are
//! reconciled — so these tests ARE its only driver. That makes them the contract: they exercise it
//! the way an addon does (declare a frame on the template, point it at a type, change the type,
//! read the coins back), because that is who calls it.
//! Eight shipped windows consume this file (1937 converged the coin cluster onto it; 1962 put the
//! file itself on the chain). These tests exercise it the way an addon does — declare a frame on
//! the template, point it at a type, change the type, read the coins back — because that is who
//! calls it.
//!
//! What they guard: the type switch actually rewiring where the number comes from and whether the
//! coins take the mouse; the collapse rule per type (`collapse`, `showSmallerCoins`, `fixedWidth`);
@@ -26,8 +25,10 @@ fn harness(money: u64) -> UiScript {
// below is deliberate rather than an artefact of an unfed VM — a flat 8px per digit makes
// every expected number readable as `digits x 8 + icon`.
s.set_text_measurer(Box::new(super::FixedWidthFont(8.0)));
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); // `message`, the kit's own error path
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
let doc = benilla_ui::framexml::parse(
r#"<Ui>
@@ -201,8 +202,9 @@ fn update_paints_a_static_frame_by_name_and_resizes_it() {
//
// **The digits used to contribute 0 here and this test asserted the bare 13s that came of it.**
// That was the director's cramped gold on a first open: `ShowCoin` sized each coin from a text
// measure that lands a frame later, so it read 0 every first time. It sums the engine's
// `BenillaNumberWidth` feed now, which answers in the same tick.
// measure that lands a frame later, so it read 0 every first time. The engine's font
// measurer answers inside the Lua call that asked now (`script/measure.rs`), so the width
// is real on the first pass.
assert_eq!(
s.eval::<f64>("return TestPurseGoldButton:GetWidth()")
.unwrap(),
@@ -321,8 +323,8 @@ fn set_money_frame_color_recolours_the_digits_and_not_the_icons() {
}
/// `MoneyTypeInfo` is read directly by addons, so its shape is part of the contract — all seven
/// reference types, each with an `UpdateFunc`, and the two we cannot source yet answering 0 rather
/// than erroring (the header's `GetSendMailMoney`/`GetSendMailCOD` gap).
/// reference types, each with an `UpdateFunc`, and the two send-mail rows reading the amounts
/// their engine getters hold (`GetSendMailMoney`/`GetSendMailCOD`, built by 1962).
#[test]
fn the_money_type_table_carries_all_seven_reference_types() {
let s = harness(12_345);
@@ -343,7 +345,7 @@ fn the_money_type_table_carries_all_seven_reference_types() {
"MoneyTypeInfo[\"{t}\"] with an UpdateFunc"
);
}
// The two whose engine getters we have not built answer 0 instead of nil-calling.
// The two send-mail rows: with no send-mail amount set, their getters answer 0.
for t in ["SEND_MAIL", "SEND_MAIL_COD"] {
s.run(&format!(
"this = TestPurse MoneyFrame_SetType(\"{t}\") this = nil"
@@ -357,7 +359,7 @@ fn the_money_type_table_carries_all_seven_reference_types() {
assert_eq!(
s.eval::<i64>("return TestPurse.staticMoney").unwrap(),
0,
"{t} reads 0 while its engine getter is missing"
"{t} reads its engine getter, which is 0 with no send-mail amount set"
);
}
}
@@ -374,9 +376,10 @@ fn the_money_type_table_carries_all_seven_reference_types() {
fn the_chains_money_input_frame_splits_an_amount_across_its_three_boxes() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml"); // COPPER_PER_GOLD / COPPER_PER_SILVER live here
// The manifest's own order: the `.lua` brings the ten verbs, the `.xml` the template.
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua"); // COPPER_PER_GOLD / COPPER_PER_SILVER live here
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
// The manifest's own order: the `.lua` brings the ten verbs, the `.xml` the template.
load_xml(&s, r"Interface\FrameXML\MoneyInputFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyInputFrame.xml");
@@ -18,11 +18,41 @@ use super::test_ui::load_ui as load_xml;
/// `ActionBar.xml` (the anchor target + shared globals both new bars need).
fn load_action_bar(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ActionBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
// The stock multibar file's OnLoad writes `UIOptionsFrameCheckButtons`, whose home is the
// reference's own hidden Interface Options window (2115) — the reference's own load order
// (UIOptionsFrame.xml l.21 before MultiActionBars.xml l.39), and the manifest's since 1938.
// That window is also where `ALWAYS_SHOW_MULTIBARS` is declared and where the load arm this
// file exercises lives.
"Interface\\FrameXML\\UIDropDownMenu.xml",
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
] {
load_xml(s, file);
}
@@ -37,7 +67,9 @@ fn show_bars(s: &UiScript, bars: &[u32]) {
if bars.contains(&bar) { "1" } else { "nil" }
));
}
lua.push_str("MultiActionBar_Update()");
// …then the manage pass, which the options window runs after a toggle (stock
// UIOptionsFrame.xml:1192, and our rows the same); stock MultiActionBar_Update does not.
lua.push_str("MultiActionBar_Update() UIParent_ManageFramePositions()");
s.run(&lua).unwrap();
}
@@ -55,21 +87,11 @@ fn shipped_multibars_drive_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/MultiBars.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
let frames = super::test_ui::load_ui(&s, "Interface\\FrameXML\\MultiActionBars.xml");
assert_eq!(
report.frames, 100,
"4 bar frames + 48 buttons, each with a Cooldown child — the two VERTICAL bars joined \
(hidden, as the reference's VerticalMultiBar3/4 are), which doubled this count"
frames, 100,
"what stock MultiActionBars.xml declares (1938): the four bar frames and their 48 buttons, \
each with a $parentCooldown — the same 100 ours built for the same seats"
);
// Occupy main slot 1, BottomLeft slot 1 (action 61), BottomRight slot 1 (action 49).
@@ -142,8 +164,9 @@ fn shipped_multibars_drive_end_to_end() {
};
assert_eq!(
rings(&quads, "Interface\\Buttons\\UI-Quickslot2"),
14,
"12 main wells + 2 occupied multibar buttons; 22 empty multibar wells hidden"
3,
"the occupied main slot + the 2 occupied multibar buttons; every empty well is hidden, \
the main bar's included (ActionButton.lua:69-70)"
);
// A click on BottomLeft button 1 (center (26,75)) queues the FIXED id 61 — and stays 61 when
@@ -173,8 +196,10 @@ fn shipped_multibars_drive_end_to_end() {
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
// The offset edges above showed the bonus overlay (1524) and its descent takes 0.15s of
// frame time this event-only test never used to pass; without it the 12 overlay wells are
// still on screen and correctly answer the SHOWGRID below as 12 extra drop targets.
// still on screen and correctly answer the SHOWGRID below as 12 extra drop targets. The
// stock slide paints, THEN advances, so the frame that hides it is one OnUpdate past the time.
s.tick(0.2);
s.tick(0.01);
// While a payload is held (SHOWGRID), the hidden empty wells appear as drop-target rings
// (UI-Quickslot, the "no action" ring): 11 empty main wells swap texture + 22 multibar wells
@@ -189,10 +214,19 @@ fn shipped_multibars_drive_end_to_end() {
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
s.resolve();
assert_eq!(rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot"), 0);
assert_eq!(rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot2"), 14);
assert_eq!(
rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot2"),
3,
"letting go hides every empty well again, the main bar's included; the three occupied \
buttons keep their rings"
);
// The unbound multibar hotkey corner carries the ref's RANGE_INDICATOR dot: out of range paints
// the red dot, back in range clears it (the main bar's labels tint instead).
// the red dot, back in range clears it (the main bar's labels tint instead). Two stock
// mechanisms, in order: `ActionButton_UpdateHotkeys` decides whether the corner's TEXT is the
// dot at all — only while `IsActionInRange` answers non-nil, i.e. with a target — and it runs
// on PLAYER_TARGET_CHANGED (ActionButton.lua:121-145, 333-334); every `ActionButton_OnUpdate`
// then shows or hides that text by range (l.389-395) and tints it red past the 0.2 s recheck.
use benilla_ui::script::ActionState;
s.set_action_state(
61,
@@ -203,6 +237,7 @@ fn shipped_multibars_drive_end_to_end() {
..Default::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.tick(0.5); // past the 0.2 s range recheck
s.resolve();
let dot_shown = |quads: &[benilla_ui::script::ExtractedQuad]| {
@@ -245,22 +280,27 @@ fn shipped_stance_bar_drives_end_to_end() {
// bottom multibar is UP to raise the flag. Since 1500 that is a player option rather than a
// given, so this test raises it; `the_stance_bar_sits_where_the_pass_puts_it` below owns the
// other state.
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[1]);
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/StanceBar.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
// The shapeshift bar is declared by stock BonusActionBarFrame.xml (ShapeshiftBarFrame and
// ShapeshiftButton1..10 beside the bonus bar), which load_action_bar loaded above (1938).
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
assert_eq!(
report.frames, 21,
"the bar frame + 10 buttons, each with a Cooldown child"
s.eval::<bool>("return ShapeshiftBarFrame ~= nil and ShapeshiftButton10 ~= nil")
.unwrap(),
"the stance bar's frames come with the stock bonus-bar file"
);
// No forms pushed (a mage): the frame is hidden.
@@ -424,12 +464,38 @@ fn multibar_hover_renders_the_buttons_own_action() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ActionBar.xml",
"MultiBars.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
] {
load_xml(&s, file);
}
@@ -463,7 +529,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
let hover_name = |s: &UiScript, button: &str| -> Option<String> {
s.run(&format!(
"GameTooltip:Hide() BenillaActionButton_OnEnter({button})"
"GameTooltip:Hide() this = {button} ActionButton_SetTooltip()"
))
.unwrap();
s.eval::<Option<String>>(
@@ -517,7 +583,20 @@ fn multibar_hover_renders_the_buttons_own_action() {
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
for bar in ["MultiBarRight", "MultiBarLeft"] {
assert!(
@@ -536,13 +615,13 @@ fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
// ref ActionButton.lua:8-9 — RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36), LEFT = 4 (37..48).
for (bar, first, last) in [("MultiBarRight", 25, 36), ("MultiBarLeft", 37, 48)] {
assert_eq!(
s.eval::<i64>(&format!("return {bar}Button1.base + {bar}Button1.index"))
s.eval::<i64>(&format!("return ActionButton_GetPagedID({bar}Button1)"))
.unwrap(),
first,
"{bar}'s first slot"
);
assert_eq!(
s.eval::<i64>(&format!("return {bar}Button12.base + {bar}Button12.index"))
s.eval::<i64>(&format!("return ActionButton_GetPagedID({bar}Button12)"))
.unwrap(),
last,
"{bar}'s last slot"
@@ -566,7 +645,20 @@ fn every_extra_bar_stays_down_until_its_own_toggle_is_set() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
const BARS: [&str; 4] = [
"MultiBarBottomLeft",
@@ -640,8 +732,21 @@ fn raising_a_bottom_bar_moves_the_managed_bottom_stack() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "CastingBar.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Interface\\FrameXML\\CastingBarFrame.xml");
// The pass writes the y of a frame row into its anchor; read it back off the anchor rather
// than off a resolved rect, so a hidden cast bar answers the same as a visible one.
@@ -691,7 +796,20 @@ fn viewable_action_bar_pages_follow_the_bar_toggles() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
let viewable = |s: &UiScript| {
s.eval::<String>(
@@ -744,7 +862,20 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[1]);
let well = |s: &UiScript| {
@@ -778,12 +909,38 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
// Re-applying an "off" that is already off must not owe anything: with a payload in hand the
// wells stay open through it (the reference's own idempotence bug, closed by the latch).
// The reference's hide is a COUNTED decrement on every well (`ActionButton_HideGrid`), never
// a no-op: with the option off, one more apply closes a payload-held well. That is why our
// row runs `MultiActionBar_UpdateGridVisibility` only on a CLICK (stock UIOptionsFrame.xml:683)
// and its load arm only ever SHOWS (UIOptionsFrame.lua:218-220) — a load-time hide would push
// counts nobody raised below zero. (Ours used to guard the no-op; the guard went with the
// file, 1938.)
// The load arm, both ways: off touches no count, on opens the wells.
// The LOAD arm is the reference's own VARIABLES_LOADED arm now (`UIOptionsFrame.lua`
// l.218-220), off the chain since 2115 — not our retired `OptionsFrame_ApplySavedSettings`.
s.run("ALWAYS_SHOW_MULTIBARS = \"0\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
s.run("MultiActionBar_UpdateGridVisibility()").unwrap();
s.run("MultiActionBar_UpdateGridVisibility()").unwrap();
assert!(well(&s), "a no-op apply may not decrement");
assert!(well(&s));
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s), "a load with the option off left the count alone");
s.run("ALWAYS_SHOW_MULTIBARS = \"1\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
assert!(well(&s), "a load with the option on opens the wells");
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
assert!(!well(&s));
// …and the click arm against a held payload: one apply closes the well the payload opened.
// (The payload's own HIDEGRID that follows takes the count to −1 in the reference too — its
// quirk, not modelled here.)
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.run("MultiActionBar_UpdateGridVisibility()").unwrap();
assert!(
!well(&s),
"the option's hide counts against the payload's open"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -810,7 +967,20 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
s.set_action(
1,
Some(ActionSlot {
@@ -849,8 +1019,9 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let resting = ring_alphas(&s, quickslot2);
assert_eq!(
resting.len(),
12,
"the 12 main wells; the multibar's are hidden"
1,
"only the occupied main slot draws a ring: every empty well, main bar included, is \
hidden while nothing is held (ActionButton.lua:69-70)"
);
assert!(
resting.iter().all(|a| *a == 1.0),
@@ -871,9 +1042,14 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
"every grid ring is the ref's half-alpha ghost, not an opaque plate: {ghosts:?}"
);
// …and the one OCCUPIED button is untouched by it.
// …and the one OCCUPIED button ghosts with it: `ActionButton_ShowGrid` sets the half alpha
// (ActionButton.lua:246) before anything asks whether the slot holds an action, and
// `HideGrid` touches no colour at all. What restores an occupied ring is its next
// `ActionButton_UpdateUsable` (l.271-283), whose `SetVertexColor(1, 1, 1)` carries the default
// alpha — from Update, and on SPELL_UPDATE_USABLE. Ours skipped occupied rings; the stock
// file does not (1938).
let occupied = ring_alphas(&s, quickslot2);
assert_eq!(occupied, vec![1.0], "the occupied ring does not ghost");
assert_eq!(occupied, vec![0.5], "the occupied ring ghosts too");
// A well that was ghosted and is then FILLED comes back opaque. This is the assertion that
// outlives the engine's own `SetVertexColor` divergence (1782): the day an absent alpha stops
@@ -894,21 +1070,28 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
s.resolve();
assert_eq!(
ring_alphas(&s, quickslot2),
vec![1.0, 1.0],
"a ghosted well that gets an action is opaque again, payload still held"
vec![0.5, 1.0],
"the well that got an action is opaque again (its Update ran UpdateUsable); the other \
occupied ring keeps its ghost, payload still held"
);
// Letting go restores both halves — art and tint, which is why they share one setter.
// Letting go takes the empty wells away; the colour it leaves alone.
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
s.resolve();
assert_eq!(ring_alphas(&s, quickslot), Vec::<f32>::new());
let after = ring_alphas(&s, quickslot2);
assert_eq!(after.len(), 12);
// (10 empty main wells back to the hollow ring + the 2 occupied ones.)
assert!(
after.iter().all(|a| *a == 1.0),
"the dim does not outlive the payload (the ref's own stuck-dim bug): {after:?}"
assert_eq!(
after,
vec![0.5, 1.0],
"HideGrid touches no colour: a ring the grid ghosted stays dim past the payload — the \
reference's own rule (ours used to clear it here)"
);
// What happens to that ring NEXT is decision 1782's open question, deliberately not asserted:
// the reference's `SetVertexColor(1, 1, 1)` in `ActionButton_UpdateUsable` keeps the alpha
// already on the region (byte-pinned, wow-re button-state-texture-path-setter.md §7 — the
// "stuck dim" a 1.12 player sees after a drag), while this engine's three-argument call still
// resets it to 1.0 until the director calls 1782. A test that pinned either outcome would be
// enshrining a divergence as fidelity.
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -927,25 +1110,42 @@ fn a_bar_toggle_sends_the_byte_its_globals_pack_to() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
let _ = s.take_action_bar_toggle_sends();
// The row's own setter, with the row's own "1"/"0" strings.
s.run("BenillaMultiBar_SetShown(1, \"1\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01]);
s.run("BenillaMultiBar_SetShown(3, \"1\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_3 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x05],
"the WHOLE byte re-sent, not a delta"
);
s.run("BenillaMultiBar_SetShown(4, \"1\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_4 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x0d]);
s.run("BenillaMultiBar_SetShown(1, \"0\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_1 = nil MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x0c],
"the row hands the setter a STRING, and \"0\" is off"
"the row turns its \"0\" into nil before the send, and nil is off"
);
// What is stored is 1 or nil and nothing else — GetActionBarToggles' own shape, and what the
@@ -997,19 +1197,37 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
// `r##`: the Lua contains `select("#", ...)`, and `"#` would close a single-hash raw string.
// 5.0's `arg.n`, not `select("#", ...)`: `...` as a value is not in this VM's grammar, because
// it is not in the 1.12 client's (decision 2101).
s.run(
r##"
r#"
BENILLA_TEST_TOGGLE_ARGC = nil
function SetActionBarToggles(...)
BENILLA_TEST_TOGGLE_ARGC = select("#", ...)
BENILLA_TEST_TOGGLE_ARGC = arg.n
end
"##,
"#,
)
.unwrap();
s.run("BenillaMultiBar_SetShown(2, \"1\")").unwrap();
// The shipped setter is the Action Bars row's own closure (OptionsFrame.xml), which is
// stock's three lines — assign the global, MultiActionBar_Update(), SetActionBarToggles(…) —
// plus the manage pass.
s.run("BenillaOptionsFrameContainerBodyActionBarsRowMultiBar2Check:Click()")
.unwrap();
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_TOGGLE_ARGC").unwrap(),
4,
@@ -1032,8 +1250,22 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "StanceBar.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
s.set_shapeshift_forms(vec![ShapeshiftFormView {
spell_id: 2457,
texture: Some("Interface\\Icons\\Stance_A".into()),
@@ -1081,8 +1313,8 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
/// the missing border the DEFAULT look rather than an edge case.
///
/// The last block is the point of the whole test: one `MultiActionBar_Update()` flips the seat AND
/// the art, which is what proves the art rides `UIParent_RegisterManagedPositionListener` rather
/// than having been set once at load.
/// the art, which is what proves the art rides the managed-position pass rather than having been
/// set once at load.
#[test]
fn the_stance_shelf_follows_the_bottom_left_bar() {
use benilla_ui::script::ShapeshiftFormView;
@@ -1090,8 +1322,22 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "StanceBar.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
@@ -1155,8 +1401,17 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
// Even at three forms, raised keeps the middle strip down — the fork is on the BAR, not the
// form count; the count only decides the middle strip within the unraised branch.
// A third form learned while the bar is up: the reference's `ShapeshiftBar_Update` shows the
// middle strip for three forms regardless of the bar (BonusActionBarFrame.lua:198-206), and
// nothing runs the pass on that event — the strip draws across the row until something
// moves. The pass, not Update, owns the raised look (UIParent.lua:1705-1720).
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"Update shows the strip; only the pass takes it down"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert!(!shown(&s, "ShapeshiftBarMiddle"));
assert_eq!(ring(&s), 50.0);
@@ -1188,8 +1443,22 @@ fn the_stance_shelf_is_as_long_as_the_form_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "StanceBar.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
show_bars(&s, &[]); // unraised: the shelf art is the state under test
let form = |id: u32| ShapeshiftFormView {
@@ -1291,7 +1560,20 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
// the player binds it, as the reporter had).
s.register_bindings(&crate::bindings::registry_commands());
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[1]);
s.set_action(
61,
@@ -1363,3 +1645,289 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
assert_eq!(label(&s, "MultiBarBottomLeftButton2"), "");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Decision 2000 — the shelf's middle strip tiles along its LENGTH only.**
///
/// Past two forms `ShapeshiftBar_Update` maps the middle strip `SetTexCoord(0, n-2, 0, 1)`
/// (BonusActionBarFrame.lua l.198-206): one slot of art per extra form, repeated along u — the
/// reference's tiling idiom — while v spans exactly the texture. The renderer serves such a strip
/// from a repeat-sampled image, and until 2000 that image wrapped BOTH axes: bilinear filtering
/// at the strip's top edge weighed in the texture's LAST row (`ShapeshiftBarMiddle.blp` row 31 —
/// opaque grey; rows 0-7 transparent), so every four-form bar wore a one-device-px grey hairline
/// along the top of its middle piece, over the world: 0.81-0.84× the world's luma over the strip
/// against 1.00 over the clamp-sampled end caps, in a live four-form shot.
///
/// The seam this pins is the ask itself: the wrap the shelf's three pieces request of the
/// renderer, derived from the UV mapping the VM extracts — u alone for the middle strip past two
/// forms, nothing at three forms (`SetTexCoord(0, 1, 0, 1)` is the whole texture), and never for
/// the end caps (atlas crops of `ShapeshiftBarEnds`).
#[test]
fn the_middle_strip_tiles_along_its_length_only() {
use super::extract::tiling_axes;
use crate::ui_pass::UvRect;
use benilla_ui::script::{ShapeshiftFormView, TexCoords};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[]); // bars down: the shelf is the state under test
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Form {id}"),
active: id == 2457,
castable: true,
cooldown: None,
};
// Every drawn piece whose texture path ends in `piece`, as the wrap it asks the renderer for.
let wraps_of = |s: &UiScript, piece: &str| -> Vec<(bool, bool)> {
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture {
path: Some(p),
tex_coords,
..
} if p.ends_with(piece) => Some(tiling_axes(&match tex_coords {
Some(TexCoords::Rect(e)) => UvRect::from_tex_coords(e),
Some(TexCoords::Corners(c)) => UvRect::from_corners(c),
None => UvRect::FULL,
})),
_ => None,
})
.collect()
};
// Four forms (a druid; a GM-learned warrior): the middle strip carries two slots and wraps
// along u ONLY. Both end caps stay clamped on both axes.
s.set_shapeshift_forms(vec![form(2457), form(71), form(768), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
assert_eq!(
wraps_of(&s, "ShapeshiftBarMiddle"),
vec![(true, false)],
"four forms: the strip tiles along its length and clamps across it"
);
assert_eq!(
wraps_of(&s, "ShapeshiftBarEnds"),
vec![(false, false), (false, false)],
"the end caps are atlas crops and never tile"
);
// Three forms (the plain warrior): one slot, the whole texture — nothing tiles.
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
assert_eq!(wraps_of(&s, "ShapeshiftBarMiddle"), vec![(false, false)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The director's picture (2026-09-05, decision 2009)**: a three-stance warrior with the
/// bottom-left bar up, and a silver plate around Defensive Stance — `ShapeshiftBarMiddle` drawn
/// alone, both end caps down.
///
/// The reference fires `UPDATE_SHAPESHIFT_FORMS` for exactly one thing: the form LIST changed
/// (learn / unlearn / rank — wow-re `shapeshift-bar-api.md`, the fires at `0x4b28ff`/`0x4b2e43`).
/// Our feed fired it for any change in the pushed view — a stance switch, a castable flip, the
/// shared 1 s category cooldown arming and then EXPIRING. Every fire runs the stock
/// `ShapeshiftBar_Update` (BonusActionBarFrame.lua l.170): `ShapeshiftBarMiddle:Show()`
/// unconditionally past two forms, then `ShapeshiftBarFrame:Show()` on a frame already shown —
/// no OnShow, so no manage pass, and the strip stays up over the raised bar (UIParent.lua
/// l.1706-1712 is the only thing that takes it down). 2000 and 2001 each fixed a real thing in
/// this band and neither touched this, which is the director's "no change from before".
///
/// Driven through the feed's own diff ([`crate::ui_shapeshift::push_forms`]), so what fails here
/// is the feed's rule, not a hand-fired event. A form's STATE rides the reference's state events,
/// which the feeds owning those transitions fire (`PLAYER_AURAS_CHANGED` for the form aura's
/// slot, `SPELL_UPDATE_COOLDOWN` for the store's generation edge) — checked below by firing the
/// one the switch really carries and reading the checked ring.
#[test]
fn a_forms_state_change_leaves_the_shelf_down_over_the_raised_bar() {
use crate::ui_shapeshift::{push_forms, FormsEdge, StanceMemory};
use benilla_ui::script::ShapeshiftFormView;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
// A recorder for the two events a push may announce.
s.run(
"STANCE_LOG = {} local f = CreateFrame('Frame') \
f:RegisterEvent('UPDATE_SHAPESHIFT_FORMS') f:RegisterEvent('SPELL_UPDATE_USABLE') \
f:SetScript('OnEvent', function() table.insert(STANCE_LOG, event) end)",
)
.unwrap();
let log = |s: &UiScript| {
s.eval::<String>("local l = table.concat(STANCE_LOG, ',') STANCE_LOG = {} return l")
.unwrap()
};
let shown = |s: &UiScript, region: &str| {
s.eval::<bool>(&format!("return {region}:IsShown()"))
.unwrap()
};
let shelf = [
"ShapeshiftBarLeft",
"ShapeshiftBarMiddle",
"ShapeshiftBarRight",
];
let ring = |s: &UiScript| {
s.eval::<f64>("return ShapeshiftButton1NormalTexture:GetWidth()")
.unwrap()
};
let checked = |s: &UiScript, i: u32| {
s.eval::<bool>(&format!("return ShapeshiftButton{i}:GetChecked() == 1"))
.unwrap()
};
let stance = |id: u32, active: bool, castable: bool, cooldown| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Stance {id}"),
active,
castable,
cooldown,
};
let mut memory = StanceMemory::default();
// Login: the list arrives — the reference's learn edge — with Battle Stance active.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, true, true, None),
stance(71, false, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::List
);
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
assert!(checked(&s, 1) && !checked(&s, 2));
// The bottom-left bar up, the way the Options row raises it: the pass seats the stance bar a
// row higher and takes the whole shelf down (UIParent.lua l.1706-1716).
show_bars(&s, &[1]);
for region in shelf {
assert!(!shown(&s, region), "{region} is down under a raised bar");
}
assert_eq!(ring(&s), 50.0);
// A stance switch: the form byte flips to Defensive and category 47 arms for a second on all
// three. State, not list — the reference announces it through the aura and cooldown events.
let cd = Some((0, 1000, true));
let edge = push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, true, cd),
stance(71, true, true, cd),
stance(2458, false, true, cd),
],
);
for region in shelf {
assert!(
!shown(&s, region),
"{region}: the director's plate — the shelf re-shown over the raised bar"
);
}
assert_eq!(ring(&s), 50.0);
assert_eq!(edge, FormsEdge::Silent);
assert_eq!(log(&s), "", "a state move fires no list edge");
// …and the checked ring follows on the event the switch really carries.
assert!(
checked(&s, 1) && !checked(&s, 2),
"no repaint before the state event"
);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
!checked(&s, 1) && checked(&s, 2),
"the aura event repaints the ring"
);
for region in shelf {
assert!(
!shown(&s, region),
"{region}: a state repaint never touches the shelf"
);
}
// The cooldown running out is the same silent edge — the widget hides itself, the reference's
// store fires nothing at expiry, and neither do we.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, true, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Silent
);
assert_eq!(log(&s), "");
assert!(!shown(&s, "ShapeshiftBarMiddle"));
// A castable flip is a usability move — the reference's own SPELL_UPDATE_USABLE — and the
// stock bar greys the icon on it without touching the shelf.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Usable
);
assert_eq!(log(&s), "SPELL_UPDATE_USABLE");
let grey = s
.eval::<f64>("local r = ShapeshiftButton1Icon:GetVertexColor() return r")
.unwrap();
assert!(
(grey - 0.4).abs() < 1e-6,
"not castable: the 0.4 grey, got {grey}"
);
assert!(!shown(&s, "ShapeshiftBarMiddle"));
// Nothing moved: nothing pushed, nothing fired.
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
],
),
FormsEdge::Unchanged
);
assert_eq!(log(&s), "");
// The control — a fourth stance LEARNED is the list edge, and the reference's own Update
// shows the strip across the raised bar until the next pass (the sibling test pins that).
assert_eq!(
push_forms(
&mut s,
&mut memory,
vec![
stance(2457, false, false, None),
stance(71, true, true, None),
stance(2458, false, true, None),
stance(768, false, true, None),
],
),
FormsEdge::List
);
assert_eq!(log(&s), "UPDATE_SHAPESHIFT_FORMS");
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"the learn edge: Update shows the strip"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert!(!shown(&s, "ShapeshiftBarMiddle"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
//! The shipped `assets/ui/UIPanelTemplates.xml` + `assets/ui/OptionsFrameTemplates.xml` — the
//! reference's SHARED widget kit, driven the way an addon drives it.
//! The stock `Interface\FrameXML\UIPanelTemplates.xml` + our `assets/ui/OptionsFrameTemplates.xml`
//! — the reference's SHARED widget kit, driven the way an addon drives it.
//!
//! **These are not tests of a window.** Nothing benilla ships instantiates a single template in
//! either file: their only consumer is a third-party addon writing
@@ -32,10 +32,6 @@ use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// The manifest prefix these two files sit on: fonts, `UIParent` (the parent every addon passes),
/// `HideUIPanel` (the close button's OnClick), the tooltip (the options widgets' hover) and the
/// scroll kit (`ScrollFrame_OnLoad`), then the two files under test in manifest order.
@@ -43,16 +39,27 @@ fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
"OptionsFrameTemplates.xml",
// `UIOptionsCheckButtonTemplate`'s home. It was ours, in a one-template
// `OptionsFrameTemplates.xml`, until 2115 put the reference's own hidden Interface
// Options window on the manifest — the template's actual home, and the whole point of
// that record: an addon that names it gets the reference's own declaration. The dropdown
// kit comes with it because the window has four.
r"Interface\FrameXML\UIDropDownMenu.xml",
r"Interface\FrameXML\UIOptionsFrame.xml",
] {
load_xml(&s, file);
}
@@ -233,7 +240,7 @@ fn a_panel_button_from_the_template_labels_and_paints() {
/// `UIPanelCloseButton` — 8 call sites across 4 corpus addons, and the one template here whose
/// whole point is its script: `HideUIPanel(this:GetParent())`, resolved at click time against
/// `UiPanels.xml`.
/// the chain's `UIParent.xml`.
#[test]
fn the_templated_close_button_hides_the_frame_it_sits_on() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -241,10 +248,10 @@ fn the_templated_close_button_hides_the_frame_it_sits_on() {
s.run(
r#"MyPanel = CreateFrame("Frame", "MyPanel", UIParent)
MyPanel:SetWidth(200) MyPanel:SetHeight(100)
MyPanel:SetPoint("CENTER")
MyPanel:SetPoint("CENTER", 0, 0)
MyPanel:Show()
MyClose = CreateFrame("Button", "MyClose", MyPanel, "UIPanelCloseButton")
MyClose:SetPoint("TOPRIGHT")"#,
MyClose:SetPoint("TOPRIGHT", 0, 0)"#,
)
.unwrap();
assert_eq!(
@@ -429,59 +436,18 @@ fn the_options_check_button_resolves_its_whole_inheritance_chain() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **No invented names.** Every `virtual="true"` template these two files declare is a name the
/// real 1.12.1 client has, read from `reference/1.12-globals.tsv`.
///
/// Scoped to these two files on purpose: the rest of `assets/ui` is full of deliberately
/// benilla-shaped template names (`BenillaScrollBarTemplate`, `BenillaMacroPanelButtonTemplate`,
/// `OptionsRedButtonTemplate`), and a whole-tree sweep would be asserting something else. These
/// two files make the opposite claim — *these are the reference's own names, which is why an addon
/// can find them* — so that claim is the one worth gating. A template renamed to something
/// plausible-but-absent here is a template no addon will ever name.
#[test]
fn every_template_these_files_declare_is_a_real_1_12_name() {
let _data = benilla_formats::wow_data_or_skip!();
let tsv =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../reference/1.12-globals.tsv");
let text = std::fs::read_to_string(&tsv).unwrap_or_else(|e| {
panic!(
"reading {}: {e} — regenerate with scripts/gen-reference-globals.py",
tsv.display()
)
});
let known: std::collections::HashSet<&str> = text
.lines()
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').next())
.collect();
let mut checked = 0;
// OUR files only. This reads the source tree directly, so it cannot name a chain entry — and
// it has no reason to: asserting that the reference's own file declares 1.12 names is
// tautological. What it guards is us inventing one (decision 1841).
// Our remaining file only. `UIPanelTemplates.xml` was ours until 1846 put the whole kit on the
// chain; what is left to guard is the one template we still declare.
for file in ["OptionsFrameTemplates.xml"] {
let src = std::fs::read_to_string(ui_dir().join(file)).unwrap();
let doc = benilla_ui::framexml::parse(&src).unwrap();
for item in &doc.items {
let benilla_ui::framexml::TopLevel::Template(el) = item else {
continue;
};
let name = el.name().expect("every template here is named");
assert!(
known.contains(name),
"{file} declares '{name}', which is not a 1.12 global — \
a template the reference does not have is one no addon can name"
);
checked += 1;
}
}
assert!(
checked >= 1,
"only {checked} templates swept — the sweep, not the files, is what broke"
);
}
// **RETIRED with its subject (decision 2115).** `every_template_these_files_declare_is_a_real_1_12_name`
// swept the `virtual="true"` templates of the files we shipped under a reference name and required
// each to be a real 1.12 global (`reference/1.12-globals.tsv`) — the guard against inventing a
// plausible-but-absent template name that no addon could ever reach for (decision 1841). 1846 took
// `UIPanelTemplates.xml` to the chain and left it one subject, `OptionsFrameTemplates.xml`'s single
// `UIOptionsCheckButtonTemplate`; 2115 took that one too, by loading the reference's own
// `UIOptionsFrame.xml`. Nothing under `assets/ui` now declares a template under a reference name —
// what is left there is deliberately benilla-shaped (`BenillaScrollBarTemplate`,
// `OptionsCheckboxRowTemplate`, `BenillaScriptLogRowTemplate`), which is a different claim and the
// sweep's own doc said so. This is 1751 §5 working as written: a drift instrument loses its subject
// as the copies retire. `the_options_check_button_resolves_its_whole_inheritance_chain` above still
// proves the template resolves — off the chain now.
/// **`PanelTemplates_TabResize`'s `tab` argument is OPTIONAL, and omitting it means `this`.**
///
@@ -521,28 +487,42 @@ fn tab_resize_falls_back_to_this_when_no_tab_is_passed() {
assert!(width > 0.0, "the tab got a width, got {width}");
}
/// **The window tab's structural clamp, driven the way the client drives it — from `OnUpdate`.**
/// **The reference's own window tab fits its text in `<OnShow>`, on the first show, with no
/// settle** — the property that let benilla's own copy of this template retire (decision 1993).
///
/// 1002's law is that a tab may never grow past its window's drawn right edge, whatever fit the
/// instance asked for. `BenillaTabButton_OnUpdate` enforces it by comparing the fitted width
/// against the room, and it used to read that width from `PanelTemplates_TabResize`'s return —
/// a benilla-only line on OUR copy of that verb.
/// The reference fits a tab exactly once, from the template's `<OnShow>`
/// (`CharacterFrameTemplates.xml:77-80`): `PanelTemplates_TabResize(0)`, whose `width` is
/// `tabText:GetWidth() + padding`. That needs a `GetStringWidth` that answers during the Lua call
/// that asked. Ours landed a frame late when our template was written, so our copy re-fit from
/// `OnUpdate` until the measure settled; the engine has a synchronous measurer now
/// (`script::measure`), so the stock handler is the whole fit. The falsifier is a tab still wearing
/// the template's authored 115 after one show — which is exactly what an OnShow-only fit on the old
/// engine produced.
///
/// 1837 put `Interface\FrameXML\UIPanelTemplates.lua` on the chain BELOW `UiPanels.xml`, so the
/// reference's `PanelTemplates_TabResize` is the one that runs, and it returns nothing. The
/// comparison became `nil > number`: a raise, once per tab per width change, with the clamp
/// silently off the air and the tab already sized by the unclamped first call.
/// It also pins **1004's structural half, now on the reference's own file**: the highlight anchors
/// `LEFT +10` / `RIGHT −10`, so it spans the tab at every width and `TabResize`'s own
/// `highlightTexture:SetWidth(tabWidth)` — and the `<OnShow>`'s second line, `SetWidth(
/// GetTextWidth() + 30)` — are dead against two opposing anchors, here as in the reference.
///
/// The existing tab test drives `OnLoad` and never ticks, which is why nothing caught it.
/// **What went with our template is 1002's clamp** (superseded by 1993): a tab may now grow past
/// its window's drawn edge exactly as it does in the real client, whose only caps are the numbers
/// each window passes `TabResize` itself. That is asserted here by its absence — the long label
/// below overflows the 160-wide probe window and nothing stops it.
#[test]
fn a_tab_never_grows_past_its_windows_right_edge() {
fn the_stock_tab_fits_its_text_on_the_first_show() {
let _data = benilla_formats::wow_data_or_skip!();
/// `2 * $parentLeft:GetWidth()` — the template's two 20-unit end slices.
const SIDES: f64 = 40.0;
let mut s = harness();
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
// A deliberately narrow window and a long label: the natural fit does not fit.
// The highlight authors NO `<Size>`, so its height is content-derived off the art's texel
// extent (decisions 1349/1664) — `UI-Character-Tab-Highlight.blp` is 128x32. Without a probe
// the Y axis never resolves and the region has no rect at all, which is a property of an
// engine-less VM, not of the template.
s.set_texture_size_probe(Box::new(|_| Some((128, 32))));
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<Frame name="ProbeWindow">
<Frame name="ProbeWindow" parent="UIParent" hidden="true">
<Size><AbsDimension x="160" y="200"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Frames>
@@ -560,21 +540,58 @@ fn a_tab_never_grows_past_its_windows_right_edge() {
let report = benilla_ui::loader::load_in(&s, &doc, "test", &|_: &str| None);
assert!(report.errors.is_empty(), "load: {:?}", report.errors);
// Two ticks: the first lands the measure, the second settles against it.
s.tick(0.1);
s.tick(0.1);
// The first show, and nothing else: no tick, no measure round trip to pump.
s.run("ProbeWindow:Show()").unwrap();
assert!(
s.errors().is_empty(),
"the settle must not raise — the clamp reads the fit back, not a return value: {:?}",
"the stock OnShow must not raise: {:?}",
s.errors()
);
s.resolve();
let (left, width) = s
.eval::<(f64, f64)>("return ProbeTab:GetLeft(), ProbeTab:GetWidth()")
let (label, width) = s
.eval::<(f64, f64)>("return ProbeTabText:GetStringWidth(), ProbeTab:GetWidth()")
.unwrap();
assert!(label > 0.0, "the label measured synchronously, got {label}");
assert_eq!(
width,
label + SIDES,
"the tab is its text plus the two end slices, from OnShow alone"
);
assert_ne!(width, 115.0, "…and not the template's authored pre-fit");
// No settle: a frame later it is the same number, because nothing re-fits it.
for _ in 0..3 {
s.tick(0.016);
}
s.resolve();
assert_eq!(
s.eval::<f64>("return ProbeTab:GetWidth()").unwrap(),
width,
"the fit is once, in OnShow — nothing may move it per frame"
);
// 1004, structurally: the highlight is the tab inset 10 on each side, at this width.
// Lit, so it joins the resolved tree — the way the dropdown kit's checked rows are lit.
s.run("ProbeTab:LockHighlight()").unwrap();
s.resolve();
let (tl, tr, hl, hr) = s
.eval::<(f64, f64, f64, f64)>(
"return ProbeTab:GetLeft(), ProbeTab:GetRight(), \
ProbeTabHighlightTexture:GetLeft(), ProbeTabHighlightTexture:GetRight()",
)
.unwrap();
assert_eq!(
(hl - tl, tr - hr),
(10.0, 10.0),
"the highlight spans the tab's own edges — the OnShow SetWidth is inert against them"
);
// 1002's clamp is gone with our template: the reference has no such guarantee, and the long
// label really does run past the window's right edge now.
let right = s.eval::<f64>("return ProbeWindow:GetRight()").unwrap();
assert!(
left + width <= right + 0.5,
"the tab ran past its window: left {left} + width {width} > right {right}"
tl + width > right,
"the probe label was meant to overflow: left {tl} + width {width} <= right {right}"
);
}
+152 -81
View File
@@ -4,9 +4,9 @@ use benilla_ui::script::{
};
/// Load one manifest entry into `s`, panicking on any loader error and returning the frame count
/// it materialized — the panel tests all load `UiPanels.xml` (decision 0084 §2's slot manager)
/// before the panel frame(s) under test, exactly as `benilla.toc`'s own order does, so
/// `ShowUIPanel`/`HideUIPanel` already exist when a frame's OnLoad/OnEvent references them.
/// it materialized — the panel tests all load `Interface\FrameXML\UIParent.xml` (decision 0084 §2's
/// slot manager) before the panel frame(s) under test, exactly as `benilla.toc`'s own order does,
/// so `ShowUIPanel`/`HideUIPanel` already exist when a frame's OnLoad/OnEvent references them.
///
/// This was a private disk-only reader until 1751's second window: `BankFrame.xml` is the
/// reference's own file off the player's chain now, and a reader that only knows `assets/ui`
@@ -30,7 +30,7 @@ fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Re
.unwrap_or_else(|| panic!("no bare-frame quad sized {w}x{h}"))
}
/// Load the real `assets/ui/GossipFrame.xml` (the shipped gossip window) behind `UiPanels.xml`
/// Load the stock `Interface\FrameXML\GossipFrame.xml` behind `UIParent.xml`
/// into a bare engine and drive it with a synthetic gossip menu — the whole phase-3 chain minus
/// Bevy (decision 0081), now over the UIPanel slot manager (decision 0084): the hidden→shown
/// lifecycle on GOSSIP_SHOW goes through ShowUIPanel (landing the window at the left slot,
@@ -48,14 +48,21 @@ fn shipped_gossip_frame_drives_end_to_end() {
// `shipped_gossip_rows_grow_to_their_wrapped_labels` for why this is a harness gap and not an
// engine one.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// The window + its scroll frame (bar + child) + the 32-row shared pool (quest rows and option
// rows both draw from it, decision 0088 §3 — the reference's own NUMGOSSIPBUTTONS) + the close
// button + the GOODBYE button. The greeting and the NPC-name banner are FontString layers (the
@@ -248,14 +255,21 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
s.set_gossip(Some(GossipMenu {
@@ -406,14 +420,21 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
// (`ui_script::extract`'s `AtlasMeasurer`); only a bare test VM does not. The gap was in the
// harness, and it hid behind a window we had written to measure a frame later.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
// Three long options — the shape of a real judgement/roleplay menu, every one of them wrapping
@@ -463,8 +484,9 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
.collect();
s.set_measured_text_unwrapped(&answers);
};
// Frame 1: the labels are measured (their heights land for the NEXT tick — the round-trip is a
// frame late, exactly as the tab-fit and quest-panel resizes already live with).
// Frame 1: the labels are measured (their heights land for the NEXT tick — this harness drives
// the host's BATCH round-trip by hand, which is a frame late by construction; the app's own
// synchronous measurer closes the same loop inside `resolve`).
answer_measures(&mut s);
s.resolve();
// Frame 2: the frame's own settle pass reads those measures and sizes each row.
@@ -519,14 +541,21 @@ fn gossip_show_hide_plays_open_and_close_kits() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
@@ -562,18 +591,21 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
// The vendor window is the reference's own since 1751, and its `MerchantFrame_UpdateMerchantInfo`
// calls `TEXT()` while building every row — see `test_ui::MERCHANT_UI` for the rest.
// The vendor window is the reference's own since 1751, and its `MerchantFrame_UpdateMerchantInfo`
// calls `TEXT()` while building every row — see `test_ui::MERCHANT_UI` for the rest.
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
@@ -656,18 +688,21 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
// The vendor window is the reference's own since 1751, and its `MerchantFrame_UpdateMerchantInfo`
// calls `TEXT()` while building every row — see `test_ui::MERCHANT_UI` for the rest.
// The vendor window is the reference's own since 1751, and its `MerchantFrame_UpdateMerchantInfo`
// calls `TEXT()` while building every row — see `test_ui::MERCHANT_UI` for the rest.
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
@@ -703,7 +738,7 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
}
/// Pin §4's pushable path: a higher-`pushable` occupant (loot's future pushable=7 row, already
/// registered in `UiPanels.xml`) gets pushed to the center slot rather than replaced when a
/// registered in `UIParent.xml`) gets pushed to the center slot rather than replaced when a
/// pushable=0 frame (merchant) wants the left spot (UIParent.lua l.734-741) — the synthetic
/// registrant the pin calls for, since no loot window ships yet. A bare `CreateFrame` with a
/// distinctive 50×50 size stands in for it: it has no visual layers, but `extract` still emits
@@ -714,13 +749,15 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
// The vendor window is the reference's own since 1751, and its `MerchantFrame_UpdateMerchantInfo`
// calls `TEXT()` while building every row — see `test_ui::MERCHANT_UI` for the rest.
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
// The vendor window is the reference's own since 1751, and its `MerchantFrame_UpdateMerchantInfo`
// calls `TEXT()` while building every row — see `test_ui::MERCHANT_UI` for the rest.
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
@@ -734,7 +771,7 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
r#"
local loot = CreateFrame("Frame", "LootFrame")
loot:Hide()
loot:SetSize(50, 50)
loot:SetWidth(50); loot:SetHeight(50)
ShowUIPanel(loot)
"#,
)
@@ -817,16 +854,23 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
let order_first = |bank_first: bool| {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); // the reference bank's slot buttons inherit it
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml"); // our file: the two scroll kits + the placeholder icon
// (BenillaScroll_Step is only this suite's pan driver). The window's own
// scroll frame inherits the reference's UIPanelScrollFrameTemplate, which
// lives in UIPanelTemplates.xml (next) — NOT optional: a missing template is
// a loader *warning*, not an error, so an under-loaded list passes load_xml
// and then loses the scrollbar silently.
// Before BankFrame, not after: its close and purchase buttons inherit UIPanelCloseButton
// and UIPanelButtonTemplate, and an `inherits=` is resolved at LOAD.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
@@ -902,11 +946,16 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// `shipped_gossip_rows_grow_to_their_wrapped_labels` for why this is a harness gap and not an
// engine one.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); // UIPanelScrollFrameTemplate — see the note above
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
// Eight wrapping options: ~4 lines each, far past the 334 px scroll frame.
@@ -1058,11 +1107,16 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml"); // UIPanelScrollFrameTemplate — see the note above
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
// The addon's three lines, in the order an addon writes them.
@@ -1134,10 +1188,16 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
fn the_1507_registry_rows_match_the_reference_bytes() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
for probe in [
// ItemTextFrame — UIParent.lua l.20 (the B288 row).
"UIPanelWindows['ItemTextFrame'].area == 'left'",
@@ -1149,8 +1209,9 @@ fn the_1507_registry_rows_match_the_reference_bytes() {
// The whileDead flags the ref authors and 1507 carried (l.21, l.25, Blizzard_TalentUI:71).
"UIPanelWindows['SpellBookFrame'].whileDead == 1",
"UIPanelWindows['QuestLogFrame'].whileDead == 1",
"UIPanelWindows['TalentFrame'].whileDead == 1",
// UIChildWindows — UIParent.lua l.44-50 verbatim, all four shipped.
// `TalentFrame`'s row is `Blizzard_TalentUI.lua:71`'s, registered when that addon loads
// (1988 retired our copy of it, which the reference does not keep in UIParent.lua).
"table.getn(UIChildWindows) == 4",
"UIChildWindows[1] == 'OpenMailFrame'",
"UIChildWindows[2] == 'GuildControlPopupFrame'",
@@ -1173,10 +1234,15 @@ fn a_dead_player_opens_only_whiledead_windows() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -1191,8 +1257,8 @@ fn a_dead_player_opens_only_whiledead_windows() {
// Stand-ins for two shipped rows: GossipFrame (no whileDead) and QuestLogFrame (whileDead=1).
// Bare frames are enough — the guard runs before any seat is chosen.
s.run(
r#"local g = CreateFrame("Frame", "GossipFrame") g:SetSize(50, 50) g:Hide()
local q = CreateFrame("Frame", "QuestLogFrame") q:SetSize(50, 50) q:Hide()
r#"local g = CreateFrame("Frame", "GossipFrame") g:SetWidth(50); g:SetHeight(50) g:Hide()
local q = CreateFrame("Frame", "QuestLogFrame") q:SetWidth(50); q:SetHeight(50) q:Hide()
ShowUIPanel(GossipFrame)"#,
)
.unwrap();
@@ -1241,18 +1307,23 @@ fn a_frame_arriving_at_center_puts_the_child_windows_away() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// The letter is open; two shipped-row stand-ins take the seats: MerchantFrame (pushable 0)
// holds left, TradeFrame (pushable 1) then ARRIVES at center (UIParent.lua l.734-741's
// else-arm: the incumbent outranks nobody, the newcomer settles at center).
s.run(
r#"local m = CreateFrame("Frame", "OpenMailFrame") m:SetSize(50, 50)
local a = CreateFrame("Frame", "MerchantFrame") a:SetSize(50, 50) a:Hide()
local b = CreateFrame("Frame", "TradeFrame") b:SetSize(50, 50) b:Hide()
r#"local m = CreateFrame("Frame", "OpenMailFrame") m:SetWidth(50); m:SetHeight(50)
local a = CreateFrame("Frame", "MerchantFrame") a:SetWidth(50); a:SetHeight(50) a:Hide()
local b = CreateFrame("Frame", "TradeFrame") b:SetWidth(50); b:SetHeight(50) b:Hide()
ShowUIPanel(MerchantFrame)
ShowUIPanel(TradeFrame)"#,
)
@@ -1273,7 +1344,7 @@ fn a_frame_arriving_at_center_puts_the_child_windows_away() {
s.run(
r#"HideUIPanel(TradeFrame) HideUIPanel(MerchantFrame)
OpenMailFrame:Show()
local l = CreateFrame("Frame", "LootFrame") l:SetSize(50, 50) l:Hide()
local l = CreateFrame("Frame", "LootFrame") l:SetWidth(50); l:SetHeight(50) l:Hide()
ShowUIPanel(LootFrame)
ShowUIPanel(MerchantFrame)"#,
)
@@ -1,5 +1,5 @@
//! The shipped `assets/ui/ActionBar.xml`'s performance ("ping") meter — the tinted bar in the
//! main bar's last empty recess — over the real files, never a stub.
//! The stock `Interface\FrameXML\ActionBarFrame.xml`'s performance ("ping") meter — the tinted bar
//! in the main bar's last empty recess — over the real files, never a stub.
//!
//! What these guard, in order: the ref geometry that puts the bar IN the recess (the whole point of
//! the slice); the LOW-strata draw order that makes it show *through* the bar art instead of over
@@ -14,9 +14,19 @@ use super::test_ui::load_ui as load_xml;
fn harness(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
for f in extra {
load_xml(&s, f);
}
@@ -219,8 +229,12 @@ fn get_net_stats_reports_the_pushed_latency() {
/// tooltip that is quietly one line short.
#[test]
fn hovering_the_meter_shows_the_live_latency() {
let mut s = harness(&["UIParent.xml", "GameTooltip.xml"]);
let mut s = harness(&[r"Interface\FrameXML\UIParent.xml"]);
s.set_latency_ms(Some(42));
// 1.12 ships detailed tips ON — `SHOW_NEWBIE_TIPS = "1"` is UIOptionsFrame_Init's (ref
// UIOptionsFrame.lua l.100; ours sits in OptionsFrame.xml's uvar block, 1968), and a harness
// without the options file says so itself, the way the reference's tooltip would read it.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.resolve();
assert_eq!(
@@ -229,7 +243,7 @@ fn hovering_the_meter_shows_the_live_latency() {
"the button over the meter takes the mouse"
);
s.run("BenillaPerformanceBar_OnEnter(MainMenuBarPerformanceBarFrameButton)")
s.run("this = MainMenuBarPerformanceBarFrameButton MainMenuBarPerformanceBarFrameButton:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
@@ -276,7 +290,7 @@ fn hovering_the_meter_shows_the_live_latency() {
"the refresh rebuilds BOTH lines — a held-open plate never decays to one"
);
s.run("BenillaPerformanceBar_OnLeave()").unwrap();
s.run("this = MainMenuBarPerformanceBarFrameButton MainMenuBarPerformanceBarFrameButton:GetScript(\"OnLeave\")()").unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
@@ -1,4 +1,4 @@
//! The pet action bar (`PetActionBar.xml`) driven end to end through the REAL shipped XML
//! The pet action bar (the reference's `PetActionBarFrame.xml`, 1953) driven end to end through the REAL shipped XML
//! (decision 0982) — the `multibar_stance_tests` pattern: a self-contained loader, then the
//! whole chain from a pushed slot list to the quads it actually paints.
@@ -9,16 +9,43 @@ use super::test_ui::load_ui as load_xml;
/// The pet bar's own load prerequisites, in manifest order: UiPanels (`SetDesaturation`, the
/// disabled-bar grey), UIParent (the managed bottom stack its OnShow/OnHide re-fires), Cooldown
/// (`CooldownFrame_SetTimer`), ActionBar (the `MainMenuBar` anchor target) — then the bar.
fn load_pet_bar(s: &UiScript) {
/// The reference's pet bar needs what the manifest loads before it: the action-bar chain
/// (`ActionButton_UpdateState`, `MainMenuBar` as its parent, `CooldownFrame_SetTimer`), the
/// options window's uvars for `MultiActionBars.lua`, `SetDesaturation` and `GetBindingText` from
/// our UIParent.lua slices, `TEXT` — and the chat window, whose edit box `ShowPetActionBar`
/// raises over the sliding bar (PetActionBarFrame.lua l.175). One chain, in the manifest's order,
/// for every test here (1953).
pub(super) fn load_pet_bar(s: &UiScript) {
for file in [
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"Interface\\FrameXML\\PetActionBarFrame.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(s, file);
}
@@ -27,7 +54,7 @@ fn load_pet_bar(s: &UiScript) {
/// GlobalStrings is loaded from the MPQ at runtime, not by the loader — a token slot's `name` is
/// a KEY into it, so the tests declare the two keys they read. (That the real keys exist in the
/// shipped file is a separate fact, asserted in `ui_pet`'s own tests by name.)
fn declare_token_strings(s: &UiScript) {
pub(super) fn declare_token_strings(s: &UiScript) {
s.run(
"PET_ACTION_ATTACK = 'Attack' \
PET_MODE_DEFENSIVE = 'Defensive'",
@@ -42,7 +69,7 @@ const CLAW_WORD: u32 = 0xC100_0BC2;
/// A hunter's bar as the server actually sends it: Attack (a lit command token), an empty spell
/// slot, Claw (a spell with autocast running), and Defensive (a lit reaction token).
fn hunter_slots() -> Vec<PetActionView> {
pub(super) fn hunter_slots() -> Vec<PetActionView> {
let mut slots = vec![PetActionView::default(); 10];
slots[0] = PetActionView {
name: Some("PET_ACTION_ATTACK".into()),
@@ -165,19 +192,25 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"Attack + Defensive are lit; Claw is not"
);
// Autocast: the static ring on the one slot that allows it, and the shine MARKER on the one
// slot where it is running — the native lane's registration (decision 1383): the extract
// carries the token, never sparks, and `autocast_shine::emit_shine` draws the trails at it
// on the append lane while the script layer stays settled.
// Autocast: the static ring on the one slot that allows it, and the stock shine MODEL on the
// one slot where it is running — `$parentAutoCast`, `UI-AutoCastButton.mdx` over the whole
// button at `scale="1.2"`, rendered as a tile (decisions 2013/2014). A hidden pane is not
// extracted, so one pane is exactly one running shine.
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
1,
"only Claw can autocast"
);
let shines: Vec<_> = quads
.iter()
.filter(|q| q.rect.is_none_or(|r| (r.top + r.bottom) / 2.0 >= 50.0))
.filter(|q| matches!(&q.content, QuadContent::ModelPane { model: Some(p), model_scale, .. }
if p == "Interface\\Buttons\\UI-AutoCastButton.mdx" && (*model_scale - 1.2).abs() < 1e-6))
.collect();
assert_eq!(
textures(&quads, "benilla:autocast-shine:1.2"),
shines.len(),
1,
"the shine marker on Claw alone — enabled, not merely allowed"
"the shine pane on Claw alone — enabled, not merely allowed"
);
// Geometry, quoted from the ref: the bar's TOPLEFT is MainMenuBar's BOTTOMLEFT +(36,97),
@@ -191,14 +224,23 @@ fn the_shipped_pet_bar_drives_end_to_end() {
(72.0, 56.0, 102.0, 86.0)
);
// The pet goes: the bar hides again, shine marker and all.
// The pet goes: the bar slides out over the reference's PETACTIONBAR_SLIDETIME (0.09 s,
// `PetActionBarFrame_OnUpdate`) and hides at the end of the slide, shine marker and all.
s.set_pet_actions(false, true, true, Vec::new());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.tick(0.05);
for _ in 0..3 {
s.tick(0.05);
}
s.resolve();
let gone = s.extract();
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
assert_eq!(textures(&gone, "benilla:autocast-shine:1.2"), 0);
assert!(
!gone.iter().any(
|q| matches!(&q.content, QuadContent::ModelPane { model: Some(p), .. }
if p == "Interface\\Buttons\\UI-AutoCastButton.mdx")
),
"the shine pane hides with its bar"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -350,23 +392,11 @@ fn a_disabled_bar_greys_rather_than_hides() {
fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
] {
load_xml(&s, file);
}
load_xml(&s, "MultiBars.xml");
load_pet_bar(&s);
if with_multibar {
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update()")
.unwrap();
}
load_xml(&s, "PetActionBar.xml");
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
@@ -625,29 +655,9 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// The autocast trail's MOTION tests (the B228 corner-walk goldens, the 1321 truncating-clock
// law, the no-seam continuity pin) live with the drawing now: `crate::autocast_shine`'s own
// test module — the script layer no longer moves a single spark (decision 1383).
/// The pet bar plus the three files its TOOLTIP needs, in the TOC's own order: Fonts.xml (the
/// colour codes that wrap the binding), GameTooltip.xml (the plate), and UIParent.xml — already a
/// prerequisite for the managed stack — for `GetBindingText`, which renders the key.
fn load_pet_bar_with_tooltip(s: &UiScript) {
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
] {
load_xml(s, file);
}
}
// The autocast trail's MOTION is the model's own since decisions 2013/2014: the stock
// `$parentAutoCast` Model's four bones and four emitters, rendered as a tile on the pane's clock
// — the script layer never moves a spark, and no test here needs to.
/// A hunter bar hovered through the REAL gesture, with the real binding registry and the real CVar
/// table behind it — `mouse_move` runs the shipped `<OnEnter>` with `this` bound, which is the only
@@ -667,7 +677,7 @@ fn hovered_pet_bar() -> UiScript {
// `GetCVar("UberTooltips")` answers "1" (its byte-read registrar value) rather than nil.
s.register_bindings(&crate::bindings::registry_commands());
s.register_cvars(crate::cvars::registered_pairs());
load_pet_bar_with_tooltip(&s);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
@@ -692,7 +702,7 @@ fn tooltip_line1(s: &UiScript) -> String {
///
/// The exact string is pinned, colour codes and all, because the ref's concatenation puts the
/// colour BEFORE the space (`Attack|cffffd200 (CTRL-1)|r`, not `Attack |cffffd200(CTRL-1)|r` —
/// which is what MicroMenu.xml's different-shaped version of the same idea produces).
/// which is what the stock `MicroButtonTooltipText` — the same idea, the other shape — produces).
#[test]
fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
let mut s = hovered_pet_bar();
@@ -26,11 +26,14 @@ use super::test_ui::load_ui as load_xml;
fn load_pet_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
@@ -398,8 +398,10 @@ fn the_level_line_names_the_family_and_is_untouched_without_one() {
// level line at all", because our deleted `assets/ui/PetPaperDollFrame.xml` declared
// `PetLevelText` with no `text=` and it started empty. The reference declares it
// `text="Level level race class"` (stock `PetPaperDollFrame.xml:70`) — a design-time
// placeholder, and not a GlobalStrings key, so the loader's `text=` lookup falls through to the
// literal and that literal is what a player sees in this state, un-replaced. Both spellings say
// placeholder, and not a GlobalStrings key. **A real 1.12 client draws that literal too**:
// `CSimpleFontString::LoadXML` substitutes the raw attribute when `FrameScript_GetText` comes
// back empty (`771029`-`771032 mov eax,esi`), exactly as `Button::LoadXML 0x778c31` does — so
// this is fidelity, not the loader divergence this comment used to call it. Both spellings say
// the same thing about the guard; this one says it about the file that ships.
let drawn = with_family(None);
assert!(
@@ -468,7 +470,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let pane = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::ModelPane { name: Some(n) } if n == "PetModelFrame")
matches!(&q.content, QuadContent::ModelPane { name: Some(n), .. } if n == "PetModelFrame")
})
.expect("the pet model pane is in the render list");
let pane_rect = pane.rect.expect("…with a resolved rect");
@@ -479,9 +481,18 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
&& pane_rect.top >= rect.top,
"the icon sits wholly inside the pane, which is what makes the z-order matter"
);
// **The pane's scene draws AFTER the icon, and that is the reference's own order.**
// `PetPaperDollPetInfo` is a sibling frame of `PetModelFrame` (same parent, so the same
// `(strata, level)` bucket) and its happiness art is a BACKGROUND texture on it; a model's
// scene drains out of that bucket's ARTWORK batch, last (wow-re
// `ui/scratch/model-frame-draw-order.md`: `0x76d160` registers the render callback only for
// layer 2, and `0x76fb00` drains quads, then text, then callbacks). The icon stays visible
// because a model pane is not an opaque quad — the bake is transparent everywhere the pet
// is not, here as in the client. This assertion is the ordering, not the pixels.
assert!(
icon.z > pane.z,
"the diet icon must paint over the pane it sits in (icon z={:#x}, pane z={:#x})",
pane.z > icon.z,
"the pane's scene draws out of the ARTWORK batch, after a sibling's BACKGROUND art \
(icon z={:#x}, pane z={:#x})",
icon.z,
pane.z
);
@@ -0,0 +1,175 @@
//! The reference's own stable window (`Interface\FrameXML\PetStable.xml`, off the player's chain
//! since 1751) driven through the one event path that reaches it **while it is closed**:
//! `PetStable_OnLoad` registers `UNIT_PET`, and `PetStable_OnEvent` repaints on it
//! unconditionally (stock `PetStable.lua:20-21`) — so every Call Pet a hunter casts runs the whole
//! of `PetStable_Update` against a pet the client has only just learned about.
//!
//! That paint concatenates `UnitName("pet")` (l.129, and again at l.163-164) the moment
//! `UnitExists("pet")` is true, and the pet's name is the one name the client never has at that
//! moment: it does not ride the descriptor, it is answered by `CMSG_PET_NAME_QUERY` a round-trip
//! later. The reference reads `UNKNOWNOBJECT` there; a verb answering nil raises
//! `attempt to concatenate a nil value` in a window nobody opened (decision 2002).
use benilla_ui::script::{PetStats, ScriptValue, UiScript, UnitState};
use super::test_ui::load_ui_strict;
/// The window's production load prefix, in `benilla.toc` order: `GlobalStrings.lua` for
/// `UNIT_LEVEL_TEMPLATE`/`EMPTY_STABLE_SLOT` (and `UNKNOWNOBJECT`, the string under test),
/// `BasicControls.xml` for `TEXT()`, `ItemButtonTemplate.xml` for `SetItemButtonTexture`,
/// `MoneyFrame` for the cost row, `UIParent.xml` for the slot manager, `BuildListString` and
/// `Model_OnLoad` (the pane is a `<PlayerModel>`), `UIPanelTemplates` for the purchase and close
/// buttons, `GameTooltip.xml` for the slot hovers.
///
/// **Callers must open with `benilla_formats::wow_data_or_skip!()` themselves** — every entry
/// here is a chain entry, and the macro `return`s from the function it is written in.
const STABLE_UI: &[&str] = &[
r"Interface\FrameXML\GlobalStrings.lua",
r"Interface\FrameXML\Fonts.xml",
r"Interface\FrameXML\BasicControls.xml",
r"Interface\FrameXML\ItemButtonTemplate.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\GameTooltip.xml",
r"Interface\FrameXML\PetStable.xml",
];
fn load_stable() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in STABLE_UI {
load_ui_strict(&s, f);
}
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Benilla".into()),
health: 100,
max_health: 100,
level: 60,
class: Some("Hunter".into()),
class_file: Some("HUNTER".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
s
}
/// The pet's snapshot exactly as `ui_pet::unit` pushes it on the tick the summon streams: the
/// descriptor's fields are in, the guid is in, and the name is whatever the name cache has —
/// `None` until `SMSG_PET_NAME_QUERY_RESPONSE` lands.
fn pet(name: Option<&str>) -> UnitState {
UnitState {
exists: true,
has_object: true,
name: name.map(String::from),
health: 900,
max_health: 1000,
level: 58,
guid: 0xF140_0000_0000_0001,
..UnitState::default()
}
}
/// The stat block the pet feed resolves off the creature template the same tick — the icon and
/// the family word come off one `CreatureFamily.dbc` row, which is why `GetPetIcon()` answers
/// (the paint's "is there a current pet?" test, l.51/161) while the NAME is still in flight.
fn hunter_pet_stats() -> PetStats {
PetStats {
icon: Some(r"Interface\Icons\Ability_Hunter_Pet_Boar".into()),
hunter_pet: true,
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
family: Some("Boar".into()),
..PetStats::default()
}
}
/// `Option`, not `String`: an emptied line reads back **nil**, because `FontString:GetText
/// 0x79d690` substitutes nil for an empty string (decision 2110).
fn level_text(s: &UiScript) -> Option<String> {
s.eval::<Option<String>>("return PetStableLevelText:GetText()")
.unwrap()
}
/// The director's report: a hunter with a pet out casts Call Pet, and the red script-error dialog
/// names `PetStable.lua:129` — with the stable window closed. `UNIT_PET` fires on the summon,
/// the paint runs, `UnitExists("pet")` is true, and the name is not known yet.
///
/// Failure looks like: `s.errors()` holds `PetStable.lua:129: attempt to concatenate a nil
/// value`, and the level line is whatever the XML shipped as placeholder text. The pass is the
/// reference's own line for that instant — `UNKNOWNOBJECT`, the level template, the family —
/// and no error.
#[test]
fn call_pet_repaints_the_closed_stable_with_unknownobject_while_the_name_is_in_flight() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_stable();
// No stable master has been visited: nothing pushed through `set_stable`, so every
// `GetStablePetInfo` answers its miss shape and `GetSelectedStablePet()` is -1.
s.set_unit("pet", Some(pet(None)));
s.set_pet_stats(true, hunter_pet_stats());
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.errors().is_empty(),
"the closed stable window raised on Call Pet: {:?}",
s.errors()
);
assert!(
!s.eval::<bool>("return PetStableFrame:IsVisible()").unwrap(),
"UNIT_PET must repaint the window without opening it"
);
assert_eq!(level_text(&s).as_deref(), Some("Unknown Level 58 Boar"));
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltip")
.unwrap(),
"Unknown",
"the slot's hover title is the same read (l.163)"
);
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltipSubtext")
.unwrap(),
"Level 58 Boar"
);
// The name lands; the next repaint reads it. (`UNIT_NAME_UPDATE` is not one of the window's
// events — a stable-side repaint is what the reference re-reads the name on.)
s.set_unit("pet", Some(pet(Some("Snarl"))));
s.fire_event("PET_STABLE_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(level_text(&s).as_deref(), Some("Snarl Level 58 Boar"));
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltip")
.unwrap(),
"Snarl"
);
}
/// The control the fix must not move: the dismiss edge. `UNIT_PET` with no pet token seated and
/// no stable row for slot 0 takes the "nothing" arm (l.151-156) — empty level line, no error.
#[test]
fn dismissing_the_pet_empties_the_closed_stable_without_raising() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_stable();
s.set_unit("pet", Some(pet(None)));
s.set_pet_stats(true, hunter_pet_stats());
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.set_unit("pet", None);
s.set_pet_stats(false, PetStats::default());
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(level_text(&s), None, "an emptied line reads back nil");
assert_eq!(
s.eval::<String>("return PetStableCurrentPet.tooltip")
.unwrap(),
"Empty Stable Slot"
);
}
@@ -90,26 +90,34 @@ fn setup() -> UiScript {
// out of GlobalStrings, with no fallback of its own — `format(nil, …)` raises, and the window
// never paints.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `QuestTitleButtonTemplate`, which the reference's registrar inherits for its two service
// rows — 1.12 declares it in QuestFrameTemplates.xml, an `<Include>` of QuestFrame.xml, and
// ours declares it in QuestFrame.xml directly. An unknown template is a loader WARNING, so
// without this the rows build with no art at all and nothing goes red — which is exactly the
// failure `load_ui_strict` exists to turn into a red test.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// `ChatFrameEditBox`, which the reference's own purchase button indexes on every click to
// decide where focus goes after the name box closes — a nil there raises before the charter is
// bought. Ours guarded it; the reference does not.
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrops
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrops
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // ChatFrame's seven dropdowns inherit its template
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GuildRegistrarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetitionFrame.xml");
s
@@ -13,12 +13,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "QuestShareFrame.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s
}
+255 -102
View File
@@ -1,7 +1,7 @@
//! The shipped **questgiver window** open/close sound, driven engine-only (no Bevy): the real
//! `assets/ui/QuestFrame.xml` loaded behind `UiPanels.xml` and shown/hidden through the wire events.
//! The window-sound convention's machine check for the quest arc (decision 0090), the sibling of the
//! merchant/gossip/bag/loot sound tests.
//! `Interface\FrameXML\QuestFrame.xml` loaded behind `UIParent.xml` and shown/hidden through the
//! wire events. The window-sound convention's machine check for the quest arc (decision 0090), the
//! sibling of the merchant/gossip/bag/loot sound tests.
use benilla_ui::script::{QuestPanel, QuestState, ScriptValue, SoundRequest, UiScript};
@@ -17,17 +17,26 @@ use super::test_ui::load_ui as load_xml;
fn questgiver_show_hide_plays_open_and_close_kits() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// The BenillaMoney_* purse helpers the quest reward/progress panels repaint through live in
// MerchantFrame.xml (the same documented cross-window dep the bag tests load).
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
assert!(
@@ -79,15 +88,24 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let panels = [
"QuestFrameGreetingPanel",
@@ -104,10 +122,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
active_titles: vec!["Report to Goldshire".into()],
..QuestState::default()
}));
s.fire_event(
"QUEST_GREETING",
vec![ScriptValue::Str("Deputy Willem".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Deputy Willem".into()),
..Default::default()
}),
);
s.fire_event("QUEST_GREETING", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
for name in panels {
assert_eq!(
@@ -130,10 +153,15 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
title: "A Threat Within".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Deputy Willem".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Deputy Willem".into()),
..Default::default()
}),
);
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
for name in panels {
assert_eq!(
@@ -159,15 +187,24 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let choice = |name: &str, quality: u32| benilla_ui::script::QuestItemView {
item_id: 0,
@@ -188,10 +225,15 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
reward_money: 150,
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Marshal McBride".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Marshal McBride".into()),
..Default::default()
}),
);
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return QuestDetailItem1:IsShown()").unwrap());
@@ -205,7 +247,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
assert_eq!(
s.eval::<String>("return QuestDetailItemChooseText:GetText()")
.unwrap(),
"You may choose one of these rewards:"
"You will be able to choose one of these rewards:"
);
assert_eq!(
s.eval::<String>("return QuestDetailItemReceiveText:GetText()")
@@ -232,8 +274,11 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
// arm at all: an unmodified click must not raise and must not set an itemChoice.
s.run("QuestDetailItem1:Click()").ok();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The reward panel's `itemChoice` is set only by its own OnShow (stock QuestFrame.lua:104,
// `= 0`) and a reward-row click; on the detail panel it is simply unset.
assert_eq!(
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice or 0")
.unwrap(),
0,
"a detail-panel row never selects a reward"
);
@@ -245,15 +290,24 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
let choice = |name: &str| benilla_ui::script::QuestItemView {
item_id: 0,
@@ -271,10 +325,15 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
choices: vec![choice("Worn Sword"), choice("Worn Mace")],
..QuestState::default()
}));
s.fire_event(
"QUEST_COMPLETE",
vec![ScriptValue::Str("Marshal McBride".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Marshal McBride".into()),
..Default::default()
}),
);
s.fire_event("QUEST_COMPLETE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
@@ -311,25 +370,39 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
fn greeting_goodbye_button_closes_the_window() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Greeting,
greeting: "Hello".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_GREETING",
vec![ScriptValue::Str("Deputy Willem".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Deputy Willem".into()),
..Default::default()
}),
);
s.fire_event("QUEST_GREETING", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return QuestFrame:IsVisible()").unwrap());
@@ -355,15 +428,24 @@ fn greeting_goodbye_button_closes_the_window() {
fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// Planted, not shipped: instant text is OFF out of the box (see the doc comment).
s.eval::<()>(r#"QUEST_FADING_DISABLE = "1""#).unwrap();
s.set_quest(Some(QuestState {
@@ -372,10 +454,15 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
body: "Kill kobolds.".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Deputy Willem".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Deputy Willem".into()),
..Default::default()
}),
);
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return QuestFrameAcceptButton:IsShown()")
@@ -470,15 +557,24 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
fn write_on_still_fades_when_instant_text_is_off() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.eval::<()>(r#"QUEST_FADING_DISABLE = "0""#).unwrap();
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
@@ -486,10 +582,15 @@ fn write_on_still_fades_when_instant_text_is_off() {
body: "Kill kobolds.".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Deputy Willem".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Deputy Willem".into()),
..Default::default()
}),
);
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Mid-write (reveal edge at char 4 of 13): the quill scratches, Accept stays dead.
@@ -534,15 +635,24 @@ fn write_on_still_fades_when_instant_text_is_off() {
fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
@@ -550,10 +660,15 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
body: "Kill things.".into(),
..QuestState::default()
}));
s.fire_event(
"QUEST_DETAIL",
vec![ScriptValue::Str("Marshal McBride".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Marshal McBride".into()),
..Default::default()
}),
);
s.fire_event("QUEST_DETAIL", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return getglobal('QuestFrameNpcNameText'):GetText() or ''")
@@ -600,15 +715,28 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// Stock sizes each row at show time — `SetHeight(GetTextHeight() + 2)` right after SetText
// (QuestFrame.lua:245) — which the reference can do because its text measure is synchronous.
// So is the app's (measure.rs); the harness installs the same seam's stand-in.
s.set_text_measurer(Box::new(super::FixedWidthFont(7.0)));
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
s.set_quest(Some(QuestState {
panel: QuestPanel::Greeting,
@@ -620,10 +748,15 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
],
..QuestState::default()
}));
s.fire_event(
"QUEST_GREETING",
vec![ScriptValue::Str("Deputy Willem".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Deputy Willem".into()),
..Default::default()
}),
);
s.fire_event("QUEST_GREETING", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let answer_measures = |s: &mut UiScript| {
@@ -656,7 +789,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
};
let (t1, b1, h1) = row(1);
let (t2, b2, h2) = row(2);
assert!(h1 >= 28.0 && h2 >= 28.0, "both titles wrap: {h1}, {h2}");
assert!(h1 >= 24.0 && h2 >= 24.0, "both titles wrap: {h1}, {h2}");
assert!(
(t1 - b1 - (h1 + 2.0)).abs() < 0.5,
"row 1 is its wrapped title + 2: got {}, title {h1}",
@@ -687,19 +820,31 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
fn reward_rows_preview_and_post_without_selecting_the_choice() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit its menus build from
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml"); // ChatFrameEditBox lives here
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\FloatingChatFrame.xml");
const SWORD: &str = "|cffffffff|Hitem:2299:0:0:0|h[Worn Sword]|h|r";
const MACE: &str = "|cffffffff|Hitem:2300:0:0:0|h[Worn Mace]|h|r";
@@ -727,10 +872,15 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
)],
..QuestState::default()
}));
s.fire_event(
"QUEST_COMPLETE",
vec![ScriptValue::Str("Marshal McBride".into())],
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Marshal McBride".into()),
..Default::default()
}),
);
s.fire_event("QUEST_COMPLETE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
@@ -738,7 +888,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// move it if they leaked into the select arm.
s.run("QuestRewardItem2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice")
.unwrap(),
2,
"a plain click still selects the reward choice"
);
@@ -762,7 +913,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"shift-click posted choice 1's link"
);
assert_eq!(
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice")
.unwrap(),
2,
"the shift arm returns — it must NOT also select the clicked choice"
);
@@ -793,7 +945,8 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"ctrl-click opened the room wearing choice 1"
);
assert_eq!(
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice")
.unwrap(),
2,
"the ctrl arm returns — it must NOT also select the clicked choice"
);
@@ -25,19 +25,26 @@ fn harness() -> UiScript {
// the player's own strings it draws the KEY, which is exactly what a `text=` attribute does
// with an unknown name.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `SecondsToTime`, which the reference's `QuestTimerFrame_Update` formats every row with, and
// `UIParent_ManageFramePositions`, which its OnShow/OnHide call.
load_xml(&s, "UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
// `MAX_QUESTS`, the loop bound the reference's repaint hides its spare rows with. 1.12
// declares it on QuestLogFrame.lua:2 and so do we (1751 window 16) — a nil there is
// `'for' limit must be a number` on the first repaint, not a missing row.
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\QuestTimerFrame.xml");
s
}
@@ -64,7 +71,6 @@ fn log(entries: Vec<QuestLogEntryView>) -> QuestLogState {
QuestLogState {
num_quests: entries.len() as u32,
entries,
detail: None,
}
}
File diff suppressed because it is too large Load Diff
@@ -559,7 +559,13 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
)]))
};
let mut memory = FeedMemory::default();
apply_container_source(&mut s, &mut memory, quiver(200), [0; 10], Vec::new());
apply_container_source(
&mut s,
&mut memory,
quiver(200),
Default::default(),
Vec::new(),
);
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
s.tick(0.1);
@@ -568,7 +574,13 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
// The server tells us the stack is one lighter. That is a fired shot, and the only way the
// addon can ever know it.
apply_container_source(&mut s, &mut memory, quiver(199), [0; 10], Vec::new());
apply_container_source(
&mut s,
&mut memory,
quiver(199),
Default::default(),
Vec::new(),
);
s.tick(0.1);
let (_, reloading, reload_left, _) = shot_state(&mut s);
+32 -65
View File
@@ -16,26 +16,13 @@ use benilla_ui::script::{
UiScript,
};
use super::test_ui::load_ui_strict as load_xml;
/// The window's slice of the manifest, in `load_default_ui` order. `UIParent.xml` is in it for
/// two functions the pane really calls — `MouseIsOver` (the drag's hover sweep) and
/// `SecondsToTime` (the lockout rows) — and for the `READY_CHECK` arm that opens the popup.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
load_xml(&s, "RaidFrame.xml");
super::test_ui::load_social_ui(&mut s);
s
}
@@ -93,6 +80,7 @@ fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
members,
leader_index: 0,
leader_guid: 0, // the player leads; their guid is unset in this fixture
own_guid: 0,
raid,
loot_method: "group".into(),
master_looter: None,
@@ -738,7 +726,18 @@ fn the_raid_info_panel_lists_the_saved_lockouts() {
.unwrap(),
0
);
assert!(!visible(&s, "RaidInfoInstance1"));
// **The stale row STAYS, and that is the reference's own defect.** `RaidInfoFrame_Update`
// wraps its whole row loop in `if ( savedInstances > 0 )` (RaidFrame.lua:93-106), so the
// count dropping to zero never reaches the `Hide()` in its else-arm — the last lockout
// remains painted under an already-open panel. Our retired file hid them; the migration
// reverts that, and it is the director's call, not ours to re-author (1874).
//
// What DOES protect the player is the line above: the button is disabled, so the flyout
// cannot be reopened onto the stale row.
assert!(
visible(&s, "RaidInfoInstance1"),
"the reference leaves the last row standing when the count hits zero"
);
// The button toggles the flyout.
assert!(visible(&s, "RaidInfoFrame"));
@@ -779,12 +778,21 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
"an empty lockout list is a dead button"
);
// And the panel behind it is emptied, not left in the state the XML loaded: every row away,
// and no scroll bar standing over a list of nothing.
// **The panel IS left in the state the XML loaded, and that is the reference's own defect.**
// `RaidInfoFrame_Update` wraps its entire row loop in `if ( savedInstances > 0 )`
// (RaidFrame.lua:93-106), so with a lockout list that has never had anything in it the loop
// never runs at all and no row is ever hidden — they keep the visibility stock
// `Blizzard_RaidUI.xml` declared. Our retired file cleared them; the migration reverts that,
// and a look the migration reverts is the director's call, not ours to re-author (1874).
//
// What protects the player is the line above: the button is disabled, so this panel cannot be
// reached by a click at all. The rows below are only visible because the test showed the frame
// by hand.
s.run("RaidInfoFrame:Show()").unwrap();
assert!(!visible(&s, "RaidInfoInstance1"));
assert!(!visible(&s, "RaidInfoInstance10"));
assert!(!visible(&s, "RaidInfoScrollFrameScrollBar"));
assert!(
visible(&s, "RaidInfoInstance1") && visible(&s, "RaidInfoInstance10"),
"with no lockouts the reference's update never runs, so the XML's own rows stand"
);
s.run("RaidInfoFrame:Hide()").unwrap();
// The button is dead to a real click, not merely drawn grey — the half the director asked for
@@ -909,51 +917,10 @@ fn the_ready_check_popup_opens_and_answers() {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The reference-geometry diff (decision 0675's discipline)
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Every element this file shares with the reference carries the reference's numbers.
///
/// The pane's specification is TWO reference files — `RaidFrame.xml` from FrameXML and
/// `Blizzard_RaidUI.xml` from the load-on-demand addon — so this runs twice. The addon half is
/// read straight out of the patch chain rather than from an extraction on disk: `_extracted_framexml/`
/// holds FrameXML only, and requiring someone to have extracted the addon first would leave half
/// the window's geometry guarded by nothing.
#[test]
fn the_geometry_matches_both_reference_files() {
let _data = benilla_formats::wow_data_or_skip!();
// The FrameXML half: the pane, its two buttons, the info flyout and the lockout row template.
if let Some(reference) = super::framexml_diff::reference("RaidFrame.xml") {
super::framexml_diff::assert_geometry_matches(
"RaidFrame.xml",
&reference,
&[
// The reference's close button inherits `UIPanelCloseButton`, which carries the
// 32x32; no such template ships in this house (FriendsFrame's own close button
// says the same), so ours states the size the template would have given it. The
// ANCHOR offsets match; only the extra size pair differs.
"RaidInfoCloseButton",
],
10,
);
}
// The addon half: the row, the slot, the group frame and the ready-check popup.
let Some(data) = benilla_formats::wow_data() else {
return;
};
let chain = benilla_formats::open_chain(&data).expect("open chain");
let bytes = chain
.read("Interface\\AddOns\\Blizzard_RaidUI\\Blizzard_RaidUI.xml")
.expect("Blizzard_RaidUI.xml is in the patch chain");
super::framexml_diff::assert_geometry_matches_text(
"RaidFrame.xml",
&String::from_utf8_lossy(&bytes),
&[],
10,
);
}
// The reference-geometry diff that stood here is GONE with the transcription it policed (1874).
// It compared our `RaidFrame.xml`'s numbers against the reference's; the pane is now the
// reference's own file off the player's chain, so there is nothing left to diff — the geometry is
// the reference's by construction rather than by assertion.
/// **A drag the cursor carries off the window edge ENDS — it does not glue the row to the mouse
/// for the rest of the session** (B310).
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
//! The shipped **Reputation tab** (`assets/ui/ReputationFrame.xml`) and the **reputation watch
//! bar** (`assets/ui/ActionBar.xml`) driven end-to-end, engine-only (no Bevy) — the per-window test
//! module the skills/spellbook/bank files already establish.
//! The stock **Reputation tab** (`Interface\FrameXML\ReputationFrame.xml`) and the **reputation
//! watch bar** (`Interface\FrameXML\ActionBarFrame.xml`) driven end-to-end, engine-only (no Bevy) —
//! the per-window test module the skills/spellbook/bank files already establish.
//!
//! What it pins is the PAINT law, which is the half `benilla-ui`'s own
//! `script::reputation::tests` structurally cannot reach: that module drives the twelve globals and
@@ -13,12 +13,13 @@
//! Steamwheedle over Booty Bay, the parentless bucket last) so a failure here reads against a shape
//! that is already pinned one layer down, and the two can be compared line for line.
//!
//! **The window around the page is the reference's own since 1751** — `CharacterFrame.xml` and
//! `PaperDollFrame.xml` off the player's chain — so every test here opens with
//! `wow_data_or_skip!()` and loads [`super::test_ui::CHARACTER_UI`]. The page itself
//! (`ReputationFrame.xml`) and the watch bar (`ActionBar.xml`) are still ours.
//! **All of it is the reference's own now** — the window since 1751 (`CharacterFrame.xml`,
//! `PaperDollFrame.xml`), the page itself (`ReputationFrame.xml`) with it, and the watch bar with
//! `ActionBar.xml`'s retirement into `ActionBarFrame.xml`. So every test here opens with
//! `wow_data_or_skip!()` and loads [`super::test_ui::CHARACTER_UI`], and what it pins is our
//! ENGINE under stock XML rather than XML of ours.
use benilla_ui::script::{FactionEntry, ReputationState, UiScript, UnitState};
use benilla_ui::script::{FactionEntry, QuadContent, ReputationState, UiScript, UnitState};
/// An ordinary bar row: visible, not a header, `standing_id` 5 ("Friendly") sitting 1000 into a
/// 6000-wide rank window. The same numbers `benilla-ui`'s own fixture uses.
@@ -218,8 +219,9 @@ fn the_reputation_page_opens_on_the_windows_third_tab() {
"slot 9 has nothing to hold"
);
assert!(!shown(&mut s, "ReputationHeader9"), "neither twin shows");
// No trough line here: it was ours, and the stock pane declares none (1875). Asserting a frame
// that cannot exist would pass for the wrong reason.
assert!(!visible(&mut s, "ReputationListScrollFrameScrollBar"));
assert!(!visible(&mut s, "ReputationListScrollFrameScrollBarTrough"));
// Switching away hides it again through the same one-page-at-a-time switch.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
@@ -383,28 +385,11 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() {
visible(&mut s, "ReputationListScrollFrameScrollBar"),
"21 rows in 15 slots raises the scroll bar"
);
assert!(
visible(&mut s, "ReputationListScrollFrameScrollBarTrough"),
"and the trough it rides in comes up with it (the kit shows the pair together)"
);
// **The trough fits this window to the pixel, and that is not a coincidence.**
// `BenillaScrollTrough_Seat`'s 21-above / 20-below / 8-left hang was DERIVED from
// ReputationFrame's own reference anchors (ScrollTemplates.xml cites ref l.573-599 against
// ref-UIPanelTemplates.xml l.166-181), so the seated trough has to land back on the reference's
// own numbers here: TOPRIGHT + (-2, +5), BOTTOMRIGHT + (+29, -4), 31 wide. Miss it by 4 and the
// arrow buttons ride out of their sockets onto the caps, which is what B224 reported.
let d = |s: &mut UiScript, expr: &str| s.eval::<f64>(&format!("return {expr}")).unwrap();
let sf_right = d(&mut s, "ReputationListScrollFrame:GetRight()");
let sf_top = d(&mut s, "ReputationListScrollFrame:GetTop()");
let sf_bottom = d(&mut s, "ReputationListScrollFrame:GetBottom()");
let t = "ReputationListScrollFrameScrollBarTrough";
assert_eq!(d(&mut s, &format!("{t}:GetLeft()")), sf_right - 2.0);
assert_eq!(d(&mut s, &format!("{t}:GetRight()")), sf_right + 29.0);
assert_eq!(d(&mut s, &format!("{t}:GetTop()")), sf_top + 5.0);
assert_eq!(d(&mut s, &format!("{t}:GetBottom()")), sf_bottom - 4.0);
assert_eq!(d(&mut s, &format!("{t}:GetWidth()")), 31.0);
// **The trough is GONE with our pane.** `ReputationListScrollFrameScrollBarTrough` was the one
// name in this window that was never the reference's (1844) — it belongs to our own scroll
// templates, and stock `ReputationFrame.xml` declares no such thing. B224's law (the 21/20/8
// hang that drops each arrow into its socket) is unchanged and still asserted, by the
// keybindings page's own trough, which is a window we still own (1875).
// Scroll three rows down: every slot re-binds, and slot 1 stops being a header.
s.run("FauxScrollFrame_SetOffset(ReputationListScrollFrame, 3) ReputationFrame_Update()")
.unwrap();
@@ -544,6 +529,95 @@ fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B369 — a disabled box is still a box.** The reported symptom, at the quad level.
///
/// `ReputationDetailAtWarCheckBox` (stock `ReputationFrame.xml`) carries a `<NormalTexture>` and
/// **no `<DisabledTexture>`**, and `ReputationFrame_Update` `Disable()`s it for every faction whose
/// war flag cannot be toggled (`ReputationFrame.lua` l.115-120). The client's `SetState 0x779790`
/// gates its hide-old step on the new state having a texture, so the `UI-CheckBox-Up` box stays up
/// and the row reads "a greyed **At War** beside an empty box". Ours resolved the shown texture as
/// a pure function of the state, hid it, and left a bare grey label with nothing beside it — which
/// is what MarcusAga photographed on Ironforge.
///
/// Three rows, because the tick is the half that made the report confusing: a peace-forced faction
/// that is NOT at war shows the empty box (the shot), a peace-forced faction that IS at war shows
/// the box plus its grey `DisabledCheckedTexture` (*"the tick renders, however"*), and a
/// toggleable one is the control that must not move.
#[test]
fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_page(&s);
s.set_unit("player", Some(player(40)));
let mut st = state();
for e in &mut st.entries {
match e.name.as_str() {
// The report: peace-forced and at peace.
"Ironforge" => e.can_toggle_at_war = false,
// Peace-forced and at war — the same box, wearing the grey tick.
"Stormwind" => {
e.can_toggle_at_war = false;
e.at_war = true;
}
_ => {}
}
}
s.set_reputation(st);
s.run(r#"ToggleCharacter("ReputationFrame")"#).unwrap();
s.resolve();
/// Every texture the At War box itself draws, in painter order.
fn box_art(s: &UiScript) -> Vec<String> {
s.extract()
.iter()
.filter(|q| {
s.quad_owner_name(q.target).as_deref() == Some("ReputationDetailAtWarCheckBox")
})
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p.clone()),
_ => None,
})
.collect()
}
let up = "Interface\\Buttons\\UI-CheckBox-Up".to_string();
// Ironforge (row 2): peace-forced, at peace — the photographed case.
click_center(&mut s, "ReputationBar2");
assert_eq!(
s.eval::<i64>("return ReputationDetailAtWarCheckBox:IsEnabled()")
.unwrap(),
0,
"the box is disabled for a faction whose war flag is locked"
);
assert_eq!(
box_art(&s),
vec![up.clone()],
"and it still draws its box — the sticky shown texture"
);
// Stormwind (row 3): peace-forced and at war — box plus the grey disabled tick.
click_center(&mut s, "ReputationBar3");
assert_eq!(
box_art(&s),
vec![
up.clone(),
"Interface\\Buttons\\UI-CheckBox-Check-Disabled".to_string()
],
"a peace-forced faction at war keeps the box under its grey tick"
);
// Booty Bay (row 5): the control — toggleable, and unchanged by any of this.
click_center(&mut s, "ReputationBar5");
assert_eq!(
s.eval::<i64>("return ReputationDetailAtWarCheckBox:IsEnabled()")
.unwrap(),
1
);
assert_eq!(box_art(&s), vec![up], "a live box is the same box");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The watch bar shows what is being watched, and swaps posture at max level.**
///
/// Below max level it STACKS above the XP bar, 8px tall, wearing the `UI-ReputationWatchBar` end
@@ -661,3 +735,115 @@ fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() {
assert!(!shown(&mut s, "MainMenuExpBar"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B368 — "Show as Experience Bar", the whole round trip.** The reported symptom is the one thing
/// no test here covered: every check above either drives `SetWatchedFactionIndex` from Lua or pushes
/// `watched` in by hand, so the box itself — the only thing a player touches — had no guard at all
/// between the pointer and the wire.
///
/// What the round trip is, and why it LOOKS broken when a link is missing: the box's `<OnClick>`
/// (stock `ReputationFrame.xml` l.839-848) calls `SetWatchedFactionIndex(GetSelectedFaction())` and
/// then `ReputationWatchBar_Update()` — and that update reads `GetWatchedFactionInfo()`, which is
/// still nil, because watching is **not optimistic** (`0x4d6b60`: the slot is
/// `PLAYER_FIELD_WATCHED_FACTION_INDEX`, a server field with no client mirror). So the click's own
/// frame moves NOTHING on screen by design; the bar comes up one round trip later, when the
/// descriptor update lands as a fresh push plus `UPDATE_FACTION`. A server that never answers the
/// opcode therefore reads exactly as "the box does nothing", and so does anything between the
/// pointer and the send.
///
/// Driven through the real pointer pipeline, so a frame eating the box's clicks fails here.
#[test]
fn the_show_as_experience_bar_box_sends_the_watch_and_the_server_brings_the_bar_up() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_reputation_page();
// The row's checkmark is anchored past `factionName:GetStringWidth()`, so this test needs a
// font: without a measurer installed every metric reads 0 (`script::measure`'s "absent by
// default" state) and the mark would land on the name's first letter for that reason rather
// than for a real one. 6 units a character, the file-wide stand-in.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
let _ = s.take_reputation_sends();
// Ironforge (visible row 2, reputation slot 20) — select it and open the popup by clicking it.
click_center(&mut s, "ReputationBar2");
assert!(shown(&mut s, "ReputationDetailFrame"), "the popup opened");
assert!(
!s.eval::<bool>("return ReputationDetailMainScreenCheckBox:GetChecked() and true or false")
.unwrap(),
"nothing watched, so the box starts clear"
);
assert!(
!shown(&mut s, "ReputationWatchBar"),
"and the strip is down"
);
// THE CLICK. The engine ticks the box before the handler runs (the reference's own order), so
// the handler takes the `GetChecked()` branch that watches rather than the one that clears.
click_center(&mut s, "ReputationDetailMainScreenCheckBox");
assert_eq!(
s.take_reputation_sends(),
[benilla_ui::script::ReputationSend::Watch(Some(20))],
"one send, carrying Ironforge's reputation SLOT — not its visible row"
);
assert!(
!shown(&mut s, "ReputationWatchBar"),
"and nothing on screen moved: watching is not optimistic"
);
// The server's answer: the descriptor update arrives as a fresh push, and the event with it.
let mut watched = state();
watched.watched = Some(20);
s.set_reputation(watched);
s.fire_event("UPDATE_FACTION", vec![]);
s.resolve();
assert!(
s.eval::<bool>("return ReputationDetailMainScreenCheckBox:GetChecked() and true or false")
.unwrap(),
"NOW the box reads ticked — off the server's field, not off the click"
);
assert!(shown(&mut s, "ReputationWatchBar"), "and the strip is up");
assert_eq!(
text_of(&mut s, "ReputationWatchStatusBarText"),
"Ironforge 1000 / 6000"
);
// The list's own witness: the row grows a checkmark, re-anchored past the name's string width
// (ref `ReputationFrame.lua` l.96-103) — the half a player sees without leaving the pane.
assert!(
shown(&mut s, "ReputationBar2Check"),
"the watched row wears the checkmark"
);
assert!(
!shown(&mut s, "ReputationBar3Check"),
"and no other row does"
);
let (check_l, name_l) = s
.eval::<(f32, f32)>(
"return ReputationBar2Check:GetLeft(), ReputationBar2FactionName:GetLeft()",
)
.unwrap();
// "Ironforge" is nine characters, so the stand-in font makes the offset exactly 54 — the
// reference's `factionName:GetStringWidth()`, not a guess at one.
assert_eq!(
check_l - name_l,
54.0,
"the mark sits the name's own string width to its right"
);
// Untick: the same box, the same funnel, and `None` — NOT slot 0, which is a real faction.
click_center(&mut s, "ReputationDetailMainScreenCheckBox");
assert_eq!(
s.take_reputation_sends(),
[benilla_ui::script::ReputationSend::Watch(None)]
);
let mut cleared = state();
cleared.watched = None;
s.set_reputation(cleared);
s.fire_event("UPDATE_FACTION", vec![]);
s.resolve();
assert!(!shown(&mut s, "ReputationWatchBar"), "the strip goes down");
assert!(!shown(&mut s, "ReputationBar2Check"), "and the row's mark");
assert!(!s
.eval::<bool>("return ReputationDetailMainScreenCheckBox:GetChecked() and true or false")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -73,14 +73,14 @@ fn settled_default_ui() -> UiScript {
s
}
/// One frame in the app's own order (`extract::drive_script`): measure FIRST, then resolve.
/// One frame in the app's own order (`extract::tick_script`): measure FIRST, then resolve.
fn app_frame(s: &mut UiScript) {
answer_measures(s);
s.resolve();
}
/// A frame that also **ticks the VM**, i.e. runs the shipped UI's own `OnUpdate` handlers, in
/// `drive_script`'s real order (tick → measure → resolve).
/// `tick_script`'s real order (tick → measure → resolve).
///
/// [`app_frame`] deliberately models only the measure/resolve half, and the tooltip benches stand
/// in for the handler by calling it themselves. That is fine when the test IS the driver — but a
@@ -97,7 +97,7 @@ fn app_frame_ticked(s: &mut UiScript, dt: f32) {
fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) {
s.run(&format!(
r#"
local a = CreateFrame("Button", "{owner}"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local a = CreateFrame("Button", "{owner}"); a:SetPoint("CENTER", 0, 0); a:SetWidth(10); a:SetHeight(10)
{func}_n = 0
function {func}()
{func}_n = {func}_n + 1
@@ -174,7 +174,7 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
assert_eq!(
solves, 10,
"10 content changes must cost 10 solves — one each. Two per change means the measure \
round-trip is running AFTER the resolve again (extract::drive_script's order)."
round-trip is running AFTER the resolve again (extract::tick_script's order)."
);
// …and none of those ten may DERIVE the graph (decision 1388). This is the second shape of
// the same law `a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui`
@@ -534,7 +534,7 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
s.run(
r#"
FlipOwner = CreateFrame("Button", "FlipOwner"); FlipOwner:SetPoint("CENTER", 0, 0)
FlipOwner:SetSize(10, 10)
FlipOwner:SetWidth(10); FlipOwner:SetHeight(10)
flip_n = 0
-- The two shapes a hover alternates between. The trailing `1` on the wrap arm is
-- `AddLine`'s positional wrapText flag (the byte-pinned 0x531630 signature) — it is what
@@ -606,7 +606,7 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
r#"
for i = 1, 12 do
local b = CreateFrame("Button", "SweepOwner" .. i)
b:SetPoint("CENTER", 0, 0); b:SetSize(10, 10)
b:SetPoint("CENTER", 0, 0); b:SetWidth(10); b:SetHeight(10)
end
sweep_n = 0
-- A different owner AND a different line shape every step: the two halves of a real sweep
@@ -662,9 +662,11 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
///
/// The two guards above drive `SetOwner(button, "ANCHOR_RIGHT")` — the bag slot's idiom. The bars
/// do not use it. With `UberTooltips` at its shipped default of `"1"`, every action button routes
/// through `GameTooltip_SetDefaultAnchor` (`ActionBar.xml:510`, and the stance/pet/bonus bars the
/// through `GameTooltip_SetDefaultAnchor` (stock `ActionButton_SetTooltip`, and the stance/pet/bonus bars the
/// same), which is `SetOwner(owner, "ANCHOR_NONE")` followed by an explicit `SetPoint` — a
/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors.
/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors
/// (`0x52fe90`'s mode-7 leg reaches `0x52fec2 call 0x767ed0` like every mode but PRESERVE — 2176
/// re-confirmed that at the bytes after 2142 predicted the opposite).
///
/// That drop took the conservative touch, so it re-derived the whole graph on every button the
/// cursor crossed — and on nothing else, which is why it survived two rounds of fixing and a
@@ -679,7 +681,7 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
r#"
for i = 1, 12 do
local b = CreateFrame("Button", "BarOwner" .. i)
b:SetPoint("CENTER", 0, 0); b:SetSize(36, 36)
b:SetPoint("CENTER", 0, 0); b:SetWidth(36); b:SetHeight(36)
end
bar_n = 0
-- `GameTooltip_SetDefaultAnchor`'s body, which is what every action button actually runs:
@@ -721,8 +723,90 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
let derives = s.layout_derivations() - derives_before;
assert_eq!(
derives, 0,
"24 action-bar hovers derived the layout graph {derives} times. `SetOwner`'s ANCHOR_NONE \
arm drops the tooltip's anchors, which is a retarget to the EMPTY target set and names \
its node like any other (decision 1630, extending 1625)."
"24 action-bar hovers derived the layout graph {derives} times. The `ClearAllPoints()` on \
`GameTooltip_SetDefaultAnchor`'s next line is a retarget to the EMPTY target set and must \
name its node like any other (decision 1630, extending 1625; 2176 moved the emptying \
itself off `SetOwner`'s ANCHOR_NONE arm, which the reference leaves alone)."
);
}
/// **The bag-addon hover law** (decision 2114, ledger B06's third idiom): a hover that owns the
/// tooltip WITH an anchor and then re-points it by hand must cost **zero** derivations.
///
/// The guard above drives `SetOwner(owner, "ANCHOR_NONE")` + `ClearAllPoints()`, and it has always
/// passed — because ANCHOR_NONE already dropped the anchors, so the `ClearAllPoints()` after it
/// finds an empty list and touches nothing at all. It is the third idiom that was never driven:
///
/// ```lua
/// -- Bagnon_Core/core/Item.lua -> Bagnon_Core/core/Utility.lua
/// ContainerFrameItemButton_OnEnter(item) -- SetOwner(item, "ANCHOR_RIGHT"): sets an anchor
/// Bagnon_AnchorTooltip(item) -- ClearAllPoints(), then GetLeft(), then SetPoint
/// ```
///
/// `ClearAllPoints()` with anchors present took the conservative touch, and the `GetLeft()` on the
/// next line settles the layout right there — so the whole graph was re-derived INSIDE the handler,
/// once per bag slot the cursor crossed. Measured live on `Probetwo` with the director's AddOns
/// folder, `WOW_UI_HANDLERS=8` over a 60 Hz sweep of twelve `BagnonItem*` buttons: `OnEnter` self
/// 1.75 ms/frame and `[layout-derive]` naming this site in three of its four samples, against
/// 0.43 ms/frame of total `tick` for the same sweep over stock `ContainerFrame1Item*` with no
/// addons. `derives/frame` read 0.88 with Bagnon and 0.00 without.
#[test]
fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
let mut s = settled_default_ui();
s.run(
r#"
for i = 1, 12 do
local b = CreateFrame("Button", "BagOwner" .. i)
b:SetPoint("CENTER", 0, 0); b:SetWidth(37); b:SetHeight(37)
end
bag_n = 0
function bag_hover()
bag_n = bag_n + 1
local item = getglobal("BagOwner" .. (math.mod(bag_n, 12) + 1))
-- ContainerFrameItemButton_OnEnter's arm: an ANCHORED SetOwner.
GameTooltip:SetOwner(item, "ANCHOR_RIGHT")
GameTooltip:AddLine("Item " .. bag_n, 1, 1, 1)
if math.mod(bag_n, 2) == 0 then
GameTooltip:AddLine("Use: restores health over 21 sec.", 0, 1, 0, 1)
else
GameTooltip:AddLine("Main Hand", 1, 1, 1)
end
GameTooltip:Show()
-- …then Bagnon_AnchorTooltip: drop the anchors it just set, ASK A RESOLVED EDGE
-- (which settles the layout on the spot), and re-point by hand.
GameTooltip:ClearAllPoints()
local left = item:GetLeft() or 0
if left < (UIParent:GetRight() / 2) then
GameTooltip:SetPoint("TOPLEFT", item, "BOTTOMRIGHT")
else
GameTooltip:SetPoint("TOPRIGHT", item, "BOTTOMLEFT")
end
end
"#,
)
.unwrap();
// Positive control across the births and the line pool's growth — both structural.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("bag_hover()").unwrap();
app_frame(&mut s);
}
assert!(
s.layout_derivations() > born_at,
"creating twelve slots must derive the graph — zero here makes the assertion vacuous."
);
let derives_before = s.layout_derivations();
for _ in 0..24 {
s.run("bag_hover()").unwrap();
app_frame(&mut s);
}
let derives = s.layout_derivations() - derives_before;
assert_eq!(
derives, 0,
"24 bag-slot hovers derived the layout graph {derives} times — one per slot crossed. \
`ClearAllPoints` drops a node's whole anchor-target set, which is a retarget onto the \
EMPTY set and names its node like any other (decision 2114, completing 1625/1630)."
);
}
@@ -1,4 +1,4 @@
//! Print screen's UI half (`assets/ui/ScreenshotStatus.xml`) against the shipped XML — and above
//! Print screen's UI half — the stock `WorldFrame.xml`'s `ScreenshotStatus` (decision 1983) — and above
//! all **B261's third clause: the "Screen Captured" line must not be in the file it announces.**
//!
//! That contract is an ORDERING, so it is tested as one. Two paths could put text in a picture and
@@ -14,13 +14,17 @@ fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"UIParent.xml",
"ScreenshotStatus.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
r"Interface\FrameXML\WorldFrame.xml",
] {
load_xml(&s, f);
}
@@ -216,6 +216,11 @@ mod tests {
// re-seats off `!script.has_text_measurer()` — it interrogates the VM instead of a memo,
// which is the same guarantee arrived at the other way.
("ui_script/extract/mod.rs", "last_seam"),
// The plate driver's anti-overlap scratch (decision 2148 put the VM in that system's
// hands). It is cleared at the top of every run and rebuilt from this frame's plates —
// a reused allocation, not memory: nothing in it survives the call it was filled in, so
// a new VM has nothing to stale.
("vplates.rs", "bucket"),
// The window's `scale_factor` beside it (decision 1342) — the other term a measure is
// only correct under, since a logical height becomes an integer DEVICE-pixel raster size.
// A fact about the window, not about the VM; it gates the same re-seat `last_seam` does,
+766 -15
View File
@@ -7,13 +7,24 @@
//! invisible to the chain's own files too, because a Lua caller that uses one value of two never
//! notices the second.
//!
//! **Arity is only half a shape.** A binding that answers the right *number* of values of the
//! wrong *kind* is the same silent break one level down, and it is the shape the 1.12 API is most
//! exposed to: the reference has no boolean-returning query at all — every `Is*`/`Can*`/`Has*` row
//! in the table is `(nil) | (number)`, because the client pushes `1` and `nil`, never `true` and
//! `false`. An addon writing `if IsShiftKeyDown() == 1` — and 87 corpus addons compare returns to
//! `1` — reads a `true` as "not held". So the kinds column is gated as well, wherever the table
//! says it is trustworthy (decision 2118).
//!
//! The table is wow-re's `re/audit/binding-shapes.tsv`, vendored: 1722 rows over all 82 registrar
//! tables, one per registered binding, generated and differentially tested against that repo's
//! other harvester. It carries the gate rule **per row** rather than in prose, which is the whole
//! reason it can be enforced instead of remembered:
//!
//! * `arity_conf = exact` — sound. This is the column to gate on.
//! * `kinds_conf` — trustworthy only where an independent push count agreed; not gated here.
//! * `kinds_conf = agree` — the row's independent `lua_push*` trace matched `eax` at every `ret`,
//! which is the table's own statement that the kinds are trustworthy. Gated too, by
//! [`every_query_binding_answers_the_reference_s_return_kinds`]; a row that did not agree is
//! advisory and this module never reads its kinds at all.
//! * `argc_conf` — **never** gate on it. It is a deliberate over-flagging superset: any binding
//! whose direct callee reads a Lua index is marked `lower-bound`, so a `lower-bound` row says
//! nothing about the real argument count.
@@ -25,9 +36,15 @@
//! `AcceptBattlefieldPort` for theirs. Names are filtered to the query verbs — `Get*`, `Is*`,
//! `Has*`, `Can*`, `Unit*`, `Num*` — which is a loss of coverage, never a wrong assertion.
//!
//! **A call that raises is skipped, not failed.** 53 of the 83 unit-table bindings gate their
//! arguments and raise on a nil token (1834/1836), so a no-argument probe of those is expected to
//! throw; that says nothing about arity. Only a call that *returns* is measured.
//! **A call that raises is skipped, not failed.** A raise says nothing about arity, so only a call
//! that *returns* is measured. That rule used to cost the whole unit surface: the unit bindings
//! gate their token and raise on a nil one (1834/1836), so a no-argument probe threw for every one
//! of them, and the 62 `Unit*` rows this table calls `arity_conf = exact` were silently
//! uncovered — the gap named in this doc and never priced. The probe now **seats a player** and
//! calls `Unit*` with a real token, which took coverage from **220 bindings to 249**. All 29 newly
//! measured agreed with the reference on the first run; the value here is that they cannot drift
//! away from it unnoticed, not that anything was found wrong. The remainder still raise, needing
//! state a seated body does not supply, and are still skipped.
//!
//! ## The name is not a key
//!
@@ -48,6 +65,26 @@
/// an entry needs an arity, a reason, and a dispatched question.
const NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
/// The **kind** gate's own shrinking list, same rules as [`NOT_YET_ASSERTED`]: a name, the wrong
/// kinds it currently answers, and the dispatched question that will settle it. An entry whose
/// binding has come into agreement fails the gate, so it cannot outlive its fix — and both of the
/// two it held did exactly that, within the day.
///
/// **It is empty.** It held `GetSendMailItem`, whose no-attachment leg the shapes table types as
/// two numbers and no recorded note gave the values of. wow-re answered it at the bytes —
/// `0x4ae590` pushes `(nil, nil, 0, 0)`, and the stray `1` we had was borrowed from
/// `GetAuctionSellItemInfo`'s empty leg — and 2129 folded the verdict back.
const KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[];
/// The widget kind gate's shrinking list — see [`KINDS_NOT_YET_ASSERTED`].
///
/// **Also empty.** It held `GetFont`, whose height slot the table types as a number and which we
/// answered nil on a FontString that was never given a font. The recorded note read as though a
/// NULL `CGxFont` should nil all three slots; the bytes say otherwise — `0x7727b0`'s unconditional
/// `fld [esi+0xe4]` sits in front of the `+0xe0` test, behind a branch `GetFont` never takes — and
/// 2129 folded that back too.
const WIDGET_KINDS_NOT_YET_ASSERTED: &[(&str, &str, &str)] = &[];
/// The widget half's own shrinking list — see [`NOT_YET_ASSERTED`], same rules. Also empty:
/// `GetFogColor` came off it with 1845, which found the fog colour was a packed `0xAARRGGBB` dword
/// all along.
@@ -61,9 +98,18 @@ struct Row {
name: String,
table_va: String,
arity: usize,
/// The `kinds` column, verbatim — `(number,string?)`, or several `|`-separated alternatives.
/// Empty when the row's `kinds_conf` is not `agree`; see [`KindGate`].
kinds: String,
}
fn rows() -> Vec<Row> {
rows_of_kind("global")
}
/// The same read, for one `table_kind` — `global` is the registrar surface, `baselib` the
/// 36-entry Lua base array looped straight into `_G` at `0x811e28`.
fn rows_of_kind(kind: &str) -> Vec<Row> {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-shapes.tsv"
@@ -74,13 +120,29 @@ fn rows() -> Vec<Row> {
.filter_map(|l| {
let f: Vec<&str> = l.split('\t').collect();
// name fn pair_va table_va table_kind argc argc_conf arity arity_conf kinds …
if f.len() < 9 || f[4] != "global" || f[8] != "exact" {
if f.len() < 9 || f[4] != kind || f[8] != "exact" {
return None;
}
// **`agree` is necessary and not sufficient, and the second condition is the table's
// own too** (2150 + wow-re `scratch/binding-kinds-stack-overwrite.md`). The kinds
// come from tracing each binding's `lua_push*` calls; a row whose notes carry
// `delegated-push` has a CALLEE that pushes on its own behalf, which the trace sees
// one level too high up, so its tuple covers the binding's own ops only. `eax` is
// unaffected, so arity stays gated on every row — it is the kinds claim alone that
// narrows.
let delegated = f.len() > 11 && f[11].contains("delegated-push");
Some(Row {
name: f[0].to_string(),
table_va: f[3].to_string(),
arity: f[7].parse().ok()?,
// ADVISORY unless `kinds_conf == agree` — the table's own column contract. A row
// that did not agree carries no kinds here at all, so nothing downstream can read
// one by accident.
kinds: if f.len() > 10 && f[10] == "agree" && !delegated {
f[9].to_string()
} else {
String::new()
},
})
})
.collect()
@@ -102,7 +164,21 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
by_name.entry(&r.name).or_default().push(r);
}
let s = benilla_ui::script::UiScript::new().expect("VM");
let mut s = benilla_ui::script::UiScript::new().expect("VM");
// **Seat a player, so the unit surface can be probed at all.** The unit bindings gate their
// token and raise on a nil one (1834/1836), so a no-argument probe measured *nothing* for the
// 62 `Unit*` rows this table calls `arity_conf = exact` — the gate's own doc named that gap in
// prose and nobody had priced it. With a body seated they answer instead of raising, which is
// the difference between covering the unit surface and skipping it.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
@@ -128,11 +204,23 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
}
let want = first.arity;
// A call that raises says nothing about arity — 53 of the unit bindings gate their
// arguments and raise on a nil token (1834/1836).
// A call that raises still says nothing about arity. The unit bindings are the reason:
// they gate their arguments and raise on a nil token (1834/1836), so they are called with
// a real token against the body seated above rather than with nothing.
//
// The count is taken with 5.0's own `arg.n`, for the reason [`KIND_HELPER`] states one
// gate down plus the one that settled it: `select` is 5.1's base library, not a 1.12
// global, and `lua50::install` removes it. Where the count is wanted on the HOST side we
// ask `UiScript::arity`; these four probes have to hold it INSIDE a `pcall`, and there
// the vararg's own `n` is the 1.12 spelling.
let call = if name.starts_with("Unit") {
format!(r#"{name}("player")"#)
} else {
format!("{name}()")
};
let probe = format!(
"if type({name}) ~= 'function' then return -1 end \
local ok, n = pcall(function() return select('#', {name}()) end) \
local ok, n = pcall(function() return (function(...) return arg.n end)({call}) end) \
if not ok then return -1 end return n"
);
let got: i64 = match s.eval(&probe) {
@@ -156,8 +244,13 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
}
}
// A floor, not a target: it exists so a change that quietly stops the probe from measuring
// anything fails loudly instead of passing vacuously. Seating a player took the count from
// **220 to 249** by making the unit bindings answerable, so the old floor of 40 was six times
// below reality and would have sat green through losing the entire unit surface. Raise this
// whenever coverage rises; never lower it to make a change fit.
assert!(
checked >= 40,
checked >= 240,
"the gate measured only {checked} bindings — it has stopped covering anything"
);
// The list may only SHRINK. An entry that now agrees is a fix nobody deleted the note for,
@@ -169,7 +262,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
by_name.get(n).is_some_and(|rs| {
let want = rs[0].arity;
s.eval::<i64>(&format!(
"local ok, n = pcall(function() return select('#', {n}()) end) \
"local ok, n = pcall(function() return (function(...) return arg.n end)({n}()) end) \
if not ok then return -1 end return n"
))
.is_ok_and(|got| got >= 0 && got as usize == want)
@@ -188,6 +281,186 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
);
}
/// **The base-library arm of the gate** (decision 2136) — the 36 entries at `0x811e28`, which the
/// other three arms never looked at.
///
/// [`rows`] filters to `table_kind = global`, so every `baselib` row in the vendored table sat
/// outside all of the arity, kinds and widget halves. That is not a small corner: it is `gcinfo`,
/// `collectgarbage`, `loadstring`, `pairs`, `type`, `tostring` — the functions every addon calls
/// most — and it is where two divergences sat with their correct arities recorded beside them the
/// whole time. `gcinfo … 2 exact (number,number) agree` and `collectgarbage … 0 exact () agree`
/// were both in this file before anyone answered either one wrong.
///
/// **The base library is probed by an explicit ladder — a name and the arguments to call it
/// with** — rather than a prefix rule. The global arm can filter to query verbs because
/// `Get*`/`Is*` names them; the base library has no such convention, and calling it blind means
/// calling `error`, `pcall` and `setfenv` for their arities.
///
/// ## Why an argument ladder is safe HERE and was not for the global arm
///
/// 2129 §Why left the ladder question open on exactly one hazard: *a wrong-but-plausible argument
/// can produce a legitimate kind tuple outside the reference's reachable set, which is a false
/// positive — the most expensive gate failure.* That hazard is real for a game binding, where the
/// argument's meaning is the unknown (what index does `GetInboxItem` accept, and what does the
/// mailbox hold when it does?).
///
/// It does not transfer to the base library, and the reason is worth stating rather than
/// assuming: these are stock Lua 5.0 functions whose argument **types** are fixed by the language
/// and whose bodies were read at the bytes. `type` takes any value, `rawget` a table and a key,
/// `pairs` a table. There is no state to be in the wrong shape, so the argument below is not a
/// guess about the reference — it is the only shape the function accepts.
///
/// The arguments are chosen to **discriminate**, not merely to avoid a raise: `assert` is called
/// with TWO arguments precisely because its `arity 1 exact` row is only falsifiable that way — a
/// one-argument call answers one value in both dialects and proves nothing. (That probe is what
/// found 5.1's `return lua_gettop(L)` still in place here; see `script::lua50::install_assert`.)
///
/// Excluded, and why: `error`/`pcall`/`xpcall` and the rows whose `arity_conf` is not `exact`
/// (`next`, `unpack`, `ipairs`, `loadstring`) are filtered out upstream anyway; `setfenv` is left
/// out because it mutates the environment every later probe runs in, which is a different risk
/// from raising.
const BASELIB_PROBES: &[(&str, &str)] = &[
("collectgarbage", ""),
("date", ""),
// The five shipped stubs — `xor eax,eax; ret` in the image (`lua-dialect.md` §3a), so calling
// one is as safe here as it is there, and their `0 exact ()` rows are still worth holding.
("debugbreak", ""),
("debugdump", ""),
("debuginfo", ""),
("debugload", ""),
("debugprint", ""),
("debugprofilestart", ""),
("debugprofilestop", ""),
("debugstack", ""),
("debugtimestamp", ""),
("gcinfo", ""),
("geterrorhandler", ""),
("getfenv", ""),
("time", ""),
// ── the ladder (2136's "left open", closed) ──────────────────────────────────────────────
// TWO arguments on purpose: the row is `arity 1 exact` and only a multi-argument call can
// tell 5.0's `lua_settop(L,1); return 1` from 5.1's `return lua_gettop(L)`.
("assert", "1, 2"),
("getglobal", "\"BenillaShapeGateAbsent\""),
("getmetatable", "{}"),
("pairs", "{}"),
("rawequal", "1, 1"),
("rawget", "{}, 1"),
("rawset", "{}, 1, 1"),
// Both write, and both are namespaced or self-restoring so the VM the next probe sees is the
// one it would have seen anyway.
("seterrorhandler", "geterrorhandler()"),
("setglobal", "\"BenillaShapeGateProbe\", 1"),
("setmetatable", "{}, nil"),
("tonumber", "\"1\""),
("tostring", "nil"),
("type", "nil"),
];
/// The base library's own shrinking list — same rules as [`NOT_YET_ASSERTED`]. **Empty.**
const BASELIB_NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
#[test]
fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
let rows = rows_of_kind("baselib");
assert!(
rows.len() > 20,
"the vendored table's baselib rows look wrong: {}",
rows.len()
);
let s = benilla_ui::script::UiScript::new().expect("VM");
let mut checked = 0usize;
let mut kinds_checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for r in &rows {
let Some((_, args)) = BASELIB_PROBES.iter().find(|(n, _)| *n == r.name) else {
continue;
};
let name = &r.name;
let probe = format!(
"if type({name}) ~= 'function' then return -1 end \
local ok, n = pcall(function() return (function(...) return arg.n end)({name}({args})) end) \
if not ok then return -1 end return n"
);
let got: i64 = match s.eval(&probe) {
Ok(n) => n,
Err(_) => continue,
};
if got < 0 {
continue;
}
checked += 1;
if got as usize != r.arity {
if let Some((_, known, _)) = BASELIB_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
assert_eq!(*known, r.arity, "{name} is listed at a stale arity");
continue;
}
mismatches.push(format!(
"{name}: answers {got}, reference states {}",
r.arity
));
continue;
}
// Kinds, wherever the table calls them trustworthy — the same `kinds_conf = agree` rule
// the global arm uses, and the column that types `gcinfo` as `(number,number)`. The
// `delegated-push` narrowing is applied once, in [`rows_of_kind`], for every arm.
if r.kinds.is_empty() {
continue;
}
let Ok(kinds) = s.eval::<String>(&format!(
"local t = {{ {name}({args}) }} local out = '' \
for i = 1, {} do out = out .. (i > 1 and ',' or '') .. type(t[i]) end return out",
r.arity
)) else {
continue;
};
kinds_checked += 1;
let got_tuple = format!("({kinds})");
let acceptable = r.kinds.split('|').map(str::trim).any(|alt| {
alt == got_tuple
|| alt
.trim_matches(|c| c == '(' || c == ')')
.split(',')
.map(str::trim)
.zip(kinds.split(',').map(str::trim))
// `string?` is string-or-nil; `any`/`value` accept anything.
.all(|(want, got)| {
want == got
|| (want == "string?" && (got == "string" || got == "nil"))
|| want == "any"
|| want == "value"
})
});
if !acceptable {
mismatches.push(format!(
"{name}: answers kinds {got_tuple}, reference states {}",
r.kinds
));
}
}
// A floor, so a change that stops the probe measuring anything fails loudly.
// A floor, not a target: raise it when coverage rises, never lower it to fit a change.
assert!(
checked >= 28,
"the base-library gate measured only {checked} bindings"
);
// **The kinds half gets its own floor.** Arity coverage says nothing about how many rows the
// kinds comparison actually ran on, and the `kinds_conf`/`delegated-push` narrowing is exactly
// the kind of change that can quietly take it to zero while the arity floor stays green.
assert!(
kinds_checked >= 16,
"the base-library gate compared kinds on only {kinds_checked} bindings"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed base-library bindings diverge:\n {}",
mismatches.len(),
mismatches.join("\n ")
);
}
/// The widget registrar tables, each mapped to a Lua expression that produces one instance.
///
/// The table address IS the widget class — the reference registers one `{name, fn}` table per
@@ -210,13 +483,37 @@ const WIDGET_PROBES: &[(&str, &str, &str)] = &[
("0x87abb0", "ColorSelect", "PGColor"),
("0x87b960", "MessageFrame", "PGMessage"),
("0x87b5c0", "ScrollingMessageFrame", "PGScrollMsg"),
("0x878948", "PlayerModel", "PGModel"),
("0x878948", "Model", "PGModel"),
// ── The eleven tables this list did NOT cover until 2142 ──────────────────────────────────
// Twelve of the reference's twenty-three widget tables were mapped, so 11 classes — 190 of
// its 481 widget rows, `GameTooltip`'s 46 among them — were skipped by BOTH the arity gate
// and the kind gate, silently and by omission rather than by a stated narrowing. The
// identities are the corpus census's (2142), each read off its table's own distinctive
// members rather than guessed: `SetOwner`/`AddDoubleLine` is GameTooltip's, `CopyFontObject`
// the Font object's, `PingLocation` the Minimap's, `Dress`/`TryOn` DressUpModel's,
// `CycleVariation` TabardModel's, `StartMovie` MovieFrame's, `SetSlot` LootButton's.
("0x854198", "GameTooltip", "PGTip"),
("0x84c538", "Minimap", "PGMinimap"),
("0x84ee40", "TabardModel", "PGTabard"),
("0x84f190", "DressUpModel", "PGDress"),
// `PlayerModel` is `Model` plus three verbs, so its own table needs its own instance — the
// `0x878948` row above is now the plain `Model` it always was.
("0x84f1fc", "PlayerModel", "PGPlayerModel"),
("0x87ab4c", "MovieFrame", "PGMovie"),
("0x847ce4", "LootButton", "PGLoot"),
// The base map every widget misses into (its 19 names are exactly `script/mod.rs`'s
// `REGION_MAP_METHODS`). It has no class of its own, so it is probed on a Frame — which is
// what "its own class" means for a base: every widget answers it.
("0x87c9b8", "Frame", "PGRegionBase"),
];
/// The two region classes, which are made by a frame rather than by `CreateFrame`.
const REGION_PROBES: &[(&str, &str)] = &[
("0x87c128", "PGFrame:CreateTexture('PGTex')"),
("0x87c1d8", "PGFrame:CreateFontString('PGFS')"),
// The FONT OBJECT (`CreateFont`), not a FontString — 22 methods of its own, and the third
// thing here that `CreateFrame` cannot make (2142).
("0x87c7c8", "CreateFont('PGFontObject')"),
];
/// **The widget half of the gate** (decision 1843) — the half that would have caught 1840's
@@ -252,7 +549,7 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
}
}
assert!(
made.len() >= 8,
made.len() >= 20,
"only {} widget classes could be instantiated — the probe set is broken",
made.len()
);
@@ -281,7 +578,7 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
let probe = format!(
"if type({obj}.{name}) ~= 'function' then return -1 end \
local ok, n = pcall(function() return select('#', {obj}:{name}()) end) \
local ok, n = pcall(function() return (function(...) return arg.n end)({obj}:{name}()) end) \
if not ok then return -1 end return n"
);
let Ok(got) = s.eval::<i64>(&probe) else {
@@ -302,7 +599,10 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
}
assert!(
checked >= 60,
// 121 at 2142, when the probe set went from 12 of the reference's 23 widget tables to
// all 23. The floor it replaces was 60 — set at the coverage of 1843's day, and never
// raised, so eleven whole classes could go unprobed without this number moving.
checked >= 110,
"the widget gate measured only {checked} methods — it has stopped covering anything"
);
assert!(
@@ -312,3 +612,454 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
mismatches.join("\n ")
);
}
/// Split a `kinds` cell into its alternatives, each one a per-slot kind list.
///
/// `(number,string?)` is one alternative of two slots; `(nil) | (number)` is two of one. An
/// arity-0 row is `()` and yields nothing, which is how the kind gates skip the verbs that answer
/// no values at all.
fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
cell.split('|')
.map(|alt| {
alt.trim()
.trim_start_matches('(')
.trim_end_matches(')')
.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect::<Vec<String>>()
})
.filter(|a| !a.is_empty())
.collect()
}
/// Does one observed Lua `type()` satisfy one reference slot?
///
/// Two entries in the vocabulary assert less than they look like they do, and both are the table's
/// own doing rather than ours:
///
/// * **`string?` is string-or-nil.** `lua_pushstring 0x6f3890` begins `test edx,edx ; jne` and
/// tail-jumps to `lua_pushnil` when the `char*` is NULL, so a push instruction is not proof that
/// a string reached Lua. The harvester emits plain `string` only where the pointer is a constant
/// resolving to a readable literal; everything else is `string?`. This is *why* a miss path can
/// show a fixed arity with no kind alternation and still answer nil in that slot.
/// * **`any` / `value`** are the slots the walk could not narrow. They accept everything.
///
/// Everything else is exact — and note what is *absent* from the vocabulary across all 1503 rows
/// the table calls trustworthy: two `boolean` slots in the entire client, `IsPetAttackActive` and
/// Lua's own `rawequal`. A 1.12 predicate answers `1` or `nil`.
fn slot_accepts(want: &str, got: &str) -> bool {
match want {
"any" | "value" => true,
"string?" => got == "string" || got == "nil",
w => w == got,
}
}
/// The Lua helper both kind gates probe through: answer the `type()` of every value a call
/// returned, in order, comma-joined. A table constructor would swallow the trailing nils, so the
/// count has to come from the vararg itself.
///
/// It reads `arg.n`, not `select('#', ...)`, and that is not a style choice: **`...` as a value is
/// a syntax error in this VM**, because 5.0 has no `TK_DOTS` arm in `simpleexp` and the parser
/// patch reproduces that (1192; `lua50::tests::the_vararg_expression_is_a_syntax_error_as_it_is_on_1_12`).
/// The 5.0 spelling is the implicit `arg` table, which is what all 177 stock FrameXML files use.
const KIND_HELPER: &str = "function PGKinds(...) \
local o = {} for i = 1, arg.n do o[i] = type(arg[i]) end \
return table.concat(o, ',') end";
/// Every registered global whose return *kinds* the reference states trustworthily, answered by
/// this client (decision 2118).
///
/// Same three narrowings as the arity gate, for the same reasons: query verbs only, a call that
/// raises is skipped, and a name registered from two tables is read at its non-glue row. A fourth
/// is specific to kinds — a row whose observed arity already disagrees is left to the arity gate,
/// because comparing a 3-tuple against a 4-slot reference reports the same fault twice.
#[test]
fn every_query_binding_answers_the_reference_s_return_kinds() {
let all = rows();
let mut by_name: std::collections::HashMap<&str, Vec<&Row>> = std::collections::HashMap::new();
for r in &all {
by_name.entry(&r.name).or_default().push(r);
}
let mut s = benilla_ui::script::UiScript::new().expect("VM");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
s.run(KIND_HELPER).expect("kind helper");
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for (name, rs) in &by_name {
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
let candidates: Vec<&&Row> = if rs.len() == 1 {
rs.iter().collect()
} else {
rs.iter().filter(|r| r.table_va != GLUE_TABLE).collect()
};
let Some(first) = candidates.first() else {
continue;
};
if candidates.iter().any(|r| r.kinds != first.kinds) {
continue;
}
let want = kind_alternatives(&first.kinds);
if want.is_empty() {
continue;
}
let call = if name.starts_with("Unit") {
format!(r#"{name}("player")"#)
} else {
format!("{name}()")
};
let probe = format!(
"if type({name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({call}) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
// An arity fault is the arity gate's to report; reporting it here too would double-count.
if !want.iter().any(|alt| alt.len() == got.len()) {
continue;
}
checked += 1;
let agrees = want.iter().any(|alt| {
alt.len() == got.len()
&& alt
.iter()
.zip(&got)
.all(|(w, g)| slot_accepts(w.as_str(), g))
});
let observed = got.join(",");
if !agrees {
if let Some((_, known, _)) = KINDS_NOT_YET_ASSERTED.iter().find(|(n, ..)| n == name) {
assert_eq!(
*known, observed,
"{name} is on the not-yet-asserted list at stale kinds — it now answers ({observed})"
);
continue;
}
mismatches.push(format!(
"{name}: answers ({observed}), reference states {}",
first.kinds
));
} else {
assert!(
!KINDS_NOT_YET_ASSERTED.iter().any(|(n, ..)| n == name),
"{name} now agrees with the reference and must come off KINDS_NOT_YET_ASSERTED"
);
}
}
// A floor, not a target — see the arity gate's. Raise it when coverage rises; never lower it
// to make a change fit.
assert!(
checked >= 150,
"the kind gate measured only {checked} bindings — it has stopped covering anything"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed bindings answer values of the wrong kind:\n {}",
mismatches.len(),
mismatches.join("\n ")
);
}
/// The widget half of the kind gate — same rules, probed on an instance of each method's own
/// registrar class (see [`WIDGET_PROBES`]).
#[test]
fn every_widget_method_answers_the_reference_s_return_kinds() {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-shapes.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(KIND_HELPER).expect("kind helper");
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
for (table, kind, name) in WIDGET_PROBES {
if s.run(&format!(
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
))
.is_ok()
{
made.insert(table, (*name).to_string());
}
}
for (table, expr) in REGION_PROBES {
let var = format!("PGR{}", made.len());
if s.run(&format!("{var} = {expr}")).is_ok() {
made.insert(table, var);
}
}
let mut checked = 0usize;
let mut mismatches: Vec<String> = Vec::new();
for line in text
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
{
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 11 || f[4] != "widget" || f[8] != "exact" || f[10] != "agree" {
continue;
}
let (name, table) = (f[0], f[3]);
if !["Get", "Is", "Has", "Can", "Num"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
let Some(obj) = made.get(table) else { continue };
let want = kind_alternatives(f[9]);
if want.is_empty() {
continue;
}
let probe = format!(
"if type({obj}.{name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({obj}:{name}()) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
let got: Vec<&str> = got.split(',').filter(|k| !k.is_empty()).collect();
if !want.iter().any(|alt| alt.len() == got.len()) {
continue;
}
checked += 1;
let agrees = want.iter().any(|alt| {
alt.len() == got.len()
&& alt
.iter()
.zip(&got)
.all(|(w, g)| slot_accepts(w.as_str(), g))
});
let observed = got.join(",");
if !agrees {
if let Some((_, known, _)) = WIDGET_KINDS_NOT_YET_ASSERTED
.iter()
.find(|(n, ..)| *n == name)
{
assert_eq!(
*known, observed,
"{name} is on the widget not-yet-asserted list at stale kinds — it now answers ({observed})"
);
continue;
}
mismatches.push(format!(
"{obj}:{name} answers ({observed}), reference states {}",
f[9]
));
}
}
assert!(
// 119 at 2142 — the same widening. The floor it replaces was 40.
checked >= 110,
"the widget kind gate measured only {checked} methods — it has stopped covering anything"
);
assert!(
mismatches.is_empty(),
"{} of {checked} probed widget methods answer values of the wrong kind:\n {}",
mismatches.len(),
mismatches.join("\n ")
);
}
/// Argument tuples the boolean gate tries, in order, until one call returns.
///
/// The kind gate probes a binding with nothing (or a unit token) and skips the ones that raise,
/// which leaves half the query surface unmeasured — the reference gates its arguments and raises
/// on a missing one (1834/1836). A *boolean* answer is wrong whatever the arguments are, so this
/// gate can afford a ladder the kind gate cannot: a wrong-but-plausible argument can change which
/// kind tuple comes back, and would make the kind gate cry wolf, but it can never make `1` into
/// `true`.
const ARG_LADDER: &[&str] = &["", "\"player\"", "1", "1, 1", "\"player\", 1"];
/// **1.12 has no boolean query.** Across all 1503 rows this table calls trustworthy, exactly two
/// slots are `boolean`: `IsPetAttackActive` and Lua's own `rawequal`. Everything else that reads
/// as a predicate — every `Is*`, `Can*`, `Has*` — is `(nil) | (number)`, because the client pushes
/// `1` and `nil`.
///
/// The distinction is invisible to `if x then` and decisive to everything else: `x == 1`,
/// `tostring(x)`, a value stored in a saved variable, arithmetic, a table key. `ColorPickerPlus`
/// writes `if IsShiftKeyDown() == 1 then` (decision 2118), and no gate we had could see it.
#[test]
fn no_query_binding_answers_a_lua_boolean() {
// The reference's own two, by name — the list may only shrink.
const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"];
let all = rows();
let mut s = benilla_ui::script::UiScript::new().expect("VM");
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Shapeprobe".into()),
level: 60,
..Default::default()
}),
);
s.run(KIND_HELPER).expect("kind helper");
let mut names: Vec<&str> = all
.iter()
.map(|r| r.name.as_str())
.filter(|n| {
["Get", "Is", "Has", "Can", "Unit", "Num"]
.iter()
.any(|p| n.starts_with(p))
})
.filter(|n| !REFERENCE_BOOLEANS.contains(n))
.collect();
names.sort_unstable();
names.dedup();
let mut checked = 0usize;
let mut booleans: Vec<String> = Vec::new();
for name in names {
for args in ARG_LADDER {
let probe = format!(
"if type({name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({name}({args})) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
checked += 1;
if got.split(',').any(|k| k == "boolean") {
booleans.push(format!("{name}({args}) answers ({got})"));
}
break;
}
}
assert!(
checked >= 300,
"the boolean gate measured only {checked} bindings — it has stopped covering anything"
);
assert!(
booleans.is_empty(),
"{} bindings answer a Lua boolean; 1.12 pushes 1/nil and only {REFERENCE_BOOLEANS:?} are \
boolean in the reference:\n {}",
booleans.len(),
booleans.join("\n ")
);
}
/// The widget half of [`no_query_binding_answers_a_lua_boolean`], and the reason it is worth having
/// separately from the widget *kind* gate: the kind gate calls each method with no arguments and
/// skips whatever raises, so a predicate that gates its argument — `HasScript("OnClick")`,
/// `IsObjectType("Frame")` — is never measured. The argument ladder reaches those, and `boolean`
/// stays wrong under every one of them.
#[test]
fn no_widget_method_answers_a_lua_boolean() {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-shapes.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-shapes.tsv");
let s = benilla_ui::script::UiScript::new().expect("VM");
s.run(KIND_HELPER).expect("kind helper");
let mut made: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
for (table, kind, name) in WIDGET_PROBES {
if s.run(&format!(
"{name} = CreateFrame(\"{kind}\", \"{name}\", UIParent)"
))
.is_ok()
{
made.insert(table, (*name).to_string());
}
}
for (table, expr) in REGION_PROBES {
let var = format!("PGR{}", made.len());
if s.run(&format!("{var} = {expr}")).is_ok() {
made.insert(table, var);
}
}
// A widget predicate's argument is a script or object-type NAME far more often than a number,
// so the ladder differs from the global one.
const WIDGET_ARGS: &[&str] = &["", "\"OnClick\"", "\"Frame\"", "1", "1, 1"];
let mut checked = 0usize;
let mut booleans: Vec<String> = Vec::new();
for line in text
.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("name\t"))
{
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 9 || f[4] != "widget" || f[8] != "exact" {
continue;
}
let (name, table) = (f[0], f[3]);
if !["Get", "Is", "Has", "Can", "Num"]
.iter()
.any(|p| name.starts_with(p))
{
continue;
}
// The reference's own boolean, and the only widget one: `IsPetAttackActive` is a global.
let Some(obj) = made.get(table) else { continue };
for args in WIDGET_ARGS {
let probe = format!(
"if type({obj}.{name}) ~= 'function' then return '?' end \
local ok, s = pcall(function() return PGKinds({obj}:{name}({args})) end) \
if not ok then return '?' end return s"
);
let Ok(got) = s.eval::<String>(&probe) else {
continue;
};
if got == "?" {
continue;
}
checked += 1;
if got.split(',').any(|k| k == "boolean") {
booleans.push(format!("{obj}:{name}({args}) answers ({got})"));
}
break;
}
}
assert!(
checked >= 100,
"the widget boolean gate measured only {checked} methods — it has stopped covering anything"
);
assert!(
booleans.is_empty(),
"{} widget methods answer a Lua boolean; every predicate row in the reference's widget \
tables is `(nil) | (number)`:\n {}",
booleans.len(),
booleans.join("\n ")
);
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
//! The shipped **Skills tab** (`assets/ui/SkillFrame.xml`) driven end-to-end, engine-only (no
//! Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
//! The stock **Skills tab** (`Interface\FrameXML\SkillFrame.xml`, off the player's chain since
//! 1956) driven end-to-end, engine-only (no Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
//! out of `character_tests.rs` (which owns the paperdoll + the tab round-trip) so the skills-pane
//! paint law has a home of its own.
//!
@@ -12,10 +12,9 @@
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
//! numberless, exactly as it does in the real client.
//!
//! **The window around the page is the reference's own since 1751** — `CharacterFrame.xml` and
//! `PaperDollFrame.xml` off the player's chain — so every test that opens the page loads
//! [`super::test_ui::CHARACTER_UI`] and opens with `wow_data_or_skip!()`. The page itself
//! (`assets/ui/SkillFrame.xml`) is still ours.
//! **The whole window is the reference's own** — `CharacterFrame.xml` and `PaperDollFrame.xml`
//! since 1751, the page itself since 1956 — so every test that opens the page loads
//! [`super::test_ui::CHARACTER_UI`] and opens with `wow_data_or_skip!()`.
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript, UnitState};
@@ -57,8 +56,18 @@ fn skill(
/// silently never got built (`SkillDetailScrollFrame` inherits `UIPanelScrollFrameTemplate`).
/// [`super::test_ui::load_ui_strict`] is that lesson made into a check.
fn shown_skills_page() -> UiScript {
shown_skills_page_with(None)
}
/// [`shown_skills_page`] with a text measurer seated BEFORE the files load — the state the app's
/// VM is in at world entry (the glue screens' frames seat it), which a stock `OnLoad` that measures
/// text relies on.
fn shown_skills_page_with(measurer: Option<Box<dyn benilla_ui::script::TextMeasure>>) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
if let Some(m) = measurer {
s.set_text_measurer(m);
}
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
@@ -201,7 +210,7 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
);
// Selecting the single-rank row paints the detail pane the same way (the shared PaintBar).
s.run("SetSelectedSkill(2) BenillaSkillFrame_Update()")
s.run("SetSelectedSkill(2) SkillFrame_UpdateSkills()")
.unwrap();
assert_eq!(
s.eval::<String>("return SkillDetailStatusBarSkillRank:GetText() or \"\"")
@@ -314,7 +323,7 @@ fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
}
// And the class lines still read as proficiencies despite their 300/300 descriptor.
assert_eq!(
s.eval::<i64>("return (select(7, GetSkillLineInfo(2)))")
s.eval::<i64>("local _,_,_,_,_,_,mx = GetSkillLineInfo(2) return mx")
.unwrap(),
1,
"Beast Mastery's skillMaxRank"
@@ -367,10 +376,11 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
// **"Close", not "CLOSE".** An XML `text=` attribute is a GlobalStrings LOOKUP, not a literal
// (`loader::Loader::resolve_text`, wow-re `rf28-typed-widget-loadxml.md` l.36 —
// `FrameScript_GetText 0x703bf0`), and `CLOSE = "Close"` (`GlobalStrings.lua:760`). This test
// read "CLOSE" while the page's list carried no `GlobalStrings.lua`: it was asserting our
// loader's miss-fallback (the LITERAL, a deliberate divergence for benilla-authored files),
// not the label the reference client draws. [`super::test_ui::CHARACTER_UI`] loads the
// player's own strings first, as the app does, so this is the real label now.
// read "CLOSE" while the page's list carried no `GlobalStrings.lua`: it was asserting the
// miss-fallback (the raw attribute), which is the REFERENCE's own arm too (`0x778c31` /
// `0x771032`), not the label a client with the strings loaded draws.
// [`super::test_ui::CHARACTER_UI`] loads the player's own strings first, as the app does, so
// this is the real label now.
let label = page_quads(&s)
.iter()
.find_map(|q| match &q.content {
@@ -458,30 +468,20 @@ fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The tab's **fit law** (ref `SkillFrameExpandButtonFrame`'s OnLoad, l.312-316:
/// `SetWidth(GetTextWidth()+45)`). benilla seats the VM's font engine at the frame boundary, so
/// the XML-load call reads 0 — the guard leaves the declared width standing, and the first Update
/// with a measurer fits the tab. Both states are pinned here because only the second one is the
/// reference's, and only the first is what a cold load sees.
/// The tab's **fit law** (ref `SkillFrameExpandButtonFrame`'s OnLoad:
/// `SetWidth(SkillFrameCollapseAllButton:GetTextWidth()+45)`), which the reference applies ONCE,
/// at load, with its font engine already up. The app's VM is in that state at world entry (the
/// glue screens seat the measurer on it), so the test seats one first; the transcription used to
/// guard a 0 measure and re-fit on the first Update, which the reference never does (1956).
#[test]
fn the_expand_tab_fits_its_label_once_a_measure_answers() {
fn the_expand_tab_fits_its_label_at_load() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_skills_page();
assert_eq!(
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
.unwrap(),
54.0,
"unmeasured, the declared width stands — a 0 measure must not squash the tab to 45"
);
s.set_text_measurer(Box::new(super::FixedWidthFont(7.0)));
s.run("BenillaSkillFrame_Update()").unwrap();
s.resolve();
let mut s = shown_skills_page_with(Some(Box::new(super::FixedWidthFont(7.0))));
assert_eq!(
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
.unwrap(),
7.0 * 3.0 + 45.0,
"then the ref's own law: the label's width + 45"
"the ref's own law at load: the ALL label's width + 45"
);
// The middle slab is the span between the two caps, so the fit reaches the art for free.
let (mid_l, mid_r, cap_r_l) = s
@@ -496,5 +496,150 @@ fn the_expand_tab_fits_its_label_once_a_measure_answers() {
66.0 - 16.0,
"and carries the whole span minus the two caps"
);
// An Update leaves the fit alone — the reference sizes the tab at load and never again.
s.run("SkillFrame_UpdateSkills()").unwrap();
s.resolve();
assert_eq!(
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
.unwrap(),
66.0
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **B370 — the list reaches its last rows.** MarcusAga's Skills tab stopped three rows short of
/// the end: the knob mid-track, Maces the last row shown. The reference's own
/// `FauxScrollFrame_Update` sizes the bar to `(n − 12) × 15` and the scroll child to `n × 15`, and
/// `SkillListScrollFrame` is 220 tall (stock `SkillFrame.xml` l.468) against twelve rows of
/// fifteen — so the child's overflow past the frame, `n × 15 − 220`, is forty pixels short of
/// where the bar goes. An engine that clamped `SetVerticalScroll` into that overflow stopped the
/// row offset at `n − 15`; the reference stores the bar's value as given (decision 2017).
///
/// Drives the bar to its end and reads the twelfth row: the block's last line. The control is the
/// pre-fix mechanism itself — the overflow really is shorter than the bar's range.
#[test]
fn the_list_reaches_its_last_row_at_the_bars_end() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 60,
race: Some("Human".into()),
race_file: Some("Human".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
..UnitState::default()
}),
);
// A warrior's kind of block — twenty-two lines under three headers, twenty-five rows: more than
// twelve by more than the frame's slack, so the tail is only reachable past the overflow.
let mut entries = Vec::new();
for (i, name) in [
"Axes",
"Bows",
"Crossbows",
"Daggers",
"Defense",
"Guns",
"Maces",
"Polearms",
"Staves",
"Swords",
"Thrown",
"Two-Handed Axes",
"Two-Handed Maces",
"Two-Handed Swords",
"Unarmed",
]
.iter()
.enumerate()
{
entries.push(skill(
100 + i as u32,
name,
300,
300,
false,
(6, "Weapon Skills", 5),
));
}
for (i, name) in ["Cloth", "Leather", "Mail", "Plate", "Shield"]
.iter()
.enumerate()
{
entries.push(skill(
400 + i as u32,
name,
1,
1,
false,
(8, "Armor Proficiencies", 6),
));
}
for (i, name) in ["Language: Common", "Language: Dwarven"].iter().enumerate() {
entries.push(skill(
500 + i as u32,
name,
300,
300,
false,
(10, "Languages", 8),
));
}
s.set_skills(SkillsState { entries });
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let n = s.eval::<i64>("return GetNumSkillLines()").unwrap();
assert_eq!(n, 25, "22 lines under 3 headers");
let (last_name, last_is_header) = s
.eval::<(String, Option<i64>)>(&format!("local n, h = GetSkillLineInfo({n}) return n, h"))
.unwrap();
assert!(
last_is_header.is_none(),
"the tail row is a line, read off SkillRankFrame12"
);
// The reference's own numbers: the bar runs to (n − 12) × 15, the child's overflow past the
// 220-tall frame is 40 px less — the control that the reported mechanism is the real one.
let (_, bar_max) = s
.eval::<(f64, f64)>("return SkillListScrollFrameScrollBar:GetMinMaxValues()")
.unwrap();
assert_eq!(bar_max, f64::from((n as i32 - 12) * 15));
let overflow = s
.eval::<f64>("return SkillListScrollFrame:GetVerticalScrollRange()")
.unwrap();
assert_eq!(overflow, f64::from(n as i32 * 15 - 220));
assert!(
overflow < bar_max,
"the frame is taller than its twelve rows"
);
// The knob dragged to the end: bar → SetVerticalScroll → <OnVerticalScroll> →
// FauxScrollFrame_OnVerticalScroll → SkillFrame_UpdateSkills.
s.run(
"local _, hi = SkillListScrollFrameScrollBar:GetMinMaxValues() \
SkillListScrollFrameScrollBar:SetValue(hi)",
)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(SkillListScrollFrame)")
.unwrap(),
n - 12,
"the row offset reaches the bar's end, not the overflow's"
);
assert_eq!(
s.eval::<String>("return SkillRankFrame12SkillName:GetText()")
.unwrap(),
last_name,
"the twelfth row is the block's last line"
);
}
@@ -1,7 +1,7 @@
//! The shipped **spellbook window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/SpellBookFrame.xml` loaded behind `Fonts.xml`/`UiPanels.xml`/`GameTooltip.xml`
//! (plus `ActionBar.xml` for the cross-window place test) and fed a small synthetic book —
//! mirroring `character_tests.rs`'s/`action_bar_tests.rs`'s harness (decision 0216 §8, slice 5).
//! `Interface\FrameXML\SpellBookFrame.xml` loaded behind
//! `Fonts.xml`/`UIParent.xml`/`GameTooltip.xml` (plus `ActionBar.xml` for the cross-window place
//! test) and fed a small synthetic book — mirroring `character_tests.rs`'s/`action_bar_tests.rs`'s harness (decision 0216 §8, slice 5).
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
@@ -80,34 +80,83 @@ fn click(s: &mut UiScript, name: &str, button: &str) {
s.mouse_button(x, y, button, false);
}
/// The reference's spellbook needs the action-bar chain beneath it: `SpellBookFrame_OnShow` and
/// `_OnHide` call `MultiActionBar_ShowAllGrids`/`HideAllGrids` (MultiActionBars.lua, whose file
/// wants the options window's uvars), `OnShow` calls `UpdateMicroButtons`, and the window docks
/// in UiPanels' left slot. One chain, in the manifest's order, for every test that opens it.
pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(w, h);
for f in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, f);
}
s
}
/// The loader itself: every file the window depends on parses and materializes with no errors —
/// the window + close + prev/next page buttons + 12 spell buttons (each with a Cooldown child)
/// + 8 skill-line tabs + the 3 Spell/Pet toggle tabs.
/// the window + close + prev/next page buttons + 12 spell buttons (each with a Cooldown and an
/// AutoCast Model child) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame.
#[test]
fn shipped_spellbook_loads_clean() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/SpellBookFrame.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// The reference's own file, off the chain, with the one adapter it needs from this engine.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
let frames = load_xml(&s, "Interface\\FrameXML\\SpellBookFrame.xml");
load_xml(&s, "SpellBookAdapters.xml");
assert!(s.errors().is_empty(), "loader errors: {:?}", s.errors());
assert_eq!(
report.frames, 39,
"window + close + prev/next + 12 spell buttons (each with a Cooldown child) + 8 \
skill-line tabs + the 3 Spell/Pet toggle tabs (decision 1032)"
frames, 52,
"window + close + prev/next + 12 spell buttons (each with its Cooldown and AutoCast \
Model children) + 8 skill-line tabs + the 3 Spell/Pet toggle tabs + the tab flash frame"
);
for name in ["SpellBookFrame", "SpellButton12", "SpellButton1AutoCast"] {
assert!(
s.eval::<bool>(&format!("return {name} ~= nil")).unwrap(),
"{name} exists"
);
}
}
/// The whole contract in one end-to-end drive: `ToggleSpellBook` (the 'P' binding's entry point)
@@ -117,17 +166,7 @@ fn shipped_spellbook_loads_clean() {
/// bar-to-bar drag uses — packing kind 0x00 (SPELL) with the spell id.
#[test]
fn shipped_spellbook_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "SpellBookFrame.xml");
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
@@ -194,6 +233,14 @@ fn shipped_spellbook_drives_end_to_end() {
// (`cursor::bar::place_action`) a bar-to-bar drag uses: a plain click on an action button
// routes through UseAction's checkCursor=1 fork to a place. Packs kind 0x00 (SPELL, decision
// 0216 §1) with the spell id — `action_sets` is the app's own CMSG_SET_ACTION_BUTTON queue.
// An EMPTY main-bar slot is hidden under the reference (ActionButton.lua:69-70) until a held
// payload opens the grid: the engine derives ACTIONBAR_SHOWGRID from the cursor's edge and
// fires it on the next tick, as the app ticks between any two mouse events.
s.tick(0.016);
assert!(
s.eval::<bool>("return ActionButton1:IsVisible()").unwrap(),
"the held spell opened the empty well"
);
let (ax, ay) = center(&s, "ActionButton1");
s.mouse_button(ax, ay, "LeftButton", true);
s.mouse_button(ax, ay, "LeftButton", false);
@@ -211,20 +258,7 @@ fn shipped_spellbook_drives_end_to_end() {
fn shipped_spellbook_shows_the_cooldown_pie() {
use benilla_ui::script::QuadContent;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Cooldown.xml",
"SpellBookFrame.xml",
] {
load_xml(&s, f);
}
let mut s = spellbook_ui(1024.0, 768.0);
s.set_spellbook(book());
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(10.0); // GetTime = 10
@@ -234,23 +268,19 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
b.slots[0].cooldown = Some((6_000, 10_000, true));
s.set_spellbook(b);
s.fire_event("SPELL_UPDATE_COOLDOWN", vec![]);
// The stock machine (decision 2019): sequence 0 armed by `CooldownFrame_SetTimer`, scrubbed
// by the next paint's `OnUpdateModel`.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let sweep = s.extract().into_iter().find_map(|q| {
if s.quad_owner_name(q.target).as_deref() != Some("SpellButton1Cooldown") {
return None;
}
match q.content {
QuadContent::Cooldown { fraction, flash } => Some((fraction, flash)),
_ => None,
}
});
let (fraction, flash) = sweep.expect("SpellButton1's Cooldown widget is showing");
assert!(
(fraction - 0.4).abs() < 1e-3,
"4 s elapsed of 10 ⇒ the sweep sits at 40%, got {fraction}"
let play = super::test_ui::cooldown_play(&s, "SpellButton1Cooldown")
.expect("SpellButton1's cooldown pane is showing");
assert_eq!(
play,
(0, 400),
"4 s elapsed of 10 ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
);
assert_eq!(flash, None);
// An on-hold triple: no sweep (CooldownFrame_SetTimer's enable gate), the icon dims to 40%.
let mut b = book();
@@ -283,24 +313,33 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The empty-slot LOOK, pinned at the quad level (the regression that shipped with slice 5): a
/// slot past the book's length is a **disabled** SpellButton with no DisabledTexture and an
/// unchecked ring — so it draws its UI-Spellbook-SpellBackground square and **nothing else**. The
/// slice-5 build leaked both the UI-Quickslot2 NormalTexture (disabled wrongly fell back to
/// Normal) and the CheckButtonHilight ring (the reference's `SetChecked(0)` was read Lua-truthy),
/// putting a gold ring on all 12 slots — the director's "spellbook looks very wrong".
/// The empty-slot LOOK, pinned at the quad level: a slot past the book's length is a **disabled**
/// SpellButton, and it draws its `UI-Spellbook-SpellBackground` square **and its `UI-Quickslot2`
/// socket ring** — but no `CheckButtonHilight` glow.
///
/// **The ring was wrong to remove, and this test asserted the wrong half for a year** (decision
/// 2011, correcting 0227's second finding). 0227 read the empty slot as a *born-disabled* button
/// whose Normal texture had therefore never been shown — but stock `SpellButton_UpdateButton`
/// `Disable()`s a button that was created enabled and has been wearing its ring since LoadXML
/// (l.328, and the `CSimpleButton` ctor `0x7786a0` ends in `SetState(NORMAL)`), so the shown
/// pointer `+0x4c4` is already on the ring when the disable arrives and `SetState 0x779790` has
/// no step that takes it off. The reference's own Lua is the tell: the disable branch resets that
/// very ring's vertex colour to white (l.337), which is only meaningful on a ring that draws.
///
/// What the slice-5 build really got wrong was the OTHER ring — the `CheckButtonHilight` glow on
/// all 12 slots, from reading the reference's `SetChecked(0)` as Lua-truthy (0227's first
/// finding, which stands). That is the half this still pins.
#[test]
fn shipped_spellbook_empty_slot_draws_only_the_background() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(640.0, 700.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "SpellBookFrame.xml");
// An EMPTY book: every slot takes the `id > offset + numSpells` disable path.
fn shipped_spellbook_empty_slot_draws_its_background_and_socket_ring() {
let mut s = spellbook_ui(640.0, 700.0);
// A one-spell book: slot 5 takes the reference's `id > offset + numSpells` disable path. (A
// book with NO spells is a state no character is ever in — the reference's own
// `ToggleSpellBook` clamps the page to 0 for it and every button id goes negative.)
let mut b = book();
b.tabs.truncate(1);
b.tabs[0].num_spells = 1;
b.slots.truncate(1);
s.set_spellbook(b);
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(0.05);
s.resolve();
@@ -316,8 +355,11 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
}
assert_eq!(
slot5_paths,
vec!["Interface\\Spellbook\\UI-Spellbook-SpellBackground".to_string()],
"an empty slot draws its background square and nothing else (no ring, no checked glow)"
vec![
"Interface\\Spellbook\\UI-Spellbook-SpellBackground".to_string(),
"Interface\\Buttons\\UI-Quickslot2".to_string(),
],
"an empty slot keeps its socket ring and gains no checked glow"
);
// The reference passes SetChecked(0) — numeric coercion, not Lua truthiness.
assert!(!s
@@ -329,9 +371,7 @@ fn shipped_spellbook_empty_slot_draws_only_the_background() {
/// l.132/134/268/296-303/336): 1/"true"/true check; 0/"false"/nil/non-numeric strings uncheck.
#[test]
fn set_checked_uses_blizzard_bool_coercion() {
let s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "SpellBookFrame.xml");
let s = spellbook_ui(1024.0, 768.0);
for (arg, want) in [
("1", true),
("0", false),
@@ -398,16 +438,7 @@ fn pet_book() -> benilla_ui::script::PetBookState {
/// the class token's label, and a right-click flips autocast instead of casting.
#[test]
fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "SpellBookFrame.xml");
let mut s = spellbook_ui(1024.0, 768.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
@@ -480,13 +511,13 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.unwrap(),
"a passive is not autocastable"
);
// …and the shine marker follows the SECOND (is it on) — the native lane (decision 1383)
// draws the sparkle wherever a shown marker sits, so shown-ness IS the enable.
// …and the shine MODEL follows the SECOND (is it on) — the stock `$parentAutoCast`, whose
// file the tile renderer draws (2013/2014), so shown-ness IS the enable.
assert!(s
.eval::<bool>("return SpellButton1Shine:IsVisible()")
.eval::<bool>("return SpellButton1AutoCast:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return SpellButton3Shine:IsVisible()")
.eval::<bool>("return SpellButton3AutoCast:IsVisible()")
.unwrap());
// The corner brackets' RECT, through the live widget. Asserted on the resolved rect rather
// than the XML, so an anchor bug between the two is still caught — and CENTERED with no
@@ -516,29 +547,35 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
br[3]
);
// The marker's RECT: 1391 gave it the ref's own 36x36 at CENTER (1,1); 1393 squares it on the
// button instead, so the glow and the brackets share a centre. Checked here rather than
// trusted to the XML, because the whole spell-book thread turns on where this viewport sits.
let geom: Vec<f32> = ["GetWidth", "GetHeight"]
// The pane's RECT: the ref's own template gives it 36x36 at CENTER (1,1); 1393 squares it on
// the button instead (now by re-seating the stock Model, 2014), so the glow and the brackets
// share a centre. Checked here rather than trusted to the XML, because the whole spell-book
// thread turns on where this viewport sits.
let geom: Vec<f32> = ["GetWidth", "GetHeight", "GetModelScale"]
.iter()
.map(|m| {
s.eval::<f32>(&format!("return SpellButton1Shine:{m}()"))
s.eval::<f32>(&format!("return SpellButton1AutoCast:{m}()"))
.unwrap()
})
.collect();
assert!(
(geom[0] - 37.0).abs() < 0.01 && (geom[1] - 37.0).abs() < 0.01,
"shine marker is {geom:?}, expected 37x37 (1393 squares it on the button)"
"shine pane is {geom:?}, expected 37x37 (1393 squares it on the button)"
);
assert!(
(geom[2] - 1.48).abs() < 0.001,
"shine pane's model scale is {}, expected 1393's 1.48 (the pet button's rim ratio)",
geom[2]
);
let dx = s
.eval::<f32>("return SpellButton1Shine:GetLeft() - SpellButton1:GetLeft()")
.eval::<f32>("return SpellButton1AutoCast:GetLeft() - SpellButton1:GetLeft()")
.unwrap();
let dy = s
.eval::<f32>("return SpellButton1Shine:GetBottom() - SpellButton1:GetBottom()")
.eval::<f32>("return SpellButton1AutoCast:GetBottom() - SpellButton1:GetBottom()")
.unwrap();
assert!(
dx.abs() < 0.01 && dy.abs() < 0.01,
"shine marker sits at ({dx}, {dy}) inside the button; 1393 squares it on the button so it \
"shine pane sits at ({dx}, {dy}) inside the button; 1393 squares it on the button so it \
is concentric with the brackets — the ref's +1,+1 is what read as a top/right bias"
);
@@ -656,14 +693,16 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
);
for file in [
r"Interface\FrameXML\GlobalStrings.lua",
"Fonts.xml",
"BasicControls.xml", // `TEXT`
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// `ShowMacroFrame` lives here since 1848.
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\Cooldown.xml",
// **ScrollTemplates BEFORE UIPanelTemplates, which is the manifest's own order.** Ours
// still carries dead `FauxScrollFrame_*` copies that the chain overrides by loading after
// (1846's step 3, deliberately not done); load them the other way round and OUR copies win
@@ -673,13 +712,38 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
r"Interface\FrameXML\UIPanelTemplates.xml",
// The icon chooser's scroll frame inherits `ClassTrainerListScrollFrameTemplate`.
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
"MicroMenu.xml",
"ActionBar.xml",
r"Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml",
"SpellBookFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
// The reference's spellbook shows and hides the multibar grids (MultiActionBars.lua),
// whose file wants the options window's uvars — the same tail `spellbook_ui` carries.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
"Interface\\FrameXML\\SpellBookFrame.xml",
"SpellBookAdapters.xml",
] {
load_xml(&s, file);
}
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `MacroFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_MacroUI");
s.run("MacroFrame_LoadUI()").unwrap();
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The file's own book plus a third Fire spell that is PASSIVE — book id 3, which the ref's
@@ -1,7 +1,7 @@
//! The shared StaticPopup engine (decision 0308 §3 — the ref's registry + Show + OnUpdate
//! machinery, transcribed in UiPanels.xml): the countdown/StartDelay/cancels/ESC laws the death
//! arc's dialogs ride. Entries here are inline test dialogs — the real entries (DELETE_ITEM,
//! ABANDON_QUEST, the death family) are covered by their features' own tests.
//! machinery, stock `Interface\FrameXML\StaticPopup.xml` since 1988): the
//! countdown/StartDelay/cancels/ESC laws the death arc's dialogs ride. Entries here are inline test
//! dialogs — the real entries (DELETE_ITEM, ABANDON_QUEST, the death family) are covered by their features' own tests.
use benilla_ui::script::UiScript;
@@ -10,11 +10,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s
}
@@ -78,7 +83,7 @@ fn start_delay_gates_button1_then_swaps_the_text_in() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"2 seconds until resurrection",
"2 Seconds until resurrection",
"the delayText countdown renders (ceil of 1.5s)"
);
s.tick(1.6);
@@ -181,14 +186,14 @@ fn the_death_countdown_text_rerenders_each_tick() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"2 minutes until release",
"2 Minutes until release",
"above 60s renders ceil-minutes"
);
s.tick(31.0);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"59 seconds until release",
"59 Seconds until release",
"below 60s renders seconds"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -258,3 +263,69 @@ fn hide_and_find_address_one_instance_by_data_only_for_a_multiple_dialog() {
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The dialog engine's verbs (decision 1963): the five UIParent arms the feeds behind them now
/// reach — each verbatim from UIParent.lua, each raising a stock dialog whose Accept calls a
/// binding this engine answers.
#[test]
fn the_verb_dialogs_open_from_their_events_and_call_their_verbs() {
use benilla_ui::script::ScriptValue;
let mut s = setup();
load_xml(&s, r"Interface\FrameXML\UIParent.xml"); // the arms
s.set_money(50_000);
// The pet trainer's question: the dialog, its money frame at the cost, Accept → the confirm.
s.fire_event("CONFIRM_PET_UNLEARN", vec![ScriptValue::Int(12_345)]);
s.tick(0.0);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
"CONFIRM_PET_UNLEARN"
);
assert_eq!(
s.eval::<f64>("return StaticPopup1MoneyFrame.staticMoney")
.unwrap(),
12_345.0
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(s.take_pet_unlearn_confirms(), 1);
// The instance boot: START shows, STOP hides.
s.set_instance_boot_secs(30);
s.fire_event("INSTANCE_BOOT_START", vec![]);
s.tick(0.0);
assert!(s
.eval::<bool>("return StaticPopup_Visible(\"INSTANCE_BOOT\") ~= nil")
.unwrap());
s.fire_event("INSTANCE_BOOT_STOP", vec![]);
s.tick(0.0);
assert!(!s
.eval::<bool>("return StaticPopup_Visible(\"INSTANCE_BOOT\") ~= nil")
.unwrap());
// The area spirit healer: in range shows, out of range hides; Accept queues the heal.
s.set_area_spirit_healer(true, 20);
s.fire_event("AREA_SPIRIT_HEALER_IN_RANGE", vec![]);
s.tick(0.0);
assert!(s
.eval::<bool>("return StaticPopup_Visible(\"AREA_SPIRIT_HEAL\") ~= nil")
.unwrap());
// **The reference's dialog accepts on SHOW and its one button CANCELS** — the 1.12 file keeps
// the old two-button version commented out and ships the auto-accepting one
// (`StaticPopup.lua:1204-1240`): `OnShow` calls `AcceptAreaSpiritHeal()`, `button1` is CANCEL,
// and its `OnAccept` calls `CancelAreaSpiritHeal()`. So the accept is already in by the time
// the popup is on screen (1988 — our retired UIParent arm showed it without that OnShow).
assert!(
s.take_area_spirit_accepts() >= 1,
"showing the dialog IS the accept"
);
s.run("StaticPopup_OnClick(StaticPopup_FindVisible(\"AREA_SPIRIT_HEAL\"), 1)")
.unwrap();
assert_eq!(
s.take_area_spirit_accepts(),
0,
"the button cancels; it does not accept again"
);
s.fire_event("AREA_SPIRIT_HEALER_OUT_OF_RANGE", vec![]);
s.tick(0.0);
assert!(!s
.eval::<bool>("return StaticPopup_Visible(\"AREA_SPIRIT_HEAL\") ~= nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -26,12 +26,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "ConfirmSummon.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
area: "Stormwind City".into(),
@@ -66,7 +70,7 @@ fn the_confirm_names_the_summoner_and_accept_queues_the_response() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Stormwind City. The spell will be cancelled in 2 minutes.",
"Twomage wants to summon you to Stormwind City. The spell will be cancelled in 2 Minutes.",
"the tick composes summoner + area + the count and its unit word"
);
@@ -93,7 +97,7 @@ fn the_countdown_line_switches_to_seconds_and_singularises() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Elwynn Forest. The spell will be cancelled in 45 seconds."
"Twomage wants to summon you to Elwynn Forest. The spell will be cancelled in 45 Seconds."
);
// OnShow seeded 45 and the first tick spent 0.1 of it, so 44.9 stands; spending 44.2 more
@@ -103,7 +107,7 @@ fn the_countdown_line_switches_to_seconds_and_singularises() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Elwynn Forest. The spell will be cancelled in 1 second."
"Twomage wants to summon you to Elwynn Forest. The spell will be cancelled in 1 Second."
);
}
@@ -124,7 +128,7 @@ fn a_summoner_whose_name_is_still_resolving_fills_in_on_a_later_tick() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
" wants to summon you to Stormwind City. The spell will be cancelled in 30 seconds.",
" wants to summon you to Stormwind City. The spell will be cancelled in 30 Seconds.",
"no name yet: a blank, not a raise and not a withheld dialog"
);
@@ -137,7 +141,7 @@ fn a_summoner_whose_name_is_still_resolving_fills_in_on_a_later_tick() {
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Twomage wants to summon you to Stormwind City. The spell will be cancelled in 30 seconds.",
"Twomage wants to summon you to Stormwind City. The spell will be cancelled in 30 Seconds.",
"the name query landed; the same open dialog picks it up"
);
}
@@ -0,0 +1,163 @@
//! The guild tabard designer — the stock `TabardFrame.xml` off the player's chain (decision
//! 1977) in a bare engine: the wire's open event seeds and shows the window, the customization
//! rows cycle the design, the emblem cells wear the mask token, the Save button follows
//! `CanSaveTabardNow`, and the cancel path closes through `CloseTabardCreation`.
use benilla_ui::script::{
emblem_mask_path, TabardHost, TabardIntent, UiScript, UnitGuild, UnitState,
};
/// The kit, with the player in a guild at `rank` (0 = the master) — `GetGuildInfo("player")`
/// reads the unit model's own guild tag.
fn harness(rank: u32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for (token, name, guild) in [
(
"player",
"Probefour",
Some(UnitGuild {
name: "Probe Guild".into(),
rank_name: if rank == 0 { "Guild Master" } else { "Member" }.into(),
rank_index: rank,
}),
),
("npc", "Tabard Vendor", None),
] {
s.set_unit(
token,
Some(UnitState {
exists: true,
name: Some(name.into()),
level: 60,
guild,
..Default::default()
}),
);
}
for file in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\TabardFrame.xml",
] {
super::test_ui::load_ui(&s, file);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// The reference's open path: `MSG_TABARDVENDOR_ACTIVATE` → `OPEN_TABARD_FRAME`. The stock
/// handler seeds the design off the guild record, paints the four emblem cells, sets the
/// greeting and the Save button by the guild rank, and shows the window through the panel
/// manager.
#[test]
fn the_open_event_seeds_the_design_paints_the_cells_and_shows_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness(0);
s.set_tabard_host(TabardHost {
guild_record: Some([12, 4, 1, 2, 9]),
save_pending: false,
});
s.fire_event("OPEN_TABARD_FRAME", vec![]);
s.resolve();
assert!(s.eval::<bool>("return TabardFrame:IsVisible()").unwrap());
assert_eq!(s.tabard_design(), Some([12, 4, 1, 2, 9]));
assert_eq!(
s.eval::<String>("return TabardFrameNameText:GetText()")
.unwrap(),
"Tabard Vendor"
);
let cell = s
.eval::<String>("return TabardFrameEmblemTopLeft:GetTexture()")
.unwrap();
assert_eq!(
emblem_mask_path(&cell),
Some(r"Textures\GuildEmblems\Emblem_12_04_TU_U")
);
let cell = s
.eval::<String>("return TabardFrameEmblemBottomRight:GetTexture()")
.unwrap();
assert!(cell.ends_with("Emblem_12_04_TL_U"), "{cell}");
assert!(
s.eval::<bool>("return TabardFrameAcceptButton:IsEnabled() == 1")
.unwrap(),
"a guild master with a cached record can save"
);
assert_eq!(
s.eval::<String>("return TabardFrameGreetingText:GetText()")
.unwrap(),
s.eval::<String>("return TABARDVENDORGREETING").unwrap()
);
// The customization rows: the first row's right arrow cycles the emblem style and repaints.
s.run("TabardCustomization_Right(1)").unwrap();
assert_eq!(s.tabard_design(), Some([13, 4, 1, 2, 9]));
let cell = s
.eval::<String>("return TabardFrameEmblemTopRight:GetTexture()")
.unwrap();
assert!(cell.ends_with("Emblem_13_04_TU_U"), "{cell}");
s.run("TabardCustomization_Left(5) TabardCustomization_Left(5)")
.unwrap();
assert_eq!(s.tabard_design().unwrap()[4], 7);
// Save: the intent carries the five; the pending latch greys the button through the event.
s.run("TabardFrameAcceptButton:Click()").unwrap();
assert_eq!(
s.take_tabard_intents(),
vec![TabardIntent::Save([13, 4, 1, 2, 7])]
);
s.set_tabard_host(TabardHost {
guild_record: Some([12, 4, 1, 2, 9]),
save_pending: true,
});
s.fire_event("TABARD_SAVE_PENDING", vec![]);
assert!(
!s.eval::<bool>("return TabardFrameAcceptButton:IsEnabled() == 1")
.unwrap(),
"the latch greys the button"
);
// Cancel closes through the panel manager; the OnHide calls CloseTabardCreation.
s.run("TabardFrameCancelButton:Click()").unwrap();
s.resolve();
assert!(!s.eval::<bool>("return TabardFrame:IsVisible()").unwrap());
assert_eq!(s.take_tabard_intents(), vec![TabardIntent::Close]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A non-master, or a player with no guild, gets the no-guild greeting and a dead Save button —
/// the stock `TabardFrame_UpdateButtons` law over `GetGuildInfo("player")`.
#[test]
fn a_non_master_gets_the_no_guild_greeting_and_no_save() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness(3);
s.set_tabard_host(TabardHost {
guild_record: Some([-1; 5]),
save_pending: false,
});
s.fire_event("OPEN_TABARD_FRAME", vec![]);
assert!(!s
.eval::<bool>("return TabardFrameAcceptButton:IsEnabled() == 1")
.unwrap());
assert_eq!(
s.eval::<String>("return TabardFrameGreetingText:GetText()")
.unwrap(),
s.eval::<String>("return TABARDVENDORNOGUILDGREETING")
.unwrap()
);
// The close event from the engine side hides the window.
s.fire_event("CLOSE_TABARD_FRAME", vec![]);
s.resolve();
assert!(!s.eval::<bool>("return TabardFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -12,12 +12,16 @@ use super::test_ui::load_ui as load_xml;
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "TalentWipeConfirm.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
s.set_talent_master_pending(true);
s
}
@@ -19,13 +19,24 @@ use super::test_ui::load_ui as load_xml;
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the dropdown kit's MenuBackdrop)
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the dropdown kit's MenuBackdrop)
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the unit popups' kit (TargetFrameDropDown's template)
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); // BENILLA_FALLBACK_ICON
// Before UnitPopup: that file reads ITEM_QUALITY_COLORS at FILE SCOPE, and its
// declarer is UIParent (ref UIParent.lua:65) since 1888.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
+20 -17
View File
@@ -1,10 +1,10 @@
//! The shipped taxi-map window driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/TaxiFrame.xml` fed a synthetic two-node snapshot (the [`crate::ui_taxi`] feed's exact
//! output shape). Covers what only a runtime load exercises: the Lua parses and every referenced
//! global resolves (including the static node-button pool and the runtime-created route-line
//! textures — TaxiFrame.xml's own header note on both), `TAXIMAP_OPENED` shows the window with the
//! flight master's name and paints the node buttons at their pushed positions, a click on a node
//! drains through `TakeTaxiNode`, and `TAXIMAP_CLOSED` hides it.
//! `Interface\FrameXML\TaxiFrame.xml` fed a synthetic two-node snapshot (the [`crate::ui_taxi`]
//! feed's exact output shape). Covers what only a runtime load exercises: the Lua parses and every
//! referenced global resolves (including the static node-button pool and the runtime-created
//! route-line textures — TaxiFrame.xml's own header note on both), `TAXIMAP_OPENED` shows the
//! window with the flight master's name and paints the node buttons at their pushed positions, a
//! click on a node drains through `TakeTaxiNode`, and `TAXIMAP_CLOSED` hides it.
use benilla_ui::script::{ScriptValue, TaxiNodeType, TaxiUiNode, TaxiUiState, UiScript};
@@ -14,18 +14,22 @@ use super::test_ui::load_ui as load_xml;
fn taxi_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // BenillaErrorsFrame_AddMessage — DrawOneHopLines' refusal
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip + SetTooltipMoney
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml"); // `UIErrorsFrame:AddMessage` — DrawOneHopLines' refusal
// Three the reference's own TaxiFrame leans on that our transcription did not:
// · GlobalStrings — `ERR_TAXINOPATHS` is a GlobalString, and `AddMessage(nil)` draws an
// empty line rather than raising, so its absence is silent.
// · UIPanelTemplates (.lua then .xml) — `TaxiCloseButton` inherits `UIPanelCloseButton`,
// which lives there and NOT in our UiPanels.xml. Without it the close button loads as a
// which the chain declares there. Without it the close button loads as a
// bare Button with no handler and a click does nothing at all.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\TaxiFrame.xml");
@@ -124,10 +128,9 @@ fn shipped_taxi_frame_drives_end_to_end() {
/// The "no single-hop destination" refusal (ref `DrawOneHopLines`, fired from `OnShow`): a map
/// with only the `Current` node (no `Reachable` neighbor at all) hits `numSingleHops == 0` and
/// calls `BenillaErrorsFrame_AddMessage` (TaxiFrame.xml's deviation 3 from the reference's
/// `UIErrorsFrame:AddMessage`) then hides the window — verified end-to-end since this is the one
/// call this engine has no precedent for outside this window (`ErrorsFrame.xml`'s own Lua seam,
/// not yet exercised from another shipped window's script).
/// calls `UIErrorsFrame:AddMessage(ERR_TAXINOPATHS, …)` (stock `TaxiFrame.lua:161-162`) then
/// hides the window — driven end-to-end because the message frame's Lua seam is what the
/// refusal is visible through.
#[test]
fn no_single_hop_destination_posts_the_error_and_closes() {
let mut s = taxi_script();
+567 -46
View File
@@ -16,7 +16,7 @@
//! catch a mistake in a file somebody just edited, and `content::read`'s dev-build probe already
//! prefers disk for the same reason.
use benilla_ui::script::UiScript;
use benilla_ui::script::{QuadContent, UiScript};
/// Load one interface file into `s`, panicking on any loader error, and return how many frames it
/// materialized (`0` for a `.lua` entry, which materializes none).
@@ -27,10 +27,75 @@ use benilla_ui::script::UiScript;
///
/// **A chain entry needs client data**, so a test that names one has to open with
/// `benilla_formats::wow_data_or_skip!()`; [`BAG_UI`] is a list that always does.
pub(super) fn load_ui(s: &UiScript, entry: &str) -> usize {
pub(crate) fn load_ui(s: &UiScript, entry: &str) -> usize {
load_entry(s, entry, false, false)
}
/// The cooldown indicator's file: `Interface\Cooldown\UI-Cooldown-Indicator.mdx`, as the
/// stock `CooldownFrameTemplate` names it.
pub(super) const COOLDOWN_MODEL: &str = r"Interface\Cooldown\UI-Cooldown-Indicator.mdx";
/// The cooldown indicator's file facts (`benilla-extract m2seq`): sequence 0 = id 0, 1000 ms,
/// clamp — the sweep `CooldownFrame_OnUpdateModel` scrubs; sequence 1 = id 1, 1000 ms, clamp —
/// the finish flash, whose completion hides the frame. Handed to the engine the way the app does
/// once the asset lands (decisions 2007/2019); every pane holding the file arms its Stand.
pub(super) fn cooldown_facts(s: &mut UiScript) {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
let seq = |anim_id, duration_ms| SequenceFacts {
anim_id,
duration_ms,
looping: false,
};
s.set_model_facts(
COOLDOWN_MODEL,
ModelFileFacts {
sequences: vec![seq(0, 1000), seq(1, 1000)],
bbox: ([0.0; 3], [0.0; 3]),
cameras: 0,
},
);
}
/// The play head `(anim_id, cursor_ms)` of the SHOWN cooldown pane `owner` names (`None` while
/// it is hidden, or has nothing armed) — what the tile renderer samples the file at. Read the way
/// the renderer reads it: the pane's quad in the extract joined to the engine's paint list.
pub(super) fn cooldown_play(s: &UiScript, owner: &str) -> Option<(u16, u32)> {
let heads = s.visible_model_panes();
s.extract().into_iter().find_map(|q| match &q.content {
QuadContent::ModelPane {
handle,
model: Some(m),
..
} if m.eq_ignore_ascii_case(COOLDOWN_MODEL)
&& s.quad_owner_name(q.target).as_deref() == Some(owner) =>
{
heads
.iter()
.find(|p| p.handle == *handle)
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms)))
}
_ => None,
})
}
/// [`cooldown_play`] for whichever cooldown pane is shown — the bag windows number their slots
/// from the far end (`ContainerFrame_GenerateFrame`: `Item{j}` carries id `size − j + 1`), so a
/// test that seats one item asks for "the" sweep rather than a name.
pub(super) fn cooldown_play_any(s: &UiScript) -> Option<(u16, u32)> {
let heads = s.visible_model_panes();
s.extract().into_iter().find_map(|q| match &q.content {
QuadContent::ModelPane {
handle,
model: Some(m),
..
} if m.eq_ignore_ascii_case(COOLDOWN_MODEL) => heads
.iter()
.find(|p| p.handle == *handle)
.and_then(|p| p.play.map(|ph| (ph.anim_id, ph.cursor_ms))),
_ => None,
})
}
/// [`load_ui`], and **a missing template is a failure too**.
///
/// A frame that inherits a template nothing declares is a loader *warning*, not an error: the frame
@@ -54,6 +119,13 @@ pub(super) fn load_ui_no_warnings(s: &UiScript, entry: &str) -> usize {
}
fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bool) -> usize {
// **A kit's VM is the client's VM, and that includes the CVar table** (decision 2115). The app
// registers `crate::cvars::REGISTERED` at startup, before any interface file loads;
// `UiScript::new()` carries only `benilla-ui`'s own. The stock `UIOptionsFrame.xml` reads two
// camera CVars inside its dropdowns' `OnLoad` and raises on a nil, so a kit that skips this is
// measuring a client that does not exist. Idempotent, and it never clobbers a value a test set
// first — `register_cvars` only refreshes an existing slot's default.
s.register_cvars(crate::cvars::registered_pairs());
let path = entry.replace('\\', "/");
let bytes = read(&path).unwrap_or_else(|| panic!("{entry}: not found"));
if path.to_ascii_lowercase().ends_with(".lua") {
@@ -64,12 +136,54 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
let doc = benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes))
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
// Seated BEFORE the load, not after it like the two below: `MultiActionBarFrame_OnLoad`
// indexes `UIOptionsFrameCheckButtons` from inside this very load walk, where the micro row's
// and UIParent's callees only run later (decision 2115).
if path
.rsplit('/')
.next()
.is_some_and(|l| l.eq_ignore_ascii_case("MultiActionBars.xml"))
{
s.run(MULTI_ACTION_BAR_STAND_INS)
.expect("the multibar stand-ins");
}
// **Our options window's Graphics rows read the REFERENCE's slider table, at OnLoad**
// (decision 2177): `OptionsFrameSliders[1..3]` are the bounds those three rows are built with,
// and they come from `Interface\FrameXML\OptionsFrame.lua` — which the shipped manifest loads
// as part of the stock VIDEO window, one seat above ours. A kit that seats our file alone has
// to bring it too, and brings the reference's own file rather than a transcription of the
// three rows: a stand-in here would be a second copy of numbers whose whole point is that
// they are no longer ours. Seated BEFORE the load, for the same reason MultiActionBars is:
// the rows index it from inside this very load walk.
if path
.rsplit('/')
.next()
.is_some_and(|l| l.eq_ignore_ascii_case("OptionsFrame.xml"))
&& !super::reference_ui::is_chain_entry(&path)
{
let bytes = read("Interface/FrameXML/OptionsFrame.lua")
.expect("the reference's own OptionsFrame.lua");
s.run_chunk_named(&bytes, "@Interface\\FrameXML\\OptionsFrame.lua")
.expect("the video window's slider table");
}
let report = benilla_ui::loader::load_in(s, &doc, &path, &provider);
assert!(
report.errors.is_empty(),
"{entry}: loader errors: {:?}",
report.errors
);
let leaf = path.rsplit('/').next().unwrap_or("");
if leaf.eq_ignore_ascii_case("MainMenuBarMicroButtons.xml") {
s.run(MICRO_BUTTON_STAND_INS)
.expect("the micro-button stand-ins");
}
if leaf.eq_ignore_ascii_case("UIParent.xml") && super::reference_ui::is_chain_entry(&path) {
s.run(UIPARENT_STAND_INS).expect("the UIParent stand-ins");
}
if leaf.eq_ignore_ascii_case("UIOptionsFrame.xml") {
s.run(UIOPTIONS_STAND_INS)
.expect("the stock options window's stand-ins");
}
if no_warnings {
assert!(
report.warnings.is_empty(),
@@ -92,10 +206,206 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
report.frames
}
/// **What a kit owes the stock micro-button row.** `UpdateMicroButtons`
/// (`MainMenuBarMicroButtons.lua:20-84`) reads ten panels, `KeyRingButton` and `IsBagOpen`
/// UNGUARDED — the reference's own contract, because in the client every one of them is up
/// before anything can be shown. A kit is a prefix of the manifest and stops wherever its window
/// does, so [`load_entry`] wraps the function the moment the row loads: on its FIRST call — a
/// panel's OnShow, by which time the kit is complete — the wrapper seats a hidden, unnamed
/// stand-in under each name the kit never declared (a hidden frame is what a closed panel
/// answers), then runs the reference's own body. First use rather than load time, and unnamed,
/// because both registries keep the first writer: the arena's name map
/// (`named_registry_is_non_overwriting`) and `_G` itself — a named frame is published
/// non-overwriting (RF-0023: a `lua_gettable` nil check on GLOBALSINDEX, which is why a
/// metatable fallback would block the real publish just the same). A stand-in seated before the
/// real declaration shadows the real frame for good; the first cut did exactly that, 126
/// failures, every tabbed panel's `numTabs` read off the stand-in. `fire_chat_login`'s
/// `UIOptionsFrame` stand-in is the same move for the chat files. Seated here rather than at the
/// kits' seventy-odd consumers because the dependency is the row's, not any one window's — and
/// the shipped manifest never needs it. `tests/common/mod.rs` carries the same chunk for the
/// integration tests, which cannot reach this module (decision 1987).
/// **What a kit owes the stock `UIParent.xml`.** Its `<OnUpdate>` calls `FCF_OnUpdate`,
/// `UnitPopup_OnUpdate` and `BattlefieldFrame_OnUpdate` unguarded (the chat, unit-menu and
/// battlefield files, far below it in the manifest), `UIParent_OnEvent`'s `PLAYER_ENTERING_WORLD`
/// arm calls `MultiActionBar_Update`, and `ShowUIPanel` calls `CloseAllBags` (the container file's).
/// A kit that stops short of those files would raise on its first tick or first shown panel, so
/// [`load_entry`] seats a no-op stand-in under each name the moment the stock file loads. Unlike
/// the micro row's frames these are FUNCTIONS: a later chunk's `function X()` overwrites a global
/// outright, so seating at load is safe and the kit's order does not matter (decision 1988).
pub(super) const UIPARENT_STAND_INS: &str = r#"
-- Callees of the stock UIParent.xml's <OnUpdate> and of UIParent_OnEvent's arms that live in
-- files a kit may stop short of, plus the bag verbs the stock ShowUIPanel calls and the two
-- container constants its window walks read: no-op stand-ins, each overwritten by the real
-- definition when its file loads (a chunk's `function X()` is a plain global write, unlike a
-- frame's non-overwriting publish — which is why the FRAMES below are seated on first use).
FCF_OnUpdate = FCF_OnUpdate or function() end
FCF_DockUpdate = FCF_DockUpdate or function() end
UnitPopup_OnUpdate = UnitPopup_OnUpdate or function() end
BattlefieldFrame_OnUpdate = BattlefieldFrame_OnUpdate or function() end
MultiActionBar_Update = MultiActionBar_Update or function() end
RaidOptionsFrame_UpdatePartyFrames = RaidOptionsFrame_UpdatePartyFrames or function() end
LocalizeFrames = LocalizeFrames or function() end
updateContainerFrameAnchors = updateContainerFrameAnchors or function() end
-- 1.12 keeps UpdateNameplates in UIOptionsFrame.lua, which a kit reaches only at manifest
-- l.21; our own OptionsFrame.xml re-declares it below that (decision 2132). Both are plain
-- `function X()` writes, so a full kit ends on ours and a short one keeps this no-op.
UpdateNameplates = UpdateNameplates or function() end
CloseAllBags = CloseAllBags or function() end
OpenBackpack = OpenBackpack or function() end
CloseBackpack = CloseBackpack or function() end
NUM_CONTAINER_FRAMES = NUM_CONTAINER_FRAMES or 0
-- The reference's own initial values (`ContainerFrame.lua:11-12`), not zeroes: the tooltip's
-- default corner is `-CONTAINER_OFFSET_X - 13, CONTAINER_OFFSET_Y`, so a kit reading zero here
-- would seat every default-anchored plate 70 units low.
CONTAINER_OFFSET_X = CONTAINER_OFFSET_X or 0
CONTAINER_OFFSET_Y = CONTAINER_OFFSET_Y or 70
BATTLEFIELD_TAB_OFFSET_Y = BATTLEFIELD_TAB_OFFSET_Y or 210
-- The pass writes its `isVar` rows with `setglobal`, but only for a name that already reads
-- non-nil (`frame = getglobal(index); if frame then`), so an unseeded one is never written at
-- all. These four are the reference's own seeds, from the files that declare them
-- (ContainerFrame.lua, PetActionBarFrame.lua, WorldStateFrame.lua).
PETACTIONBAR_YPOS = PETACTIONBAR_YPOS or 98
PETACTIONBAR_XPOS = PETACTIONBAR_XPOS or 36
-- The frames these three read UNGUARDED, seated on the call rather than at load: a frame's
-- publish to _G is non-overwriting (RF-0023), so a stand-in seated before the real file loads
-- would shadow the real window for good.
local function benilla_seat(names)
for _, name in ipairs(names) do
if not getglobal(name) then local f = CreateFrame("Frame") f:Hide() setglobal(name, f) end
end
end
local real_manage = UIParent_ManageFramePositions
function UIParent_ManageFramePositions()
benilla_seat({ "MainMenuBar", "MultiBarLeft", "MultiBarRight", "MultiBarBottomLeft",
"PetActionBarFrame", "ShapeshiftBarFrame", "ReputationWatchBar", "MainMenuExpBar",
"MainMenuBarMaxLevelBar", "CastingBarFrame", "QuestTimerFrame", "QuestWatchFrame",
"DurabilityFrame", "DurabilityShield", "DurabilityOffWeapon", "DurabilityRanged",
"MinimapCluster", "ChatFrame1", "ChatFrame2", "ShapeshiftBarLeft",
"ShapeshiftBarMiddle", "ShapeshiftBarRight", "BattlefieldMinimapTab" })
-- …and every FRAME the managed table itself names (`frame:IsObjectType` on a nil is what
-- a kit missing one raises) — read off the table, so a row added there needs nothing here.
-- The `isVar` rows are skipped: those keys are global NUMBERS the pass writes
-- (CONTAINER_OFFSET_X/Y, PETACTIONBAR_YPOS…), and a frame seated under one of those names
-- would be arithmetic's problem two files later.
local named = {}
for name, row in pairs(UIPARENT_MANAGED_FRAME_POSITIONS) do
if not row.isVar then table.insert(named, name) end
-- …and every row's ANCHOR TARGET (`anchorTo`, l.1668), which is a different set: the
-- keys are the frames being MOVED, the targets are what they move relative to, and
-- several targets (`ActionButton1`, `MainMenuBarArtFrame`) are declared in files a
-- one-window kit never loads. An unresolvable name is a RAISE now (decision 2176), so
-- a target the kit is missing aborts `UIParent_ManageFramePositions` mid-pass where it
-- used to anchor to the parent and carry on.
if row.anchorTo then table.insert(named, row.anchorTo) end
end
benilla_seat(named)
for _, name in ipairs({ "SlidingActionBarTexture0", "SlidingActionBarTexture1" }) do
if not getglobal(name) then setglobal(name, UIParent:CreateTexture()) end
end
return real_manage()
end
-- The four options/menu windows `IsOptionFrameOpen` (l.997) and `ToggleGameMenu` (l.1467)
-- index unguarded. `IsOptionFrameOpen` is on the path of every window close, so a kit that
-- loads no options window raised on the first bag click. In the shipped manifest all four
-- names are real, and since 2177 all three options windows are the REFERENCE's own files,
-- loaded hidden — including `OptionsFrame`, the video window, which used to be our own
-- window's name. Ours is `BenillaOptionsFrame` now and is not in this list: it is not a name
-- the reference indexes, and the wrappers in `GameMenuFrame.xml` are what tell these two
-- functions about it. A KIT is a prefix of the manifest and may load none of the four, which
-- is what these stand-ins are for.
local function benilla_seat_options()
benilla_seat({ "GameMenuFrame", "OptionsFrame", "UIOptionsFrame", "SoundOptionsFrame" })
if not OptionsFrameCancel then
OptionsFrameCancel = CreateFrame("Button")
OptionsFrameCancel:Hide()
end
end
local real_option_open = IsOptionFrameOpen
function IsOptionFrameOpen()
benilla_seat_options()
return real_option_open()
end
local real_toggle_menu = ToggleGameMenu
function ToggleGameMenu(clicked)
benilla_seat_options()
return real_toggle_menu(clicked)
end
"#;
/// **What a kit owes the stock `MultiActionBars.xml`.** Its `MultiActionBarFrame_OnLoad`
/// (`MultiActionBars.lua:10`, under the reference's own comment *"Hack to get around load order
/// dependencies"*) writes five rows into `UIOptionsFrameCheckButtons` — a table whose home is
/// `UIOptionsFrame.xml`, which the reference's toc seats at l.21, eighteen rows above this one.
/// The shipped manifest has that order (decision 2115) and needs nothing here; a KIT is a prefix
/// of the manifest and dozens of them load the bars without any options window.
///
/// `or {}` rather than a fresh table, and seated at load rather than on first use, for the reason
/// 1988 gives: a table, like a function, is a plain global write, so a real declaration that lands
/// afterwards simply wins — and in manifest order it never lands afterwards, which is exactly what
/// the shipped `UIOptionsFrameCheckButtons` order proof asserts. A kit that loads both in manifest
/// order therefore gets the real table with the bars' five rows in it, which is the client's own
/// state.
/// **What a kit owes the stock `UIOptionsFrame.xml`.** Its `UIOptionsFrame_OnEvent`'s
/// VARIABLES_LOADED arm (`UIOptionsFrame.lua` l.193-227) is the reference's own load-time ladder —
/// the one decision 2115 retired our re-expression of — and it calls six functions unguarded:
/// `BuffButtons_UpdatePositions`, `FCF_Set_SimpleChat`, `FCF_Set_ChatLocked`,
/// `SetChatMouseOverDelay`, `MultiActionBar_ShowAllGrids` and
/// `RaidOptionsFrame_UpdatePartyFrames`. In the shipped manifest that is fine twice over: the
/// buff bar loads far above this row, the chat and raid files far below it, and the event does not
/// fire until every one of them has loaded. A KIT is a prefix of the manifest and fires the event
/// itself, so it needs the names to exist.
///
/// Functions, so load-time seating is safe and the kit's order does not matter: a later chunk's
/// `function X()` is a plain global write that overwrites the stand-in, unlike a frame's
/// non-overwriting publish (1988's reasoning, and its precedent).
pub(super) const UIOPTIONS_STAND_INS: &str = r#"
BuffButtons_UpdatePositions = BuffButtons_UpdatePositions or function() end
FCF_Set_SimpleChat = FCF_Set_SimpleChat or function() end
FCF_Set_NormalChat = FCF_Set_NormalChat or function() end
FCF_Set_ChatLocked = FCF_Set_ChatLocked or function() end
SetChatMouseOverDelay = SetChatMouseOverDelay or function() end
MultiActionBar_ShowAllGrids = MultiActionBar_ShowAllGrids or function() end
RaidOptionsFrame_UpdatePartyFrames = RaidOptionsFrame_UpdatePartyFrames or function() end
-- …and the one its own CHECK BUTTONS reach: three of them register VARIABLES_LOADED for
-- themselves (xml l.373, 925, 993), and CheckButton43's arm calls `PartyFrame.lua`'s
-- `UpdatePartyMemberBackground`. The other two call the file's own dropdown loaders.
UpdatePartyMemberBackground = UpdatePartyMemberBackground or function() end
"#;
pub(super) const MULTI_ACTION_BAR_STAND_INS: &str = r#"
UIOptionsFrameCheckButtons = UIOptionsFrameCheckButtons or {}
-- The five ROWS it writes into, not just the table: the reference's hack assigns
-- `UIOptionsFrameCheckButtons["SHOW_MULTIBAR1_TEXT"].setFunc`, which needs the row to exist.
-- Empty rows on purpose — the real ones carry `index = 33..36, 40`, and a stand-in that
-- restated those numbers would be a transcription of the reference's table in a test helper,
-- which is the thing 2115 deleted from `OptionsFrame.xml`. Any kit that reads an index has the
-- real window loaded and therefore the real table.
for _, key in ipairs({ "SHOW_MULTIBAR1_TEXT", "SHOW_MULTIBAR2_TEXT", "SHOW_MULTIBAR3_TEXT",
"SHOW_MULTIBAR4_TEXT", "ALWAYS_SHOW_MULTIBARS_TEXT" }) do
UIOptionsFrameCheckButtons[key] = UIOptionsFrameCheckButtons[key] or {}
end
"#;
pub(super) const MICRO_BUTTON_STAND_INS: &str = r#"
local real = UpdateMicroButtons
function UpdateMicroButtons()
for _, name in ipairs({ "CharacterFrame", "SpellBookFrame", "QuestLogFrame", "GameMenuFrame",
"OptionsFrame", "SoundOptionsFrame", "UIOptionsFrame", "FriendsFrame", "WorldMapFrame",
"HelpFrame" }) do
if not getglobal(name) then local f = CreateFrame("Frame") f:Hide() setglobal(name, f) end
end
if not KeyRingButton then KeyRingButton = CreateFrame("Button") KeyRingButton:Hide() end
if not KEYRING_CONTAINER then KEYRING_CONTAINER = -2 end
if not IsBagOpen then function IsBagOpen() return nil end end
return real()
end
"#;
/// One file's bytes, from whichever store its path names — the chain for a path, this crate's
/// `assets/ui` for a bare name. Also the `<Include>` / `<Script file=>` provider, which is why it
/// takes an already-resolved path in either space.
fn read(req: &str) -> Option<Vec<u8>> {
pub(super) fn read(req: &str) -> Option<Vec<u8>> {
if super::reference_ui::is_chain_entry(req) {
return super::reference_ui::read(req);
}
@@ -142,27 +452,64 @@ fn read(req: &str) -> Option<Vec<u8>> {
/// Needs client data: open with `benilla_formats::wow_data_or_skip!()`.
pub(super) const MERCHANT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
"BasicControls.xml", // TEXT()
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml", // TEXT()
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml", // app load order: tooltip before merchant
// The reference's own window tab (`CharacterFrameTabButtonTemplate`), whose `<OnShow>`
// fits each tab to its text — it needs the `UIPanelTemplates` pair above it (1993).
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml", // app load order: tooltip before merchant
];
/// The stock gossip window's dependencies in `benilla.toc` order — the list
/// `panel_tests::shipped_gossip_frame_drives_end_to_end` and its siblings load by hand, shared so
/// a feed-level test in `ui_gossip` can drive the reference's own `GossipFrame.xml` (1751).
/// `UIParent.xml` brings the slot manager (`ShowUIPanel`/`HideUIPanel`) the window's OnEvent
/// calls; `ScrollTemplates.xml` is our scroll kit the greeting pane inherits — a missing template
/// is a loader *warning*, so an under-loaded list passes and silently loses the scrollbar.
///
/// Needs client data: open with `benilla_formats::wow_data_or_skip!()`.
pub(crate) const GOSSIP_UI: &[&str] = &[
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\GlobalStrings.lua",
r"Interface\FrameXML\BasicControls.xml",
r"Interface\FrameXML\LocaleProperties.lua",
r"Interface\FrameXML\StaticPopup.xml",
];
pub(super) const LOOT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml", // ITEM_QUALITY_COLORS — the row-name palette
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml", // StaticPopup, and the LOOT_BIND / CONFIRM_LOOT_DISTRIBUTION dialogs
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml", // UIParent + UIParent.lua (the slot manager, the fades; 1988)
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"Interface\\FrameXML\\UIDropDownMenu.xml", // GroupLootDropDown's OnLoad calls UIDropDownMenu_Initialize
"UnitPopup.xml",
// `UnitPopup.xml` reads ITEM_QUALITY_COLORS at FILE SCOPE for its three loot-threshold rows,
// exactly as the reference's own UnitPopup.lua:47-49 does — so its declarer has to precede it.
// That declarer is UIParent (ref UIParent.lua:65); 1888 moved the table there when Fonts.xml
// went on the chain, because the reference's Fonts.xml does not declare it.
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
// …and what its rows' OnLoad calls: every `PartyMemberFrame<N>` and its pet frame runs
// `UnitFrame_Initialize`, which lives in UnitFrame.lua and itself calls
// `SetTextStatusBarText` out of TextStatusBar.lua. Naming PartyFrame without these loads
@@ -197,7 +544,7 @@ pub(super) const LOOT_UI: &[&str] = &[
/// so `UnitFrame.xml` and `CombatFeedback.xml` come first — the reference's own toc order.
/// (This entry used to be our one `UnitFrames.xml`, and this note used to say "when they are
/// migrated, this becomes the stock pair". They are; it did.)
/// * **`ActionBar.xml`** for `MainMenuExpBar` — the third bar of that prefix call — and for
/// * **`Interface\FrameXML\MainMenuBar.xml`** for `MainMenuExpBar` — the third bar of that prefix call — and for
/// `ShowWatchedReputationBarText` / `HideWatchedReputationBarText`, which the window's
/// show/hide pair calls.
/// * **`UIPanelTemplates.xml`** for `PanelTemplates_SetNumTabs`/`_SetTab`, the last two lines of
@@ -209,9 +556,12 @@ pub(super) const LOOT_UI: &[&str] = &[
/// `HonorLevelText` and `HonorGuildText` "while we're at it" (`PaperDollFrame.lua:100`/`:120`).
/// Nothing touches them at load, so leaving this out loads clean and then raises the first time
/// the window is SHOWN. It sits below the character block in the manifest for the same reason.
/// * **`MicroMenu.xml`** — `UpdateMicroButtons` (called by both `CharacterFrame_OnShow` and
/// `_OnHide`) and `MicroButtonTooltipText` (all five tab hovers). A tab hover is the only thing
/// that reaches the second one, so its absence is invisible until a test hovers a tab.
/// * **`MainMenuBarMicroButtons.xml`** — `UpdateMicroButtons` (called by both
/// `CharacterFrame_OnShow` and `_OnHide`) and `MicroButtonTooltipText` (all five tab hovers).
/// A tab hover is the only thing that reaches the second one, so its absence is invisible
/// until a test hovers a tab. The reference's own file since 1987; the panels its
/// `UpdateMicroButtons` reads unguarded and this kit stops short of are
/// [`MICRO_BUTTON_STAND_INS`]'s.
///
/// Needs client data, like its three siblings: open with `benilla_formats::wow_data_or_skip!()`.
pub(super) const CHARACTER_UI: &[&str] = &[
@@ -219,22 +569,27 @@ pub(super) const CHARACTER_UI: &[&str] = &[
// `PaperDollFrame_OnLoad` sets seven labels from them at LOAD, and `PaperDollFrame_SetStats`
// concatenates `SPELL_STAT0_NAME`..`4` on every repaint.
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
"BasicControls.xml", // TEXT(), which every one of those label sets goes through
"Interface\\FrameXML\\Fonts.xml",
// `GetText` — the reference's gendered-string helper, which stock
// `ReputationFrame.lua:65` calls for every row's standing label. Manifest line 94
// (1875).
r"Interface\FrameXML\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml", // TEXT(), which every one of those label sets goes through
"Interface\\FrameXML\\ItemButtonTemplate.xml", // PaperDollItemSlotButtonTemplate's base
"MoneyFrame.xml",
"UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable
"UiPanels.xml", // CharacterFrameTabButtonTemplate, UIPanelWindows, Show/HideUIPanel
"GameTooltip.xml",
"Cooldown.xml", // CooldownFrameTemplate + CooldownFrame_SetTimer, per equipment slot
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\Cooldown.xml", // CooldownFrameTemplate + CooldownFrame_SetTimer, per equipment slot
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
// The unit frames' four right-click dropdowns call `UIDropDownMenu_Initialize` at LOAD, so the
// kit and the menu table it initialises from both precede them — the manifest's own order
// (175 → 189 → 193 → 263).
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
// `UnitFrame_Initialize` and `CombatFeedback_Initialize`, which the two windows below call in
@@ -249,8 +604,11 @@ pub(super) const CHARACTER_UI: &[&str] = &[
// template and not four party member frames, so it takes the included file directly.
"Interface\\FrameXML\\PartyFrameTemplates.xml",
"Interface\\FrameXML\\PetFrame.xml",
"ActionBar.xml",
"MicroMenu.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
// The two page files these three need before the window can be OPENED, which is not the same
// as before it can load: `CHARACTERFRAME_SUBFRAMES` lists all five pages by name and
// `CharacterFrame_ShowSubFrame` calls `getglobal(value):Hide()` on each one it is not showing
@@ -260,18 +618,84 @@ pub(super) const CHARACTER_UI: &[&str] = &[
"ScrollTemplates.xml", // SkillFrame's faux list + trough
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The four options templates off the chain, then ours for the one it does not carry
// (`UIOptionsCheckButtonTemplate` — decision 1841).
// The reference's own window tab (`CharacterFrameTabButtonTemplate`), whose `<OnShow>`
// fits each tab to its text — it needs the `UIPanelTemplates` pair above it (1993).
r"Interface\FrameXML\CharacterFrameTemplates.xml",
// The four options templates off the chain — ReputationFrame's detail check boxes inherit
// `OptionsCheckButtonTemplate`. Ours beside it is gone with 2115: its one template,
// `UIOptionsCheckButtonTemplate`, comes off the chain out of `UIOptionsFrame.xml` now.
"Interface\\FrameXML\\OptionsFrameTemplates.xml",
"OptionsFrameTemplates.xml", // ReputationFrame's detail check boxes
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
"Interface\\FrameXML\\PetPaperDollFrame.xml",
"ReputationFrame.xml",
"SkillFrame.xml",
// `updateContainerFrameAnchors` — `ReputationWatchBar_Update` hard-calls it when the bar
// moves (ReputationFrame.lua:248), because in the reference the bar's presence reflows the
// bag row. It comes with `ContainerFrame.xml`, manifest 585 against the pane's 693 (1875).
"Interface\\FrameXML\\ContainerFrame.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\SkillFrame.xml",
"Interface\\FrameXML\\HonorFrame.xml",
];
/// The social window's slice of the manifest (1959): everything the reference's
/// `FriendsFrame.xml` + `RaidFrame.xml` reach at load or on show — `TEXT` and `GetText`, the
/// bar chain under `UpdateMicroButtons`, the chat window whose edit box `FriendsFrame_SendMessage`
/// opens, the unit menu, the party frames `RaidFrame_OnLoad` reconciles — in the manifest's own
/// order; the raid tab's LoadOnDemand addon follows through [`load_social_ui`]. Shared by the
/// friends, guild and raid harnesses.
pub(super) const SOCIAL_UI: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
// The reference's own window tab (`CharacterFrameTabButtonTemplate`), whose `<OnShow>`
// fits each tab to its text — it needs the `UIPanelTemplates` pair above it (1993).
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\OptionsFrameTemplates.xml",
"Interface\\FrameXML\\ReputationFrame.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\FriendsFrame.xml",
"Interface\\FrameXML\\RaidFrame.xml",
];
/// Load [`SOCIAL_UI`], then the raid tab's LoadOnDemand addon the way the app reaches it: seated
/// off the chain as a registry row (1957) and loaded by the reference's own `RaidFrame_LoadUI`
/// (UIParent.xml; 1967). Needs client data — the caller has checked with `wow_data_or_skip!`.
pub(super) fn load_social_ui(s: &mut UiScript) {
for f in SOCIAL_UI {
load_ui_strict(s, f);
}
seat_chain_addon(s, "Blizzard_RaidUI");
s.run("RaidFrame_LoadUI()").unwrap();
}
pub(super) const BAG_UI: &[&str] = &[
// The reference's own localized strings — `BACKPACK_TOOLTIP`, `EQUIP_CONTAINER`, `KEYRING`,
// the `*_FONT_COLOR_CODE` pair. The app loads this at VM setup, ahead of the manifest
@@ -281,31 +705,40 @@ pub(super) const BAG_UI: &[&str] = &[
// deleted `BagFrame.xml` carried `X = X or "…"` fallbacks for exactly this gap; the real file
// is the better answer, and these tests already gate on the install.
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
"Interface\\FrameXML\\Fonts.xml",
// `TEXT()` — the reference's own identity-function wrapper, which stock
// `MainMenuBarBackpackButton`'s OnEnter calls (`GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP)…)`)
// and `BagSlotButton_OnEnter` calls for `EQUIP_CONTAINER`. Manifest entry 3, and not optional
// for the bag bar since 1751's third window made that bar the reference's own.
"BasicControls.xml",
"Interface\\FrameXML\\BasicControls.xml",
// `UIParent` itself: the twelve `ContainerFrame`s declare `parent="UIParent"`, and
// `updateContainerFrameAnchors` anchors each open bag to `frame:GetParent()` while
// `OpenAllBags` opens with `if not UIParent:IsVisible() then return end`. Without it the
// windows fall out of the cascade and the reference's own layout pass has nothing to measure.
"UIParent.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\Cooldown.xml",
// The bag BAR declares `parent="MainMenuBarArtFrame"`, resolved at LOAD — so without this the
// six buttons fall back to UIParent and sit at a level no production run ever puts them at.
// It also carries `MainMenuBar_UpdateKeyRing`, which is what puts the keyring on the bar.
"ActionBar.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// `UpdateMicroButtons` — the KEYRING's own OnShow/OnHide calls it (ContainerFrame.lua l.117,
// l.137), because in the reference the keyring's existence moves the micro-button row.
"MicroMenu.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The dialog engine, after the UIPanelCloseButton it inherits (1960).
r"Interface\FrameXML\StaticPopup.xml",
"Interface\\FrameXML\\ContainerFrame.xml",
// `PaperDollItemSlotButtonTemplate` and the `PaperDollItemSlotButton_*` family behind it,
// which every bag button inherits and runs — resolved at load, so this has to precede the bar
@@ -332,12 +765,15 @@ pub(super) const BAG_UI: &[&str] = &[
// `if ( ChatFrameEditBox:IsShown() )` (ContainerFrame.lua l.569) to decide between posting the
// item's link and splitting the stack, so a VM without it raises before either.
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
// Our adapters over the reference's container files — the keyring tooltip wrapper, the three
// bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`), and the item-push
// card the reference draws with a `<Model>` this engine does not render (0887). It has to be
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
// Our adapters over the reference's container files — the keyring tooltip wrapper and the
// three bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`). It has to be
// AFTER `ContainerFrame.xml` and after the bar, which is why it is here and not up with
// UiPanels.xml.
// `UIParent.xml`.
"ContainerFrameAdapters.xml",
// `updateContainerFrameAnchors` measures every open bag against `BankFrame:GetRight()`
// (ContainerFrame.lua l.505) on EVERY open and close, so the bank window is not optional
@@ -419,3 +855,88 @@ pub(super) fn click(s: &mut UiScript, name: &str, button: &str) {
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
}
/// Seat the world frame in a fixture, the way the manifest does — the file the world drop's
/// click target comes from, plus the one its `OnUpdate` reads a constant out of.
///
/// `WorldFrame_OnUpdate` ticks the popups and breath bars that a hidden UI would otherwise
/// freeze, and it walks `1, MIRRORTIMER_NUMTIMERS` to do it — a global whose home is
/// `MirrorTimer.lua`, manifest entry 53. A harness that takes the world frame without it raises
/// `'for' limit must be a number` on every tick. Loading the real file rather than setting the
/// constant by hand is [`BAG_UI`]'s own rule (`PartyFrame.xml` is there for the same reason):
/// a stubbed constant passes and teaches nothing about the real load.
///
/// `StaticPopup.xml` — the other name that loop reads — is already in every fixture that calls
/// this, since the popup is what these tests are about.
pub(super) fn load_world_frame(s: &UiScript) {
load_ui(s, r"Interface\FrameXML\WorldFrame.xml");
load_ui(s, r"Interface\FrameXML\MirrorTimer.xml");
}
/// A completed left CLICK on the game world, at a point the loaded `WorldFrame` actually owns.
///
/// **The point is searched, not assumed, and that is the whole lesson of B380** (decision 2089).
/// Every world-drop fixture in the house used to click `(-50, -50)` — off-screen, where the hit
/// test answers nothing at all — which is a world click only in a house with no `WorldFrame`
/// loaded. The stock file's frame is full-screen and mouse-enabled, so from the day it joined the
/// manifest it took every real world click while those fixtures went on passing over the void.
///
/// The point cannot be a constant either, because a harness holds the windows its test needs and
/// not the ones that would hide them — `bag_setup`'s `PaperDollFrame` has no `CharacterFrame` to
/// sit inside, so it covers the middle of the screen. So: walk a coarse grid over the world
/// frame's own rect and take the first cell it owns. When it owns none, panic naming what is
/// there — a fixture with no world to click is one that would pass for the wrong reason.
pub(super) fn world_click(s: &mut UiScript) {
let (x, y) = world_point(s);
s.mouse_move(x, y);
s.mouse_button(x, y, "LeftButton", true);
assert!(
s.mouse_button(x, y, "LeftButton", false),
"a world drop consumes the completed click"
);
}
/// [`world_click`]'s search. Separate so the failure can say what it looked at.
fn world_point(s: &mut UiScript) -> (f32, f32) {
s.resolve();
let r: Vec<f32> = s
.eval(
"local f = WorldFrame \
return { f:GetLeft(), f:GetBottom(), f:GetWidth(), f:GetHeight() }",
)
.expect(
"the fixture must load Interface\\FrameXML\\WorldFrame.xml (load_world_frame): a \
world drop is a click on THAT frame, not on nothing (decision 2089)",
);
assert_eq!(r.len(), 4, "WorldFrame: unresolved rect {r:?}");
let (steps, mut blockers) = (7, Vec::new());
for row in 0..steps {
for col in 0..steps {
let x = r[0] + r[2] * (col as f32 + 0.5) / steps as f32;
let y = r[1] + r[3] * (row as f32 + 0.5) / steps as f32;
match s.hit_test(x, y) {
Some(id) if s.is_world_frame(id) => return (x, y),
_ => blockers.push(
s.hit_test_name(x, y)
.unwrap_or_else(|| "<anonymous>".into()),
),
}
}
}
blockers.sort();
blockers.dedup();
panic!("no point on the world frame is clickable — the UI covers all {steps}x{steps} of them: {blockers:?}")
}
/// Seat one of the reference's LoadOnDemand Blizzard addons off the chain, so a harness's
/// `UIParentLoadAddOn(name)` can load it the way the app does (1957; the combat text since
/// 1964). Needs client data — the caller has already checked with `wow_data_or_skip!`.
pub(super) fn seat_chain_addon(s: &mut UiScript, name: &str) {
let toc = super::reference_ui::read(&format!("Interface/AddOns/{name}/{name}.toc"))
.map(|b| benilla_ui::toc::Toc::parse(&benilla_ui::source::decode(&b)))
.unwrap_or_else(|| panic!("{name}: no toc off the chain"));
let mut info = super::addons::info_from_toc(name, &toc);
info.chain = true;
s.set_addon_chain_reader(Box::new(super::reference_ui::read));
s.register_addons(vec![info], None, None, None);
}
@@ -20,12 +20,23 @@ use super::test_ui::load_ui as load_xml;
fn harness(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// `FACTION_BAR_COLORS`, which the stock `GameTooltip_UnitColor` indexes on every unit hover:
// the reference defines it at ReputationFrame.lua's file scope (1968).
load_xml(&s, r"Interface\FrameXML\ReputationFrame.lua");
for f in extra {
load_xml(&s, f);
}
// The stock tooltip declares no size: it sizes from its lines through the font engine, as
// the client's does (1968) — every test here reads its rect, so the fixed-width font is
// that engine. And 1.12 ships detailed tips ON (`SHOW_NEWBIE_TIPS = "1"`, UIOptionsFrame_Init's;
// ours in OptionsFrame.xml's uvar block) — a harness without the options file says so itself.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s
}
@@ -98,7 +109,8 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
@@ -145,6 +157,53 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **The title a world hover paints while the creature query is still in flight**, over the
/// shipped files: `UNKNOWNOBJECT` as `GlobalStrings.lua` defines it on the PLAYER'S OWN CHAIN.
/// Read out of the VM at the assert rather than written as a literal, because that is the whole
/// point of the resolver's `0x703bf0` read — a translated GlobalStrings translates the
/// placeholder too (decision 2040, closing 2002's residue).
///
/// The engine half (the miss legs, the empty-global fallback, the `"player"` case) is
/// `benilla_ui`'s own `tooltip_unit` suite; what this adds is the chain: the string really is
/// defined, the plate really reads it, and the answer really replaces it.
#[test]
fn a_pending_name_hover_titles_the_chains_unknownobject() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness(&["Interface\\FrameXML\\GlobalStrings.lua"]);
// The snapshot the feed pushes before `SMSG_CREATURE_QUERY_RESPONSE` lands: the descriptor is
// in, and the name and the type word — which ride the same record — are not.
s.set_unit(
"mouseover",
Some(UnitState {
name: None,
creature_type_name: None,
..wolf()
}),
);
assert!(s.world_tooltip_unit("mouseover"), "the hover shows");
let global = s.eval::<String>("return UNKNOWNOBJECT").unwrap();
assert!(
!global.is_empty(),
"the chain's GlobalStrings.lua defines UNKNOWNOBJECT"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
global,
"a name in flight titles the plate with the GlobalString, not an empty line"
);
// The query answers; the next paint of the same hover carries the real name.
s.set_unit("mouseover", Some(wolf()));
assert!(s.world_tooltip_unit("mouseover"));
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Timber Wolf"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The detailed-tooltip fork (ref UnitFrame_OnEnter l.58-67, director-approved 0663): with tips on
/// — the 1.12 default — the frame explains its RIGHT-CLICK MENU and returns BEFORE `SetUnit`, so
/// the unit lines never render. Your own portrait always; another player's whenever they're your
@@ -164,7 +223,8 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
@@ -227,9 +287,21 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
/// table so the pass means what it says.
#[test]
fn action_button_hover_takes_the_default_corner() {
let mut s = harness(&["Cooldown.xml", "ActionBar.xml"]);
let s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
]);
s.register_cvars(crate::cvars::registered_pairs());
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
s.run("this = ActionButton3 ActionButton_SetTooltip()")
.unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let ok: bool = s
.eval(
@@ -256,7 +328,29 @@ fn action_button_hover_takes_the_default_corner() {
/// (`script/tooltip/verbs.rs`).
#[test]
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "MultiBars.xml"]);
let mut s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
]);
s.register_cvars(crate::cvars::registered_pairs());
s.set_cvar_engine("UberTooltips", "0");
@@ -268,7 +362,8 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
.unwrap()
};
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
s.run("this = ActionButton3 ActionButton_SetTooltip()")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default == nil").unwrap(),
"off: the main bar's plate is owner-anchored, not the default corner"
@@ -287,7 +382,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
// All three members of the ref's LEFT set, by frame — membership is not gated on visibility,
// and the two vertical bars are hidden until their option is ticked.
for bar in ["MultiBarBottomRight", "MultiBarRight", "MultiBarLeft"] {
s.run(&format!("BenillaActionButton_OnEnter({bar}Button1)"))
s.run(&format!("this = {bar}Button1 ActionButton_SetTooltip()"))
.unwrap();
assert_eq!(
seat(&s),
@@ -298,7 +393,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
// And the CVar back on restores the corner — the fork is a fork, not a one-way door.
s.set_cvar_engine("UberTooltips", "1");
s.run("BenillaActionButton_OnEnter(MultiBarBottomRightButton1)")
s.run("this = MultiBarBottomRightButton1 ActionButton_SetTooltip()")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
@@ -313,7 +408,20 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
/// un-collapsed with it.
#[test]
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "StanceBar.xml"]);
let mut s = harness(&[
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
]);
s.register_cvars(crate::cvars::registered_pairs());
s.set_shapeshift_forms(vec![benilla_ui::script::ShapeshiftFormView {
spell_id: 5487,
@@ -326,7 +434,7 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
s.run("BenillaShapeshiftButton_OnEnter(ShapeshiftButton1)")
s.run("this = ShapeshiftButton1 ShapeshiftButton1:GetScript(\"OnEnter\")()")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
@@ -334,7 +442,7 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
);
s.set_cvar_engine("UberTooltips", "0");
s.run("BenillaShapeshiftButton_OnEnter(ShapeshiftButton1)")
s.run("this = ShapeshiftButton1 ShapeshiftButton1:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>(
@@ -354,8 +462,16 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
#[test]
fn buff_hover_hangs_below_left_of_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"ActionBar.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
@@ -399,3 +515,53 @@ fn buff_hover_hangs_below_left_of_the_button() {
"buff tooltip hangs its TOPRIGHT on the button's BOTTOMLEFT"
);
}
/// **The cursor-seated GameObject plate carries an OWNER** — the store the reference's publisher
/// makes through the SetOwner core (`0x492a01 → 0x52ffe0(owner, 6, 0, 0)`, whose `0x53000c`
/// writes `+0x314`), and the one arm of ours that used to skip it (decision 2255).
///
/// Every other world plate reached an owner by accident, through Lua: the corner arm and the unit
/// flow both fire `OnTooltipSetDefaultAnchor`, and the stock handler calls
/// `GameTooltip:SetOwner(UIParent, …)`. The cursor arm fires nothing, so `IsOwned` answered false
/// for exactly the GENERIC(5) objects — a signpost, a mailbox — and the next test is what that
/// cost.
#[test]
fn a_cursor_seated_gameobject_plate_is_owned() {
let mut s = harness(&[]);
assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0))));
let owned: bool = s.eval("return GameTooltip:IsOwned(UIParent)").unwrap();
assert!(
owned,
"the signpost plate is owned; errors: {:?}",
s.errors()
);
}
/// **An addon's `OnShow` hook must not hide the plate the world hover just built** — the
/// director's signpost with no tooltip (decision 2255).
///
/// `!Questie` installs an `OnShow` on GameTooltip at PLAYER_LOGIN (`Questie:hookTooltip` — it
/// installs one precisely *because* the stock plate has none) whose handler ends in
/// `GameTooltip:Show()`. Lua's `:Show()` is the reference's EXISTENCE GATE `0x530a80`: owner and
/// line count both non-zero, or it takes the effective-hide `0x530a60` instead. So an unowned
/// plate hides itself the instant it is shown — through our own faithful implementation of that
/// gate, ~26 ms after the engine built it, on every signpost, for the whole session.
///
/// The reference cannot reach that state, because its publisher writes the owner *before* the
/// plate is ever shown. With the owner written, so do we.
#[test]
fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
let mut s = harness(&[]);
// Questie's hook, in one line: the plate's own show event calls Show() again.
s.run(r#"GameTooltip:SetScript("OnShow", function() GameTooltip:Show() end)"#)
.unwrap();
assert!(s.world_tooltip_gameobject("Brill", &[], Some((512.0, 384.0))));
let shown: bool = s
.eval("return GameTooltip:IsShown() and true or false")
.unwrap();
assert!(
shown,
"the signpost plate is still up; errors: {:?}",
s.errors()
);
}
@@ -1,20 +1,21 @@
//! The shopping-compare + chat-link tooltips over the REAL shipped XMLs (decision 0274 P4):
//! shift over a bag item fires `SHOW_COMPARE_TOOLTIP`, CharacterFrame's doll-slot listener
//! (ref PaperDollFrame.lua:621-640) seats `ShoppingTooltip1` on the matching slot, the armed
//! `SetInventoryItem` renders the byte law's compare shape over the template's own adopted
//! small-font ladder, and `SetItemRef` fills the parked `ItemRefTooltip`.
//! The shopping-compare + chat-link tooltips over the REAL shipped XMLs (decision 0274 P4,
//! re-based by 2202, scoped back to the reference by 2210): a VENDOR row hover raises
//! `ShoppingTooltip1/2` through the stock `MerchantFrame.xml`, their armed `SetInventoryItem`
//! renders the byte law's compare shape over the template's own adopted small-font ladder, and
//! `SetItemRef` fills the parked `ItemRefTooltip`.
//!
//! **The bag end of that flow is the REFERENCE's own code since 1751.** The hover source used to be
//! benilla's `BenillaBagSlot_OnEnter` on a `BenillaBagFrame`; it is the reference's
//! `ContainerFrameItemButton_OnEnter` on one of its recycled `ContainerFrame1..12` now. Same two
//! calls arm the compare (`GameTooltip:SetOwner` + `SetBagItem`), so what these tests pin is
//! unchanged — but the handler reads `this`, so they drive the mouse
//! ([`super::test_ui::hover`]) instead of calling it.
//! **1.12.1 has no hover compare, and neither does benilla now.** `SHOW_COMPARE_TOOLTIP` (event
//! 377) has zero fire sites in 5875, so `PaperDollFrame.lua:621-640` is dead code there and the
//! vendor row (plus the auction row, 1971) is the only live consumer of the shopping plates
//! (wow-re `merchant-compare-item-law.md` §8). benilla fired that event until 2202 and then drove
//! the plates itself on a shift-held hover until 2210; both were supersets of a client that
//! compares only where its own FrameXML asks. The plates' geometry and lifetime belong to that
//! FrameXML — this engine seats none of them.
//!
//! **The doll end went the same way.** `CharacterFrame.xml` and `PaperDollFrame.xml` are the
//! reference's own too now, so `BenillaPaperDollSlot_OnEnter` is gone and the hover is stock
//! `PaperDollItemSlotButton_OnEnter` (`PaperDollFrame.lua:739-764`), which reads `this` as well.
//! The doll-slot names (`CharacterHeadSlot` and kin) are unchanged; the mouse is what reaches them.
//! **The hover sources are the REFERENCE's own code since 1751**, so these tests drive the MOUSE
//! ([`super::test_ui::hover`]) rather than calling handlers: `MerchantItemButton`'s OnEnter, and
//! stock `PaperDollItemSlotButton_OnEnter` (`PaperDollFrame.lua:739-764`) for the doll slots
//! (`CharacterHeadSlot` and kin — the names are unchanged; the mouse is what reaches them).
use benilla_ui::script::{
ContainerSlot, ContainerState, InvSlotView, InventorySlots, ItemTemplateView, UiScript,
@@ -58,7 +59,9 @@ fn harness() -> UiScript {
/// the other window the compare flow crosses, and `ItemRef.xml`, which declares the chat-link
/// router's own `ItemRefTooltip`. Both were in this harness's hand-copied list before 1751; the
/// manifest's order is the one kept (`FrameXML.toc` 63 → 77).
const ROUTER_UI: [&str; 2] = [
const ROUTER_UI: [&str; 4] = [
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\ItemRef.xml",
];
@@ -102,6 +105,7 @@ fn harness_with_bags() -> UiScript {
fn seed_items(s: &mut UiScript) {
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -148,6 +152,7 @@ fn seed_items(s: &mut UiScript) {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -176,91 +181,6 @@ fn seed_items(s: &mut UiScript) {
);
}
/// Shift over a bag helm with the character window OPEN: `ShoppingTooltip1` seats on the head
/// doll slot (ANCHOR_RIGHT), renders gray "Currently Equipped" + the equipped helm through the
/// template's ADOPTED small-font ladder (line 1 = GameFontNormalSmall's 10px face — the
/// engine-created lines of the MAIN tooltip stay on its own faces), the compact cut drops the
/// description, and releasing shift hides the pair. With the window CLOSED nothing shows —
/// the 1.12 behavior.
#[test]
fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with_bags();
s.set_unit("player", Some(player()));
seed_items(&mut s);
// Open the bag and hover the helm slot. The button is ASKED for by its own `GetID()` — the
// reference numbers a window's buttons backwards and recycles the windows (1751), so neither
// the frame name nor the button index is a property to assume.
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
// Character window CLOSED: the shift edge fires, no listener answers, nothing shows.
s.set_modifiers(true, false, false);
assert!(
!s.eval::<bool>("return ShoppingTooltip1:IsShown()").unwrap(),
"no compare with the character window closed"
);
s.set_modifiers(false, false, false);
// Open the window; the shift edge now seats the compare on the head slot.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
hover(&mut s, &btn);
s.set_modifiers(true, false, false);
assert!(s.errors().is_empty(), "compare errors: {:?}", s.errors());
let ok: bool = s
.eval(
"local p, rel = ShoppingTooltip1:GetPoint() \
return ShoppingTooltip1:IsShown() \
and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \
and ShoppingTooltip1TextLeft2:GetText() == \"Test Helm\" \
and rel:GetName() == \"CharacterHeadSlot\" and p == \"BOTTOMLEFT\"",
)
.unwrap();
assert!(ok, "the compare plate seats on the head doll slot");
// The adopted ladder: the shopping plate's line 1 wears GameFontNormalSmall (10px); the
// MAIN tooltip's engine-created line 1 keeps the header face — different sizes.
let ok: bool = s
.eval(
"local _, sh = ShoppingTooltip1TextLeft1:GetFont() \
local _, mh = GameTooltipTextLeft1:GetFont() \
return sh == 10 and mh > sh",
)
.unwrap();
assert!(
ok,
"the template's small-font ladder rides the compare plate"
);
// The compact cut: the equipped helm's description never prints on the compare plate.
let ok: bool = s
.eval(
"for i = 1, ShoppingTooltip1:NumLines() do \
if getglobal(\"ShoppingTooltip1TextLeft\" .. i):GetText() == \"\\\"Snug.\\\"\" then \
return false \
end \
end \
return true",
)
.unwrap();
assert!(ok, "compact cut drops the description");
// One helm slot → exactly one shopping tooltip.
assert!(
!s.eval::<bool>("return ShoppingTooltip2:IsShown()").unwrap(),
"a single-slot item fires one compare"
);
// Release hides the pair; the bag hover itself stays.
s.set_modifiers(false, false, false);
let ok: bool = s
.eval("return not ShoppingTooltip1:IsShown() and GameTooltip:IsShown()")
.unwrap();
assert!(ok, "release hides the compare, keeps the hover");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A chat item link through the ref router: `SetItemRef` shows the parked `ItemRefTooltip`
/// (BOTTOM +80, ANCHOR_PRESERVE keeps the XML seat) with the linked item's law; the corner
/// close button hides it.
@@ -296,11 +216,8 @@ fn item_ref_tooltip_renders_a_chat_link() {
/// durability pair off the slot view, never the template's authored max/max (ref
/// PaperDollItemSlotButton_OnEnter l.741: `SetInventoryItem`, not an id/template render;
/// director-caught: broken gear read 100% in the char window while the bag read it right).
/// And shift over the doll slot compares NOTHING — a worn item compared with itself is no
/// comparison: `SetInventoryItem` never arms, and its content clear drops any stale arm left
/// by an earlier bag hover.
#[test]
fn doll_hover_renders_the_live_instance_and_never_self_compares() {
fn doll_hover_renders_the_live_instance() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with_bags();
s.set_unit("player", Some(player()));
@@ -308,6 +225,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: Some((0, 40)),
@@ -346,16 +264,9 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
s.take_sounds();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
// The doll slot needs its window open to be hoverable at all.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
// …and the arm really IS live at this point — asserted, not assumed, because the whole test
// below is a NEGATIVE and would pass just as well if the bag hover had quietly armed nothing.
s.set_modifiers(true, false, false);
assert!(
s.eval::<bool>("return ShoppingTooltip1:IsShown()").unwrap(),
"fixture: the bag hover armed a compare, so the doll hover has something to clear"
);
s.set_modifiers(false, false, false);
// The doll hover: the live pair, not the template's 40/40. Stock
// `PaperDollItemSlotButton_OnEnter` reads `this`, so the mouse is what reaches it — and moving
@@ -376,12 +287,140 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
"the doll hover carries the instance's live pair"
);
// Shift over the worn item: no compare — not even off the bag hover's stale arm.
s.set_modifiers(true, false, false);
assert!(
!s.eval::<bool>("return ShoppingTooltip1:IsShown()").unwrap(),
"a worn item never compares with itself"
);
s.set_modifiers(false, false, false);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **The compare 1.12.1 actually has**, end to end over the stock file: hovering a vendor row
/// raises `ShoppingTooltip1/2` against what is worn, headed by the gray `Currently Equipped`.
///
/// `MerchantFrame.xml`'s `MerchantItemButton` OnEnter is the whole law and this exercises it as
/// written — `SetMerchantCompareItem` as the predicate, `SetOwner(GameTooltip, "ANCHOR_NONE")`,
/// `TOPLEFT`/`GameTooltip` `TOPRIGHT` (0, −10), the same call again, `Show()`; plate 2 the same
/// off plate 1 at (0, 0). The engine supplies only the two bindings and the compare render; the
/// geometry, the second fill and both `Show`s are the reference's Lua. Two worn rings against a
/// ring on the shelf, so BOTH plates answer — the case that pins the offset walk.
///
/// And it pins the CONTENT the plate carries, because that is where we were wrong (2216): the
/// compare call sites pass p4 = 0, so a plate is the worn item's ordinary tooltip with one gray
/// line on top — an EPIC ring's name reads purple, not white, and its flavour text is not cut.
#[test]
fn shipped_merchant_row_raises_the_compare_plates() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Two worn rings, a third on the shelf: offset 1 and 2 both find a candidate.
let mut inv: InventorySlots = Default::default();
// The worn ring under plate 1 is EPIC and carries flavour text — the two things the old
// conflated compare flag took away.
for (slot, id, name, quality) in [
(11usize, 7000u32, "Old Loop", 4u32),
(12, 7001, "Older Loop", 1),
] {
inv[slot] = Some(InvSlotView {
item_id: id,
count: 1,
quality: quality as i32,
name: Some(name.into()),
..Default::default()
});
s.set_item_template(
id,
ItemTemplateView {
quality,
description: "Round.".into(),
..armor_template(name, 11)
},
);
}
s.set_inventory_slots(inv);
s.set_item_template(8000, armor_template("Shiny Loop", 11));
s.set_merchant(Some(benilla_ui::script::MerchantState {
items: vec![benilla_ui::script::MerchantItem {
name: Some("Shiny Loop".into()),
texture: Some("Interface\\Icons\\INV_Jewelry_Ring_03".into()),
price: 100,
quantity: 1,
num_available: -1,
item_id: 8000,
max_stack: Some(1),
..Default::default()
}],
..Default::default()
}));
s.fire_event("MERCHANT_SHOW", vec![]);
s.take_sounds();
// The mouse, not the handler: `MerchantItemButton`'s OnEnter reads `this`.
hover(&mut s, "MerchantItem1ItemButton");
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let ok: bool = s
.eval(
"local p1, r1, rp1, x1, y1 = ShoppingTooltip1:GetPoint() \
local p2, r2, rp2, x2, y2 = ShoppingTooltip2:GetPoint() \
return GameTooltipTextLeft1:GetText() == \"Shiny Loop\" \
and ShoppingTooltip1:IsShown() and ShoppingTooltip2:IsShown() \
and ShoppingTooltip1TextLeft1:GetText() == \"Currently Equipped\" \
and ShoppingTooltip1TextLeft2:GetText() == \"Old Loop\" \
and ShoppingTooltip2TextLeft2:GetText() == \"Older Loop\" \
and p1 == \"TOPLEFT\" and r1:GetName() == \"GameTooltip\" \
and rp1 == \"TOPRIGHT\" and x1 == 0 and y1 == -10 \
and p2 == \"TOPLEFT\" and r2:GetName() == \"ShoppingTooltip1\" \
and rp2 == \"TOPRIGHT\" and x2 == 0 and y2 == 0",
)
.unwrap();
assert!(
ok,
"the vendor row raises both plates at the stock geometry"
);
// p4 = 0 over the stock file: the plate is the FULL tooltip, in the worn item's own colours.
let ok: bool = s
.eval(
"local r, g, b = ShoppingTooltip1TextLeft2:GetTextColor() \
local flavour = nil \
for i = 1, ShoppingTooltip1:NumLines() do \
if getglobal(\"ShoppingTooltip1TextLeft\"..i):GetText() == \"\\\"Round.\\\"\" then flavour = i end \
end \
return flavour ~= nil and math.abs(r - 0.639) < 0.01 \
and math.abs(g - 0.208) < 0.01 and math.abs(b - 0.933) < 0.01",
)
.unwrap();
assert!(
ok,
"the epic ring's plate reads purple and keeps its flavour text — no compact cut"
);
// The plate wears the template's own small-font ladder (10px), the main tooltip its header face.
let ok: bool = s
.eval(
"local _, sh = ShoppingTooltip1TextLeft1:GetFont() \
local _, mh = GameTooltipTextLeft1:GetFont() \
return sh == 10 and mh > sh",
)
.unwrap();
assert!(
ok,
"the template's small-font ladder rides the compare plate"
);
// Leaving the row hides the tooltip, and the stock OnHide takes both plates with it.
hover(&mut s, "UIParent");
let ok: bool = s
.eval(
"return not GameTooltip:IsShown() \
and not ShoppingTooltip1:IsShown() and not ShoppingTooltip2:IsShown()",
)
.unwrap();
assert!(ok, "the plates leave with the tooltip that owns them");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// An armour template in one `InventoryType` — the CLASS is what the selection law compares by
/// (wow-re `merchant-compare-item-law.md` §3).
fn armor_template(name: &str, inventory_type: u32) -> ItemTemplateView {
ItemTemplateView {
name: name.into(),
quality: 1,
class: 4,
subclass: 1,
inventory_type,
armor: 5,
..Default::default()
}
}
@@ -1,5 +1,5 @@
//! `GameTooltipTemplate` as an ADDON sees it — the shipped `assets/ui/GameTooltip.xml` driven the
//! way the corpus drives it.
//! `GameTooltipTemplate` as an ADDON sees it — the stock `Interface\FrameXML\GameTooltip.xml`
//! driven the way the corpus drives it.
//!
//! **This is not a test of our tooltip window** (that is `tooltip_anchor_tests` /
//! `tooltip_compare_tests`, and they are the regression suite this refactor had to leave
@@ -41,7 +41,13 @@ fn load_addon_xml(s: &UiScript, text: &str) -> benilla_ui::loader::LoadReport {
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in ["Fonts.xml", "UIParent.xml", "GameTooltip.xml"] {
for f in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
] {
load_xml(&s, f);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -187,11 +193,12 @@ fn an_addon_tooltip_from_the_template_gets_the_plate() {
.any(|p| p.eq_ignore_ascii_case(r"Interface\Tooltips\UI-Tooltip-Border")),
"…and its border: {drawn:?}"
);
// The Thicken deviation rides the template too — it is the shared plate, not one window's.
// The template's children resolve against the CALLER's name — the status bar the reference's
// template declares (`$parentStatusBar`), which TipBuddy anchors to by that global.
assert!(
s.eval::<bool>(r#"return getglobal("MyTipThicken") ~= nil"#)
s.eval::<bool>(r#"return getglobal("MyTipStatusBar") ~= nil"#)
.unwrap(),
"$parentThicken resolves against the CALLER's name"
"$parentStatusBar resolves against the CALLER's name"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -282,9 +289,9 @@ fn createframe_with_the_template_is_the_same_tooltip() {
"Equip: Improves your chance to hit by 1%."
);
assert!(
s.eval::<bool>(r#"return getglobal("BetterCharacterStatsTooltipThicken") ~= nil"#)
s.eval::<bool>(r#"return getglobal("BetterCharacterStatsTooltipStatusBar") ~= nil"#)
.unwrap(),
"the plate came through CreateFrame's fourth argument too"
"the template's children came through CreateFrame's fourth argument too"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -60,13 +60,22 @@ fn unit(name: &str, guid: u64, health: u32) -> UnitState {
fn load_tot() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
+383 -124
View File
@@ -1,12 +1,12 @@
//! The shipped **trainer window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/TrainerFrame.xml` — a client-sorted, collapsible **skill-line tree** with a **dropdown**
//! state filter and a draggable **scroll bar** (decisions 0247/0251) — loaded behind its deps
//! (`UiPanels.xml` + `UIDropDownMenu.xml` + `ScrollTemplates.xml` + `MerchantFrame.xml` for the
//! `BenillaMoney_*` coin helpers) and fed a synthetic service list. Covers what only a runtime load
//! exercises: the Lua parses and every referenced global resolves, the tree renders interleaved
//! header/service rows, a header click folds its group, the dropdown filter hides a state, the wheel
//! scrolls the list, the NPC name rides `arg1` into the title, the byte-exact GlobalStrings render, the
//! Train button gates on available-and-affordable, and the buy queues the row's spell id.
//! The reference's own `Blizzard_TrainerUI` addon — a client-sorted, collapsible **skill-line
//! tree** with a **dropdown** state filter and a draggable **scroll bar** (decisions 0247/0251) —
//! loaded behind its deps (`UiPanels.xml` + `UIDropDownMenu.xml` + `ScrollTemplates.xml` +
//! `MerchantFrame.xml` for the the stock money frames) and fed a synthetic service list. Covers
//! what only a runtime load exercises: the Lua parses and every referenced global resolves, the
//! tree renders interleaved header/service rows, a header click folds its group, the dropdown
//! filter hides a state, the wheel scrolls the list, the NPC name rides `arg1` into the title, the
//! byte-exact GlobalStrings render, the Train button gates on available-and-affordable, and the buy queues the row's spell id.
use benilla_ui::script::{
ExtractedQuad, QuadContent, ScriptValue, SoundRequest, TrainerAbilityReq, TrainerService,
@@ -22,26 +22,84 @@ use super::test_ui::load_ui as load_xml;
/// pushes into the engine on every show — so a test that wants the full tree sets those, not the
/// engine's own `SetTrainerServiceTypeFilter`, which the next `TRAINER_SHOW` would overwrite.
fn trainer_script() -> UiScript {
let mut s = trainer_script_base();
// The reference's own LoadOnDemand addon's files, direct off the chain (what its `LoadAddOn`
// runs), and the FrameXML templates file its list inherits from (1957).
load_xml(
&s,
"Interface\\AddOns\\Blizzard_TrainerUI\\Blizzard_TrainerUI.xml",
);
finish_trainer_load(&mut s);
s
}
/// Everything the manifest loads before the trainer addon — the chain a LoadOnDemand load lands
/// on. The stock row's label is a width-0 `<ButtonText>` (fit the text), so a measurer is seated
/// the way the app's VM has one at world entry.
fn trainer_script_base() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the kit's MenuBackdrop), app order
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit
load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // BenillaMoney_Set/_Clear/_SetColor live here
load_xml(&s, "TrainerFrame.xml");
s.run(
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 1 \
BenillaTrainerFrame_ApplyFilter()",
)
.unwrap();
s.set_text_measurer(Box::new(super::FixedWidthFont(7.0)));
// The reference's window calls `UpdateMicroButtons` on show and inherits the panel kit's
// templates, so the harness carries what the manifest loads before it, in the manifest's
// order (ScrollTemplates BEFORE UIPanelTemplates, 1846) — and the reference's own
// LoadOnDemand addon last, with the FrameXML templates file its list inherits from (1957).
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\ClassTrainerFrameTemplates.xml",
] {
load_xml(&s, f);
}
s
}
/// What follows the addon's files in its LoadOnDemand load (1957): the saved chunk (none in a
/// bare harness) and then its ADDON_LOADED, whose arm pushes the three filter globals into the
/// engine. The harness wants "used" shown too. The stock title reads `UnitName("npc")`, so the
/// trainer NPC is seated here as the app seats it on TRAINER_SHOW.
fn finish_trainer_load(s: &mut UiScript) {
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Sana Winterhoof".into()),
..Default::default()
}),
);
s.run("TRAINER_FILTER_USED = 1").unwrap();
s.fire_event(
"ADDON_LOADED",
vec![ScriptValue::Str("Blizzard_TrainerUI".into())],
);
}
/// Whether any rendered text quad carries `color` (within a small tolerance) — used to spot the
/// reddened cost coins, whose `(1.0, 0.1, 0.1)` is distinct from the unavailable row's `(0.9, 0, 0)`.
fn has_text_color(quads: &[ExtractedQuad], color: [f32; 3]) -> bool {
@@ -191,7 +249,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
// Hidden by default.
assert!(!s
.eval::<bool>("return BenillaTrainerFrame:IsVisible()")
.eval::<bool>("return ClassTrainerFrame:IsVisible()")
.unwrap());
// The app's feed: 50 copper in the purse + the warrior menu, then the open event with the name.
@@ -205,22 +263,22 @@ fn shipped_trainer_frame_drives_end_to_end() {
// Shown, title took the NPC name off arg1.
assert!(s
.eval::<bool>("return BenillaTrainerFrame:IsVisible()")
.eval::<bool>("return ClassTrainerFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaTrainerTitleText:GetText()")
s.eval::<String>("return ClassTrainerNameText:GetText()")
.unwrap(),
"Sana Winterhoof"
);
// Row 1 renders the "Arms" header (its name, no indent); row 2 the first service.
assert_eq!(
s.eval::<String>("return BenillaTrainerService1Text:GetText()")
s.eval::<String>("return ClassTrainerSkill1Text:GetText()")
.unwrap(),
"Arms"
);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
s.eval::<String>("return ClassTrainerSkill2Text:GetText()")
.unwrap(),
" Heroic Strike"
);
@@ -232,12 +290,12 @@ fn shipped_trainer_frame_drives_end_to_end() {
2
);
assert_eq!(
s.eval::<String>("return BenillaTrainerCostLabel:GetText()")
s.eval::<String>("return ClassTrainerCostLabel:GetText()")
.unwrap(),
"Cost:"
);
assert!(s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.eval::<bool>("return ClassTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
// Train buys the selected service — the row's spell id reaches the app's drain.
@@ -247,11 +305,11 @@ fn shipped_trainer_frame_drives_end_to_end() {
assert!(s.take_trainer_buys().is_empty(), "drained");
// Select the available-but-unaffordable service (Thunder Clap, index 6, 500c > 50c): Train disables
// and the cost coins redden (BenillaMoney_SetColor 1.0, 0.1, 0.1).
s.run("SelectTrainerService(6); BenillaTrainerFrame_Update()")
// and the cost coins redden (SetMoneyFrameColor 1.0, 0.1, 0.1).
s.run("this = ClassTrainerSkill6; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.eval::<bool>("return ClassTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
s.resolve();
assert!(
@@ -261,10 +319,10 @@ fn shipped_trainer_frame_drives_end_to_end() {
// Select the gated service (Cleave, index 3): the Requires: line is built from the level/skill/
// ability gates (byte-exact REQUIRES_LABEL), and Train stays disabled (unavailable).
s.run("SelectTrainerService(3); BenillaTrainerFrame_Update()")
s.run("this = ClassTrainerSkill3; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
let reqs = s
.eval::<String>("return BenillaTrainerSkillRequirements:GetText()")
.eval::<String>("return ClassTrainerSkillRequirements:GetText()")
.unwrap();
assert!(reqs.starts_with("Requires: "), "reqs: {reqs}");
for term in ["Level", "Swords", "Charge"] {
@@ -278,14 +336,14 @@ fn shipped_trainer_frame_drives_end_to_end() {
"a known prerequisite shows white with its rank: {reqs}"
);
assert!(!s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.eval::<bool>("return ClassTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
// The app's client-side close: clear the snapshot + fire TRAINER_CLOSED → the window hides.
s.set_trainer(None);
s.fire_event("TRAINER_CLOSED", vec![]);
assert!(!s
.eval::<bool>("return BenillaTrainerFrame:IsVisible()")
.eval::<bool>("return ClassTrainerFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -303,25 +361,25 @@ fn clicking_a_header_row_collapses_its_group() {
// Full tree: 2 headers + 4 services = 6 rows; row 2 is Heroic Strike.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
s.eval::<String>("return ClassTrainerSkill2Text:GetText()")
.unwrap(),
" Heroic Strike"
);
// Click the Arms header (row 1): its two services fold → 4 rows (H:Arms, H:Fury, Rend, Thunder
// Clap). Row 2 is now the Fury header.
s.run("BenillaTrainerSkillButton_OnClick(BenillaTrainerService1, 'LeftButton')")
s.run("this = ClassTrainerSkill1; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 4);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
s.eval::<String>("return ClassTrainerSkill2Text:GetText()")
.unwrap(),
"Fury",
"Arms folded; its header (row 1) now abuts the Fury header (row 2)"
);
// Click it again → expands back to 6 rows.
s.run("BenillaTrainerSkillButton_OnClick(BenillaTrainerService1, 'LeftButton')")
s.run("this = ClassTrainerSkill1; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -341,7 +399,9 @@ fn filter_hides_a_state_keeping_headers() {
// Drive the dropdown's own click handler for the "used" row: the row button rides as `this` with
// its pre-click state (checked=on, value="used"), exactly as UIDropDownMenuButton_OnClick invokes
// the row func. It flips the engine filter off and repaints. Rend drops (6 → 5); headers remain.
s.run("this = { value = 'used', checked = 1 }; BenillaTrainerFilterDropDown_OnClick()")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button3; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
@@ -372,7 +432,7 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
// Open the menu the way the capsule's arrow does. Row 2 is "Unavailable" (Initialize's order),
// checked because trainer_script() turned every state on.
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row_checked = |s: &mut UiScript| {
@@ -415,9 +475,9 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
// what the clicks left behind (the two states left on, "used" still on from trainer_script()).
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap(); // same owner → closes
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap(); // re-open → re-Initialize
assert!(
!row_checked(&mut s),
@@ -460,11 +520,11 @@ fn wheel_over_a_row_scrolls_the_list() {
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row1 = "return BenillaTrainerService1Text:GetText()";
let row1 = "return ClassTrainerSkill1Text:GetText()";
// At the top: row 1 is the "Arms" header, row 2 the first service.
assert_eq!(s.eval::<String>(row1).unwrap(), "Arms");
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
s.eval::<String>("return ClassTrainerSkill2Text:GetText()")
.unwrap(),
" Service 01"
);
@@ -485,7 +545,7 @@ fn wheel_over_a_row_scrolls_the_list() {
// reference's and is asserted as "a page, not a row"; pinning the exact row count would pin a
// pixel arithmetic whose inputs resolve a frame apart.
let offset = s
.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.eval::<i64>("return ClassTrainerListScrollFrame.offset or -1")
.unwrap();
assert!(
offset > 1,
@@ -502,7 +562,7 @@ fn wheel_over_a_row_scrolls_the_list() {
// there is the clamp working, not the wheel failing.
s.mouse_wheel(x, y, -1.0);
assert_eq!(
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
s.eval::<i64>("return ClassTrainerListScrollFrame.offset or -1")
.unwrap(),
offset,
"clamped at the bottom (numItems - numToDisplay), never past it"
@@ -512,7 +572,7 @@ fn wheel_over_a_row_scrolls_the_list() {
s.mouse_wheel(x, y, 1.0);
s.mouse_wheel(x, y, 1.0);
assert_eq!(
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
s.eval::<i64>("return ClassTrainerListScrollFrame.offset or -1")
.unwrap(),
0,
"back at the top, clamped"
@@ -544,13 +604,13 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
// The name really is the button's own label now — a child FontString would leave
// GetFontString() nil and no per-state font could reach it.
assert!(
s.eval::<bool>("return BenillaTrainerService1:GetFontString() ~= nil")
s.eval::<bool>("return ClassTrainerSkill1:GetFontString() ~= nil")
.unwrap(),
"the row name is the Button's ButtonText, the only region per-state fonts reach"
);
// Cleave (index 3) is unavailable → red when not selected. Select it → its name goes white.
s.run("SelectTrainerService(3); BenillaTrainerFrame_Update()")
s.run("this = ClassTrainerSkill3; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
s.resolve();
assert!(
@@ -559,7 +619,7 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
);
// Select Heroic Strike (index 2): Cleave is no longer selected → back to unavailable red.
s.run("SelectTrainerService(2); BenillaTrainerFrame_Update()")
s.run("this = ClassTrainerSkill2; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
s.resolve();
assert!(
@@ -615,8 +675,21 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
"the wheel scroll is silent"
);
// The down arrow (enabled at the top), though — clicking it plays the ref's arrow click.
s.run("BenillaTrainerListScrollFrameScrollBarScrollDownButton:Click()")
// That one notch reached the BOTTOM, and the arrow to click afterwards is therefore the UP
// one. `ScrollFrameTemplate_OnMouseWheel` moves half the bar's height — 76px on this window's
// 152-tall bar — and `SetValue` snaps that onto the row lattice (step 16), which rounds 76 up
// to the range's own 80 (2133). `FauxScrollFrame_Update` then greys the DOWN arrow on its
// `GetValue() - scrollFrameHeight == 0` test, so clicking it would be clicking a disabled
// button. Pinned rather than worked around: this snap is the reference's.
assert_eq!(
s.eval::<f64>("return ClassTrainerListScrollFrameScrollBar:GetValue()")
.unwrap(),
80.0,
"one wheel notch = 76px, snapped to the 5-row bottom of an 80px range"
);
// The up arrow, now the enabled one — clicking it plays the ref's arrow click.
s.run("ClassTrainerListScrollFrameScrollBarScrollUpButton:Click()")
.unwrap();
assert!(
s.take_sounds().contains(&click),
@@ -640,12 +713,12 @@ fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
s.set_trainer(Some(long_menu())); // 16 rows > 11 visible → the bar shows
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
let offset = |s: &mut UiScript| {
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
s.eval::<i64>("return ClassTrainerListScrollFrame.offset or -1")
.unwrap()
};
let click = |s: &mut UiScript, which: &str| {
s.run(&format!(
"BenillaTrainerListScrollFrameScrollBarScroll{which}Button:Click()"
"ClassTrainerListScrollFrameScrollBarScroll{which}Button:Click()"
))
.unwrap();
};
@@ -667,42 +740,70 @@ fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
}
/// **The filter is remembered across a restart** (decision 1128) — the whole persistence path, in
/// one test: the shipped XML declares its three globals for saving at file scope, a dropdown toggle
/// writes the *global* (not only the engine mask), the serialized file carries it, and a fresh VM
/// that loads the same XML and then executes that file comes up with the toggled filter applied.
///
/// It also pins the two halves that are easy to "simplify" apart and silently break: writing only
/// the engine mask in the click handler would lose the choice at the next list packet (the reference
/// rebuilds that mask on every one), and applying the globals only in `<OnShow>` would leave the
/// first painted frame under the reset.
/// The reference's own restart, through its own mechanism (1957): the addon is a LoadOnDemand
/// registry row read off the chain; `LoadAddOn` runs its files (file-scope defaults), then its
/// per-addon saved file over them, then fires ADDON_LOADED — whose arm pushes the three filter
/// globals into the engine. A dropdown toggle writes the GLOBAL; the addon's `## SavedVariables`
/// carry it into the file; a fresh VM loading the addon comes up with the toggle applied.
#[test]
fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
let _data = benilla_formats::wow_data_or_skip!();
let toc =
super::reference_ui::read("Interface/AddOns/Blizzard_TrainerUI/Blizzard_TrainerUI.toc")
.map(|b| benilla_ui::toc::Toc::parse(&benilla_ui::source::decode(&b)))
.expect("the addon's toc off the chain");
assert!(toc.load_on_demand(), "the reference ships it LoadOnDemand");
let info = || {
let mut i = super::addons::info_from_toc("Blizzard_TrainerUI", &toc);
i.chain = true;
i
};
let saved_dir =
std::env::temp_dir().join(format!("benilla-trainer-saved-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&saved_dir);
std::fs::create_dir_all(&saved_dir).unwrap();
let boot = |s: &mut UiScript| {
s.set_addon_chain_reader(Box::new(super::reference_ui::read));
s.register_addons(vec![info()], None, Some(saved_dir.clone()), None);
s.set_unit(
"npc",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Sana Winterhoof".into()),
..Default::default()
}),
);
s.set_money(50);
s.set_trainer(Some(menu()));
// The reference's arm: TRAINER_SHOW → ClassTrainerFrame_LoadUI → UIParentLoadAddOn.
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return IsAddOnLoaded('Blizzard_TrainerUI') == 1")
.unwrap(),
"loaded on demand, off the chain"
);
};
// This XML declared exactly the reference's three globals, in its order. Filtered to the
// trainer's own prefix because the registry belongs to the whole UI: every file the harness
// loads adds its own (GameTooltip.xml's SHOW_NEWBIE_TIPS since 1136), and what the OTHER files
// register is not this test's business.
let declared: Vec<String> = s
.saved_variable_names()
.into_iter()
.filter(|n| n.starts_with("TRAINER_"))
.collect();
let mut s = trainer_script_base();
boot(&mut s);
// The reference's own file-scope default hides known spells (TRAINER_FILTER_USED = 0), so
// the one used service is out on the very first paint: 2 headers + 3 of the 4 services.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
assert_eq!(
declared,
info().saved_variables,
vec![
"TRAINER_FILTER_AVAILABLE",
"TRAINER_FILTER_UNAVAILABLE",
"TRAINER_FILTER_USED",
]
],
"the toc's own three, in its order"
);
// Toggle "Unavailable" off through the dropdown row's own handler (pre-click state on `this`).
s.run("this = { value = 'unavailable', checked = 1 }; BenillaTrainerFilterDropDown_OnClick()")
// Toggle "Unavailable" off through the dropdown row's own handler.
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
@@ -710,19 +811,17 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
0,
"the click must write the SAVED global, not just the engine mask"
);
let text = s.saved_variables_text();
let text = s.saved_variables_text_for(&info().saved_variables);
assert!(
text.contains("TRAINER_FILTER_UNAVAILABLE = 0"),
"the file carries the toggle: {text}"
);
std::fs::write(saved_dir.join("Blizzard_TrainerUI.lua"), &text).unwrap();
// The restart: a fresh VM with the same XML (file-scope defaults stand), then the saved file
// executed over them, then a trainer opens.
let mut fresh = trainer_script();
fresh.run(&text).unwrap();
fresh.set_money(50);
fresh.set_trainer(Some(menu()));
fresh.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
// The restart: a fresh VM loads the addon on demand — files, then the saved file, then
// ADDON_LOADED — and the first paint is already filtered.
let mut fresh = trainer_script_base();
boot(&mut fresh);
assert!(
!fresh
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
@@ -731,14 +830,10 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
);
assert_eq!(
fresh.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
4,
"Cleave (the unavailable service) is hidden on the very first paint after the restart"
);
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
let _ = std::fs::remove_dir_all(&saved_dir);
}
/// A **new list packet resets the engine's filter mask** to the builder's own default — mask 3 at a
@@ -749,7 +844,10 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
let mut s = trainer_script();
s.set_trainer(Some(menu()));
s.run("BenillaTrainerFrame_ApplyFilter()").unwrap();
s.fire_event(
"ADDON_LOADED",
vec![ScriptValue::Str("Blizzard_TrainerUI".into())],
);
// Collapse a group, and turn a state off directly (no global) — both are engine-side state.
s.run("CollapseTrainerSkillLine(1) SetTrainerServiceTypeFilter('used', 1)")
.unwrap();
@@ -758,7 +856,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
.unwrap());
assert!(s.eval::<i64>("return GetNumTrainerServices()").unwrap() < 6);
s.reset_trainer_filter(0);
s.reset_trainer_list_state(0);
assert!(
!s.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap(),
@@ -777,7 +875,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
);
// A mount trainer wants available|used instead — what makes a known mount visible at all.
s.reset_trainer_filter(1);
s.reset_trainer_list_state(1);
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap());
@@ -879,7 +977,7 @@ fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
// Find each name's row by what it painted (the group comparator owns the order, not this test).
let row_of = |s: &mut UiScript, needle: &str| -> i64 {
s.eval::<i64>(&format!(
"for i = 1, 11 do local b = getglobal('BenillaTrainerService' .. i) \
"for i = 1, 11 do local b = getglobal('ClassTrainerSkill' .. i) \
local t = b:GetText() if t and strfind(t, '{needle}', 1, 1) then return i end \
end return 0"
))
@@ -891,9 +989,9 @@ fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
let geom = |s: &mut UiScript, row: i64| -> (f32, f32, f32, f32) {
s.eval::<(f32, f32, f32, f32)>(&format!(
"local b = getglobal('BenillaTrainerService{row}') \
"local b = getglobal('ClassTrainerSkill{row}') \
local n = b:GetFontString() \
return n:GetHeight(), n:GetRight(), b.subtext:GetLeft(), b:GetHeight()"
return n:GetHeight(), n:GetRight(), getglobal(b:GetName() .. 'SubText'):GetLeft(), b:GetHeight()"
))
.unwrap()
};
@@ -940,17 +1038,18 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
let mut s = trainer_script();
// The reference's own file-scope defaults (1128), then the player's choice below.
s.run(
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0 \
BenillaTrainerFrame_ApplyFilter()",
)
.unwrap();
s.run("TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0")
.unwrap();
s.fire_event(
"ADDON_LOADED",
vec![ScriptValue::Str("Blizzard_TrainerUI".into())],
);
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_filter(open.trainer_type);
s.reset_trainer_list_state(open.trainer_type);
open.fresh_list = false;
}
s.set_trainer(Some(state));
@@ -968,11 +1067,11 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
let mut open = TrainerOpen::default();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, menu(), "TRAINER_SHOW");
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
assert!(
!filter_on(&mut s, "unavailable"),
@@ -1005,37 +1104,197 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
"and the list is still his — the folded Arms group is empty of learnables now, so it goes \
with its header, leaving Fury over Thunder Clap"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
assert!(
!s.eval::<bool>("return DropDownList1Button2Check:IsVisible() and true or false")
.unwrap(),
"the dropdown agrees with the list, instead of claiming a filter the list ignores"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
// He walks away and comes back: THIS packet opens a window, so the reference's reset lands —
// and the window's own OnShow/TRAINER_SHOW puts the saved globals straight back over it. The
// filter survives (it is saved), the collapse does not (it is engine-side, and the reset clears
// it) — the reference's own split.
// He walks away and comes back: THIS packet opens a window, so the reference's reset lands
// (`0x4d75d9` writes the mask to 3 on every list) — and nothing puts the saved globals back
// over it: the stock addon pushes them once, on ITS ADDON_LOADED, which a LoadOnDemand
// addon fires on the session's first trainer. The collapse is engine-side and clears too
// (1957; the transcription used to re-push on show, which the reference never does).
open.clear();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
feed(&mut s, &mut open, learned, "TRAINER_SHOW");
assert!(
!filter_on(&mut s, "unavailable"),
"the saved filter is pushed back over the packet reset"
filter_on(&mut s, "unavailable"),
"a fresh list is the reference's reset: unavailable shows again"
);
assert!(filter_on(&mut s, "available"));
assert!(
filter_on(&mut s, "available"),
"and the states he never turned off are still on"
!filter_on(&mut s, "used"),
"and the reset's mask is 3 — used stays hidden"
);
assert_eq!(
rows(&mut s),
2,
"nothing collapsed now — but still filtered"
s.eval::<i64>("return TRAINER_FILTER_UNAVAILABLE").unwrap(),
0,
"his saved choice is still the global's, for the next session's first trainer"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The detail pane after learning a spell** — the director's report: the trained row vanished, the
/// highlight landed on the row that slid into its place, and the name/icon/cost/`Requires:` block
/// below went on describing the spell that was gone.
///
/// The stock Lua repaints that pane on `TRAINER_UPDATE` through **one** door:
/// `ClassTrainer_SelectFirstLearnableSkill`, the only thing that restores
/// `ClassTrainerFrame.showSkillDetails` after `ClassTrainerTrainButton_OnClick` cleared it —
/// `ClassTrainer_SetSelection` early-returns while it is nil. Which door `TRAINER_UPDATE` takes is
/// decided entirely by the engine's `GetTrainerSelectionIndex()`, and the reference's answer for a
/// service that has gone off screen is its index in the hidden **tail** — past
/// `GetNumTrainerServices()`, never a live row (`0x4d7520`; see `benilla_ui`'s `selected_row`). So
/// the reference takes the `> 1` branch, resets the scroll, and hides the pane.
///
/// **What a player sees after training, in the reference and now here: an empty detail pane and no
/// highlighted row.** It does not advance to the next spell — that is the *fresh window's*
/// behaviour, which is what a selection reading 0 or 1 would have produced.
///
/// Driven through the real [`crate::ui_trainer::TrainerOpen`] like B256's test above, for the same
/// reason: the packet-vs-repaint decision under test is the app's own.
#[test]
fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_one() {
use crate::ui_trainer::TrainerOpen;
const DAZALAR: u64 = 0xabc;
let mut s = trainer_script();
// The reference's own file-scope defaults (1128) — "already known" OFF, which is what makes a
// learned service leave the list at all.
s.run("TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0")
.unwrap();
s.fire_event(
"ADDON_LOADED",
vec![ScriptValue::Str("Blizzard_TrainerUI".into())],
);
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_list_state(open.trainer_type);
open.fresh_list = false;
}
s.set_trainer(Some(state));
s.fire_event(event, vec![ScriptValue::Str("Dazalar".into())]);
}
let pane = |s: &mut UiScript| -> (bool, String) {
s.eval::<(bool, String)>(
"return ClassTrainerSkillName:IsVisible() and true or false, \
ClassTrainerSkillName:GetText() or ''",
)
.unwrap()
};
let row_name = |s: &mut UiScript, row: i64| {
s.eval::<String>(&format!("return (GetTrainerServiceInfo({row})) or ''"))
.unwrap()
};
let mut open = TrainerOpen::default();
open.open(DAZALAR, 0, vec![], "Hello, warrior!".into());
feed(&mut s, &mut open, menu(), "TRAINER_SHOW");
// Row 2 is the first learnable — the row the window opens on, and the one he trains.
assert_eq!(row_name(&mut s, 2), "Heroic Strike");
assert_eq!(
pane(&mut s),
(true, "Heroic Strike".into()),
"the window opens describing its own selection"
);
s.run("ClassTrainerTrainButton:Click()").unwrap();
assert_eq!(
s.take_trainer_buys(),
vec![78],
"the Train button bought the selected row"
);
// The app re-asks for the list and marks its answer the repaint it is (B256); the bought
// service comes back gray and, with "already known" off, leaves the list. Cleave slides up into
// row 2 under where the selection used to be.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, warrior!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
row_name(&mut s, 2),
"Cleave",
"the trained row is gone from the list and the next one took its place"
);
assert!(
!pane(&mut s).0,
"and the pane below is empty rather than still describing the spell he just learned: {:?}",
pane(&mut s).1
);
assert!(
!s.eval::<bool>("return ClassTrainerSkillHighlightFrame:IsVisible()")
.unwrap(),
"nothing is highlighted either — the selection is off screen, not on Cleave"
);
assert!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap()
> s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
"because the engine answers with the hidden row, which is what steers the window there"
);
// And he can pick the next one up by hand, which is the whole of the recovery.
s.run("this = ClassTrainerSkill2 ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
assert_eq!(pane(&mut s), (true, "Cleave".into()));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The filter click has to repaint the LIST, not just the engine** (decision 2244) — the
/// director's "filter no longer work", with the dropdown showing Available only and red rows still
/// under it.
///
/// Every other test in this file asserts `GetNumTrainerServices()`, which is the engine's own
/// answer and moves the instant the mask does. What the player looks at is the row buttons, and
/// those are painted by `ClassTrainerFrame_Update` — which after a filter click is reached from
/// exactly one place: `ClassTrainerFrame_OnEvent`'s `TRAINER_UPDATE` arm. The stock click handler
/// fires no event and calls no update (its `ScrollBar:SetValue(0)` is a no-op at zero), because in
/// the reference the **engine** fires it from the mask-commit thunk `0x4d8c90`
/// (`mov ecx,0x136; jmp 0x703e50`). Ours did not, so the checkbox moved and the list did not.
#[test]
fn a_filter_click_repaints_the_rows_the_player_is_looking_at() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
// What the player sees: the painted row buttons, counted the way the eye does.
let painted = "\
local n = 0
for i = 1, 11 do
local b = getglobal(\"ClassTrainerSkill\"..i)
if b and b:IsVisible() then n = n + 1 end
end
return n";
let count = |s: &mut UiScript| s.eval::<i64>(painted).unwrap();
assert_eq!(count(&mut s), 6, "six rows on screen before any filtering");
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button3; UIDropDownMenuButton_OnClick()")
.unwrap();
// The engine's queued `TRAINER_UPDATE` lands on the next tick (a binding cannot re-enter the
// handler dispatch from inside Lua — the queue's own contract).
s.tick(0.016);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
count(&mut s),
5,
"the already-known row must LEAVE THE SCREEN, not just the engine's count — this is the \
director's report: the checkbox moved and the list underneath did not"
);
}
@@ -0,0 +1,134 @@
//! The stock `TutorialFrame.xml` (decision 1976) driven engine-only: an alert button per
//! `TUTORIAL_TRIGGER`, the window a click opens over the published id's strings, the
//! `FlagTutorial` it makes, the unticked checkbox's `ClearTutorials` on hide, and
//! `CINEMATIC_STOP` clicking the Welcome alert.
use benilla_ui::script::{ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
fn session() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run("function PlaySound() end").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Localization.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\TutorialFrame.xml",
] {
load_xml(&s, f);
}
s.resolve();
s
}
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap()
}
fn trigger(s: &mut UiScript, published: i64) {
s.fire_event("TUTORIAL_TRIGGER", vec![ScriptValue::Int(published)]);
}
/// A trigger queues an alert button; its click opens the window on the id's title and text and
/// acknowledges the id (0-based on the drain); a second trigger queues a second button.
#[test]
fn a_trigger_queues_an_alert_whose_click_opens_the_window_and_flags_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
assert!(!visible(&s, "TutorialFrameAlertButton1"));
assert!(!visible(&s, "TutorialFrame"));
trigger(&mut s, 41);
assert!(
visible(&s, "TutorialFrameAlertButton1"),
"the alert button shows"
);
assert_eq!(
s.eval::<String>("return TutorialFrameAlertButton1.tooltip")
.unwrap(),
"Elite Quests"
);
trigger(&mut s, 42);
assert!(
visible(&s, "TutorialFrameAlertButton2"),
"a second id takes the next button"
);
s.run("TutorialFrameAlertButton1:Click()").unwrap();
assert!(visible(&s, "TutorialFrame"), "the click opens the window");
assert_eq!(
s.eval::<String>("return TutorialFrameTitle:GetText()")
.unwrap(),
"Elite Quests"
);
assert!(s
.eval::<String>("return TutorialFrameText:GetText()")
.unwrap()
.starts_with("You have accepted an elite quest"));
assert_eq!(
s.take_tutorial_flag_requests(),
vec![40],
"FlagTutorial(41) → the 0-based id"
);
assert!(
!visible(&s, "TutorialFrameAlertButton1"),
"the clicked alert is spent; the other stays"
);
assert!(visible(&s, "TutorialFrameAlertButton2"));
// Okay hides it; the checkbox is ticked, so nothing is cleared.
s.run("TutorialFrameOkayButton:Click()").unwrap();
assert!(!visible(&s, "TutorialFrame"));
assert_eq!(s.take_tutorial_clears(), 0);
}
/// Unticking "Display Tips" and closing calls `ClearTutorials` and drops every alert.
#[test]
fn an_unticked_checkbox_clears_the_tutorials_on_hide() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
trigger(&mut s, 41);
trigger(&mut s, 42);
s.run("TutorialFrameAlertButton1:Click()").unwrap();
let _ = s.take_tutorial_flag_requests();
s.run("TutorialFrameCheckButton:SetChecked(nil) TutorialFrame:Hide()")
.unwrap();
assert_eq!(s.take_tutorial_clears(), 1, "ClearTutorials() on hide");
assert!(
!visible(&s, "TutorialFrameAlertButton2"),
"every alert dropped"
);
}
/// `CINEMATIC_STOP` clicks the Welcome alert (id 42) if it is queued — the first thing a new
/// character sees after the intro — and moves the window to the screen's centre.
#[test]
fn cinematic_stop_opens_the_welcome_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
trigger(&mut s, 42);
trigger(&mut s, 1);
s.fire_event("CINEMATIC_STOP", vec![]);
assert!(visible(&s, "TutorialFrame"));
assert_eq!(
s.eval::<String>("return TutorialFrameTitle:GetText()")
.unwrap(),
"Welcome to World of Warcraft!"
);
assert_eq!(s.take_tutorial_flag_requests(), vec![41]);
assert!(
visible(&s, "TutorialFrameAlertButton2"),
"the Questgivers alert stays queued"
);
}
@@ -0,0 +1,130 @@
//! The reference's `UIMenu` kit (`Interface\FrameXML\UIMenu.xml` + `UIMenu.lua`) driven as the
//! chat bubble drives it — a row is a `UIMenuButtonTemplate` whose label is the Button's OWN text
//! (`UIMenu_AddButton` → `button:SetText(text)`, no `<ButtonText>` in the template) and whose
//! shortcut is a sibling FontString anchored `RIGHT`. `UIMenu_Initialize` fixes every row at
//! 104×16 and the menu at 128 wide; nothing auto-sizes in 1.12.
//!
//! What places the label is the template's `<NormalFont inherits="GameFontNormal"
//! justifyH="LEFT"/>`: `SetText`'s lazy label creation funnels through the adopter
//! `CSimpleButton::SetFontString 0x778d20`, which anchors an unanchored label to the button by the
//! NORMAL embedded font's justify word (`[button+0x390]`: LEFT→LEFT, RIGHT→RIGHT, else CENTER —
//! wow-re `system/ui/scratch/resize-bounds-and-button-fontstring.md` §5.2, VERIFIED). Decision 1996.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// The chat stack the menu lives in — the same chain `chat_tests` loads for the window, with the
/// fixed-advance font so the rows have real widths in the tick that asks.
fn chat_menu() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
r"Interface\FrameXML\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\GlobalStrings.lua",
r"Interface\FrameXML\BasicControls.xml",
r"Interface\FrameXML\LocaleProperties.lua",
r"Interface\FrameXML\StaticPopup.xml",
r"Interface\FrameXML\GameTooltip.xml",
r"Interface\FrameXML\UIMenu.xml",
r"Interface\FrameXML\ChatFrame.xml",
r"Interface\FrameXML\UIDropDownMenu.xml",
r"Interface\FrameXML\FloatingChatFrame.xml",
] {
load_xml(&s, f);
}
s.set_text_measurer(Box::new(super::FixedWidthFont(8.0)));
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
let _ = s.errors();
s
}
fn edges(s: &mut UiScript, name: &str) -> (f32, f32) {
s.eval::<(f32, f32)>(&format!("return {name}:GetLeft(), {name}:GetRight()"))
.unwrap_or_else(|e| panic!("{name}'s rect: {e}"))
}
/// **The label hugs the row's LEFT edge and the shortcut its RIGHT** — the reference's own
/// anchoring, so "Macro" and "/macro" never meet (B-report: the labels drew centred and the
/// Macro row read "Macro/macro").
#[test]
fn chat_menu_rows_left_align_their_label_and_right_align_their_shortcut() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_menu();
s.run("ChatMenu:Show()").unwrap();
assert!(s.errors().is_empty(), "opening it raises: {:?}", s.errors());
s.resolve();
// The Macro row is the tenth `UIMenu_AddButton` in `ChatMenu_OnLoad` (ChatFrame.lua l.2301).
assert_eq!(
s.eval::<String>("return ChatMenuButton10:GetText()")
.unwrap(),
"Macro"
);
assert_eq!(
s.eval::<String>("return ChatMenuButton10ShortcutText:GetText()")
.unwrap(),
"/macro"
);
// The control: `UIMenu_Initialize` fixed the row at 104 wide; nothing here may resize it.
let (row_l, row_r) = edges(&mut s, "ChatMenuButton10");
assert_eq!(row_r - row_l, 104.0, "UIMENU_BUTTON_WIDTH");
// The label's implicit anchor is LEFT→LEFT (0,0) — the adopter reading the template's
// `<NormalFont justifyH="LEFT"/>` — so its left edge IS the row's.
let (p, rp, x, y): (String, String, f32, f32) = s
.eval(
"local p, _, rp, x, y = ChatMenuButton10:GetFontString():GetPoint(1) \
return p, rp, x, y",
)
.unwrap();
assert_eq!(
(p.as_str(), rp.as_str(), x, y),
("LEFT", "LEFT", 0.0, 0.0),
"the label is anchored by the normal font's justify"
);
let (label_l, label_r) = edges(&mut s, "ChatMenuButton10:GetFontString()");
assert_eq!(label_l, row_l, "the label starts at the row's left edge");
// The shortcut is the template's own `<Anchor point="RIGHT"/>` string.
let (short_l, short_r) = edges(&mut s, "ChatMenuButton10ShortcutText");
assert_eq!(short_r, row_r, "the shortcut ends at the row's right edge");
// And the two never overlap — with 8-px glyphs "Macro" spans 40 and "/macro" 48 of the 104,
// which a centred label (32..72 against 56..104) fails.
assert!(
label_r <= short_l,
"label {label_l}..{label_r} collides with shortcut {short_l}..{short_r}"
);
// Every row, not only the one the report named.
for i in 1..=s.eval::<i64>("return ChatMenu.numButtons").unwrap() {
let row = format!("ChatMenuButton{i}");
let (rl, _) = edges(&mut s, &row);
let (ll, _) = edges(&mut s, &format!("{row}:GetFontString()"));
assert_eq!(ll, rl, "row {i}'s label starts at its left edge");
}
}
/// The label's *query* surface agrees with its paint: a lazily created label answers the normal
/// font's justify and object (the adopter links it on the spot — `0x779810`), not a fresh
/// FontString's defaults.
#[test]
fn a_menu_rows_label_reports_the_normal_fonts_justify_and_object() {
let _data = benilla_formats::wow_data_or_skip!();
let s = chat_menu();
s.run("ChatMenu:Show()").unwrap();
assert_eq!(
s.eval::<String>("return ChatMenuButton1:GetFontString():GetJustifyH()")
.unwrap(),
"LEFT"
);
assert!(
s.eval::<bool>("return ChatMenuButton1:GetFontString():GetFontObject() == GameFontNormal")
.unwrap(),
"the label inherits the button's normal font object"
);
}
@@ -1,4 +1,4 @@
//! `assets/ui/UIParent.xml`'s addon-facing helpers, driven from Lua the way an addon drives them.
//! Stock `Interface\FrameXML\UIParent.xml`'s addon-facing helpers, driven from Lua the way an addon drives them.
//!
//! The panel/ESC machinery in that file is covered by `panel_tests` and `escape_tests`; this is for
//! the loose functions the reference's `UIParent.lua` also defines, which benilla itself may never
@@ -9,16 +9,13 @@ use benilla_ui::script::UiScript;
/// Fonts (for any `inherits=`), then UIParent — the manifest's order.
fn ui_parent() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in ["Fonts.xml", "UIParent.xml"] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
// Through the chain-aware reader: this list names chain files now, and a
// reader that joins `assets/ui` cannot resolve one (1838, 1887, 1888).
for file in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
] {
crate::ui_script::test_ui::load_ui(&s, file);
}
s.set_screen_size(1024.0, 768.0);
s.run(
@@ -166,13 +163,8 @@ fn mouse_is_over_survives_a_frame_with_no_resolved_rect() {
#[test]
fn raid_class_colors_is_the_references_own_nine() {
let mut s = UiScript::new().unwrap();
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/Fonts.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// RAID_CLASS_COLORS comes off the chain with the font registry since 1888.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
s.set_screen_size(1024.0, 768.0);
// PaintChips-2.0's own line, verbatim in shape — the one that was raising.
@@ -225,13 +217,8 @@ fn raid_class_colors_is_the_references_own_nine() {
#[test]
fn the_font_path_globals_are_the_references_own_four() {
let mut s = UiScript::new().unwrap();
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/Fonts.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// The four font-path globals come off the chain with the registry since 1888.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
s.set_screen_size(1024.0, 768.0);
for name in [
@@ -300,3 +287,42 @@ fn the_font_path_globals_are_the_references_own_four() {
"|cffffff9a"
);
}
/// `MouseIsOver` divides the cursor by the frame's effective scale, as the reference does
/// (UIParent.lua l.1389-1390): a frame scaled to 0.5 whose screen footprint holds the cursor
/// answers 1 — with the division written out (the old "the scale is the constant 1" note) it
/// answered nil under the very cursor that had just entered it (decision 1985).
#[test]
fn mouse_is_over_reads_a_scaled_frame_in_its_own_units() {
let mut s = ui_parent();
s.run(
r#"Scaled = CreateFrame("Frame", "Scaled", UIParent) Scaled:SetWidth(200) Scaled:SetHeight(100)
Scaled:SetPoint("BOTTOMLEFT", 100, 100) Scaled:SetScale(0.5)"#,
)
.unwrap();
s.resolve();
// The frame's screen footprint is its own-unit box times its effective scale (offsets and
// size both scale); the probe sits at its centre, then just past its right edge.
let (l, r, b, t, eff) = s
.eval::<(f64, f64, f64, f64, f64)>(
"return Scaled:GetLeft(), Scaled:GetRight(), Scaled:GetBottom(), Scaled:GetTop(), Scaled:GetEffectiveScale()",
)
.unwrap();
assert!(
(eff - 0.5).abs() < 1e-6 && (r - l - 200.0).abs() < 1e-3,
"own units: {l}..{r} at {eff}"
);
let (cx, cy) = (((l + r) * 0.5 * eff) as f32, ((b + t) * 0.5 * eff) as f32);
s.mouse_move(cx, cy);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Scaled)").unwrap(),
Some(1),
"the cursor at the scaled footprint's centre ({cx}, {cy})"
);
s.mouse_move((r * eff) as f32 + 10.0, cy);
assert_eq!(
s.eval::<Option<i64>>("return MouseIsOver(Scaled)").unwrap(),
None,
"past the scaled footprint (inside the unscaled one) is outside"
);
}
@@ -19,15 +19,20 @@ fn load_unit_frames(s: &UiScript) {
// drawing an empty plate. Hand-setting the union of those is a second copy of the reference's
// own file; naming the file is the only version that cannot drift. (`DEAD` is l.898 of it.)
load_xml(s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(s, "Fonts.xml");
load_xml(s, "UIParent.xml");
load_xml(s, "Interface\\FrameXML\\Fonts.xml");
load_xml(s, r"Interface\FrameXML\UIParent.xml");
// The bars' numerals machinery (decision 1082), which the manifest loads immediately ahead of
// UnitFrames.xml and which every bar's OnLoad wires into since 1143.
load_xml(s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(s, "GameTooltip.xml");
load_xml(s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(s, "Interface\\FrameXML\\GameTooltip.xml");
// `FACTION_BAR_COLORS` for the stock `GameTooltip_UnitColor` — ReputationFrame.lua's (1968).
load_xml(s, r"Interface\FrameXML\ReputationFrame.lua");
load_xml(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(s, "UnitPopup.xml");
load_xml(s, "Interface\\FrameXML\\BasicControls.xml"); // `TEXT`, which UnitPopup.lua reads at file scope
load_xml(s, "Interface\\FrameXML\\UnitPopup.xml");
load_xml(s, "Interface\\FrameXML\\BuffFrame.xml");
load_xml(s, "Interface\\FrameXML\\UnitFrame.xml");
load_xml(s, "Interface\\FrameXML\\CombatFeedback.xml");
@@ -464,6 +469,7 @@ fn left_clicking_the_player_frame_targets_self() {
// The player's own guid, which this fixture leaves unset — spelled out because a bare 0
// is also the reference's "ungrouped" sentinel and this party has a member.
leader_guid: 0,
own_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -546,6 +552,7 @@ fn raid_mark_clicks_through_the_nested_level() {
}],
leader_index: 0, // we lead — the mark rows are leader-gated
leader_guid: 0, // the player's own guid; this fixture leaves it unset
own_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -596,13 +603,11 @@ fn raid_mark_clicks_through_the_nested_level() {
.unwrap();
s.mouse_button(sx as f32, sy as f32, "LeftButton", true);
s.mouse_button(sx as f32, sy as f32, "LeftButton", false);
// RED while the `SetRaidTarget` engine binding is absent, and deliberately left that way.
// `UnitPopup.xml`'s row calls `SetRaidTargetIcon(menu.unit, mark)`; the definition of that
// used to be ours, and since the migration it is the reference's own — `TargetFrame.lua`
// l.486-492 — whose whole body is `SetRaidTarget(unit, 0 or index)`. `SetRaidTarget` is an
// engine verb this house does not have yet, so the click raises
// "attempt to call global 'SetRaidTarget' (a nil value)" and no intent is queued. 1203: it
// gets built, never stubbed.
// l.486-492 — whose whole body is `SetRaidTarget(unit, 0 or index)`. That is the ENGINE
// verb, built by 1820 (`benilla-ui` `script/party.rs`), never the wrapper's name on our
// body: this asserts the intent it queues, and `:638` asserts nothing raised.
assert_eq!(
s.take_party_requests(),
vec![benilla_ui::script::PartyRequest::SetRaidTarget {
@@ -644,12 +649,23 @@ fn shipped_target_frame_runs_the_level_law() {
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
// GetDifficultyColor's own load chain (the quest log window, its ref home).
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\CharacterFrameTemplates.xml"); // the window tab (1993)
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\QuestFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MainMenuBarMicroButtons.xml");
load_xml(&s, "Interface\\FrameXML\\QuestLogFrame.xml");
// The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state
// the −1 gate and GetQuestGreenRange read) — the app keeps the two in step.
s.set_player_req_state(PlayerReqState {
@@ -1193,23 +1209,30 @@ fn the_party_art_paints_over_the_bars() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The loot test's prefix (`loot_tests.rs`): PartyFrame's inline <Script> reads
// StaticPopupDialogs, which UiPanels.xml defines, and its per-member dropdown OnLoad walks the
// whole popup kit.
// StaticPopupDialogs, which the chain's `StaticPopup.xml` defines, and its per-member dropdown
// OnLoad walks the whole popup kit.
// GlobalStrings first, for the same reason `load_unit_frames` names it — the stock unit-frame
// files resolve it at LOAD (`CombatFeedback.lua` l.7-17, `UnitFrame.lua` l.1-6).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\ReputationFrame.lua"); // FACTION_BAR_COLORS (1968)
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
// Before UnitPopup: that file reads ITEM_QUALITY_COLORS at FILE SCOPE and its
// declarer is UIParent (ref UIParent.lua:65) since 1888.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
// The reference's own kit, in the manifest's order. `UIParent.xml` is not decoration here:
// `RaiseFrameLevel`/`LowerFrameLevel` live in it (ref UIParent.lua l.1890-1896) and stock
// `TargetofTargetTextureFrame`'s OnLoad calls one of them.
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
@@ -1247,6 +1270,7 @@ fn the_party_art_paints_over_the_bars() {
}],
leader_index: 0,
leader_guid: 0,
own_guid: 0,
raid: Vec::new(),
loot_method: "group".into(),
master_looter: None,
@@ -1391,9 +1415,10 @@ fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
"the WORD is the GlobalString `DEAD` (l.898), never the key: a literal \"DEAD\" here \
is the caps bug the director caught on Onyxia"
);
assert!(
s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap(),
"UnitIsDead 0x517ac0's dynflag leg reaches the API too"
assert_eq!(
s.eval::<i64>(r#"return UnitIsDead("target")"#).unwrap(),
1,
"UnitIsDead 0x517ac0's dynflag leg reaches the API too — as the number 1 (2043)"
);
// He stands back up: the flag clears, and nothing about the body needed restoring.
@@ -1838,10 +1863,9 @@ fn no_two_numeral_strings_overlap_on_any_frame() {
/// function UnitFrame_OnEnter() originalUnitFrame_OnEnter() … end
/// ```
///
/// Against a missing global that captured nil and raised on the first hover. Note these are
/// ADAPTERS, not renames: the reference's contract takes no arguments and reads `this`, ours takes
/// the frame explicitly, so a rename would have reached our body with a nil frame — reachable but
/// broken, which is worse than inert.
/// Against a missing global that captured nil and raised on the first hover. Our own adapter
/// (`BenillaUnitFrame_OnEnter(frame)`, which took the frame explicitly) went with the file 1751's
/// twenty-second window deleted: what runs here is the reference's own `this`-shaped body.
///
/// The same class as the Bagnon bag bug, and equally invisible to the corpus survey: it loads
/// addons and fires events, but never hovers anything.
@@ -1976,6 +2000,7 @@ fn the_player_frame_wears_the_leader_and_master_looter_icons() {
leader_index,
// Follows `leader_index`: 0 = the player (unset here), else the member who leads.
leader_guid: if leader_index == 0 { 0 } else { 0x7A17 },
own_guid: 0,
raid: Vec::new(),
loot_method: method.into(),
master_looter,
@@ -2151,3 +2176,84 @@ fn the_unit_frames_publish_every_name_the_reference_declares() {
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The reported symptom, on the stock file that has it: the target's leader crown went stale.**
///
/// `PLAYER_FLAGS_CHANGED` was one of the events a migrated window registered and nothing here
/// produced (decision 2078). `TargetFrame.lua:88-95` is its only 1.12 consumer and it re-runs the
/// **party-leader icon** — *not* an AFK/DND badge, which 1.12 has nowhere on a unit frame and has
/// no `UnitIsAFK`/`UnitIsDND` binding to draw from. With no producer, `TargetFrame_Update` was the
/// crown's only writer, so it moved on a re-target and at no other moment: leadership passing to
/// the player you were already holding did nothing on screen.
///
/// This drives the real stock file, so it is the symptom itself and not a proxy for it.
#[test]
fn the_target_leader_crown_follows_player_flags_changed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let mate = |leader: bool| {
Some(UnitState {
exists: true,
has_object: true,
name: Some("Groupmate".into()),
health: 100,
max_health: 100,
level: 30,
power_type: 0,
power: 50,
max_power: 50,
// `UnitIsPartyLeader`'s descriptor leg — PLAYER_FLAGS bit 0x1 — and the raw dword the
// event fires on, moved together the way the wire moves them.
group_leader: leader,
player_flags: u32::from(leader),
// **A NON-ZERO guid, or this test cannot fail.** `UnitIsPartyLeader` is two legs ORed
// and the second is a raw compare against the group's leader guid, with no zero guard
// — verified 1.12 behaviour, which is why `UnitIsPartyLeader(nil)` answers 1 while
// solo (`unit::bindings`). A default `UnitState` carries `guid: 0`, which equals the
// zeroed leader of an empty group, so the crown is up from the first frame and every
// assertion below passes for the wrong reason. Naming a guid puts the descriptor leg
// in sole charge, which is the leg this event exists to refresh.
guid: 0x4000_0000_0000_0009,
..UnitState::default()
})
};
let crown =
|s: &UiScript| -> bool { s.eval::<bool>("return TargetLeaderIcon:IsShown()").unwrap() };
// Target an ordinary group member: no crown.
s.set_unit("target", mate(false));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!crown(&s), "not the leader: no crown");
// **Leadership passes to them while you hold the target.** This is the whole report.
s.set_unit("target", mate(true));
// The control FIRST: the same event for a different token must change nothing, because the
// stock handler is gated on `arg1 == "target"`. A producer that fired argless — or that fired
// the wrong token — would light the crown here and the assertion below would prove nothing.
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("player".into())],
);
assert!(
!crown(&s),
"arg1 = \"player\" is not this frame's unit — the token gate is real"
);
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(crown(&s), "the crown appears without a re-target");
// And back down — the reference fires on the XOR-diff, so the clear edge is the same event.
s.set_unit("target", mate(false));
s.fire_event(
"PLAYER_FLAGS_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(!crown(&s), "leadership leaves and the crown goes with it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -15,16 +15,24 @@ use super::test_ui::load_ui as load_xml;
/// live here since window 6 (our ItemRef.xml used to carry a private second copy of both).
fn load_popup_frames(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
// `SmallMoneyFrame_OnLoad`, which UiPanels' own StaticPopup money rows call at load.
"MoneyFrame.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
// `SmallMoneyFrame_OnLoad`, which the chain's StaticPopup money rows call at load.
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
// `StaticPopupDialogs` and the `PanelTemplates_*` family, both of which FriendsFrame.xml
// reaches at LOAD (its tab row and its confirm dialogs).
"UiPanels.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua", // `TEXT`, read at file scope below
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\ItemRef.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -36,9 +44,10 @@ fn load_popup_frames(s: &UiScript) {
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"FriendsFrame.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\FriendsFrame.xml",
// Declares ChatFrameEditBox, which the rename dialog's OnHide refocuses (1960).
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(s, file);
}
@@ -66,8 +75,9 @@ fn bake_strings(s: &UiScript) {
-- The newbie tooltip the stock unit frame raises on a HOVER, which every test in this file
-- takes on its way to a right-click. `UnitFrame_OnEnter` (ref `UnitFrame.lua:58-65`) runs
-- the detailed-tip branch whenever `SHOW_NEWBIE_TIPS == "1"` — 1.12's own default
-- (`UIOptionsFrame.lua:100`), which our `GameTooltip.xml:46` sets at load — and for a
-- player-controlled target that is not us it calls
-- (`UIOptionsFrame.lua:100`, a file benilla does not build; our `assets/ui/OptionsFrame.xml`
-- is the definer, and this harness loads no options file, so the branch is not reached
-- here). For a player-controlled target that is not us that branch calls
-- `GameTooltip_AddNewbieTip(PLAYER_OPTIONS_LABEL, 1, 1, 1, NEWBIE_TOOLTIP_PLAYEROPTIONS)`.
-- Both are nil in a bare harness, and `GameTooltip:SetText(nil)` raises. Verbatim from the
-- real `Interface\FrameXML\GlobalStrings.lua` off the 1.12.1 chain (l.3081 and l.2755).
@@ -108,11 +118,13 @@ fn chat_name_right_click_opens_the_invite_menu() {
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the chat-name right-click opens the FRIEND dropdown"
);
// title (Bob) + Whisper + Invite + Cancel — the rest of FRIEND is DEFERRED/hidden.
// title (Bob) + Whisper + Invite + Target + Cancel — the reference's FRIEND menu with its two
// guild rows hidden by their own predicates (no guild, no guild frame). Our transcription
// had deferred Target; the stock row is `TargetByName`, which exists (1958).
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
4,
"title + Whisper + Invite + Cancel"
5,
"title + Whisper + Invite + Target + Cancel"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
@@ -138,14 +150,22 @@ fn chat_name_right_click_opens_the_invite_menu() {
"Invite on a chat name queues an invite-by-name"
);
// A plain LEFT-click on the name whispers instead (ref's else branch).
// A plain LEFT-click on the name whispers instead (ref's else branch): the stock
// `ChatFrame_SendTell` puts the box into WHISPER mode at the name and opens it (1960).
s.run(r#"SetItemRef("player:Carol", "|Hplayer:Carol|h[Carol]|h", "LeftButton")"#)
.unwrap();
s.tick(0.05);
assert_eq!(
s.take_tell_requests(),
vec!["Carol".to_string()],
s.eval::<(String, String)>("return ChatFrameEditBox.chatType, ChatFrameEditBox.tellTarget")
.unwrap(),
("WHISPER".to_string(), "Carol".to_string()),
"left-click a chat name opens a whisper"
);
assert!(
s.eval::<bool>("return ChatFrameEditBox:IsVisible()")
.unwrap(),
"and the box is open"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -457,21 +477,28 @@ fn solo_target_inspect_click_reaches_inspect_unit() {
// ── The PET menu (decision 1066; report B219) ───────────────────────────────────────────────────
/// The pet menu's own prefix. Two things join the popup prefix: `UiPanels.xml` (the StaticPopup
/// engine, because two of the four rows go behind a dialog) and `PetActionBar.xml`, which is where
/// those three dialogs are registered — with the pet arc, not with the rows. `Cooldown.xml` and
/// The pet menu's own prefix. What joins the popup prefix is `StaticPopup.xml` — the StaticPopup
/// engine, because two of the four rows go behind a dialog, and since 1953 where the pet arc's
/// three dialogs are registered (they rode our pet-bar file until it became the reference's).
/// `Cooldown.xml` and
/// `ActionBar.xml` are the pet bar's own load deps, not this menu's.
fn load_pet_menu_frames(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua", // `TEXT`, read at file scope below
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UnitPopup.xml",
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
@@ -481,9 +508,13 @@ fn load_pet_menu_frames(s: &UiScript) {
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// Declares ChatFrameEditBox, which the rename dialog's OnHide refocuses (1960).
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(s, file);
}
@@ -791,150 +822,6 @@ fn the_pet_details_row_opens_the_pet_paper_doll() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The enable-pass driver (BenillaUnitPopupDriver) ─────────────────────────────────────────────
/// **The idle popup driver parks itself** (decision 1396's class, the audit's driver-hide item):
/// with no unit menu of ours open, `UnitPopup_OnUpdate`'s early-out hides the driver's own frame —
/// and the engine dispatches OnUpdate only to visible frames (`tick.rs`), so the per-frame scan
/// stops entirely. `UnitPopup_ShowMenu` — the one opener every unit menu goes through (grep:
/// UnitFrames/PartyFrame/FriendsFrame/ItemRef all call it) — wakes it again.
///
/// The probe wraps `UnitPopup_OnUpdate` in a counter: after the first tick parks the shipped-shown
/// driver, ten idle ticks never run the pass. The control: an open target menu still runs the
/// per-frame enable pass — a disconnected target's Whisper row greys, and greys back live when the
/// unit reconnects mid-open — and closing the menu re-parks the driver.
#[test]
fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_strings(&s);
load_popup_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Me".into()),
is_player: true,
player_controlled: true,
..UnitState::default()
}),
);
// A friendly player target, DISCONNECTED (is_connected defaults to false): the Whisper row's
// grey predicate is live from the first enable pass.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Ally".into()),
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.resolve();
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
s.run(
"BENILLA_TEST_POPUP_TICKS = 0\n\
local real = UnitPopup_OnUpdate\n\
function UnitPopup_OnUpdate(elapsed)\n\
BENILLA_TEST_POPUP_TICKS = BENILLA_TEST_POPUP_TICKS + 1\n\
real(elapsed)\n\
end",
)
.unwrap();
// The driver ships shown; the first tick's early-out parks it, and then nothing runs.
s.tick(0.016);
s.resolve();
assert!(
!s.eval::<bool>("return BenillaUnitPopupDriver:IsShown()")
.unwrap(),
"no menu open: the first tick parks the driver"
);
s.run("BENILLA_TEST_POPUP_TICKS = 0").unwrap();
for _ in 0..10 {
s.tick(0.016);
s.resolve();
}
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_POPUP_TICKS").unwrap(),
0,
"a parked driver never runs the enable pass"
);
// Open the PLAYER menu through the real hit path: the driver wakes...
let (cx, cy) = s
.eval::<(f64, f64)>("return TargetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
s.resolve();
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the menu opened"
);
assert!(
s.eval::<bool>("return BenillaUnitPopupDriver:IsShown()")
.unwrap(),
"UnitPopup_ShowMenu wakes the driver"
);
// ...and the pass runs per frame: the disconnected target's Whisper row greys...
s.tick(0.016);
s.resolve();
assert!(
s.eval::<i64>("return BENILLA_TEST_POPUP_TICKS").unwrap() > 0,
"an open menu ticks the enable pass"
);
assert!(
!s.eval::<bool>("return DropDownList1Button2:IsEnabled() ~= 0")
.unwrap(),
"Whisper greys against a disconnected target"
);
// ...and un-greys LIVE when the unit reconnects while the menu is up — the whole reason the
// pass is per-frame rather than baked at open.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Ally".into()),
health: 40,
max_health: 40,
is_player: true,
player_controlled: true,
reaction: 5,
is_connected: true,
..UnitState::default()
}),
);
s.tick(0.016);
s.resolve();
assert!(
s.eval::<bool>("return DropDownList1Button2:IsEnabled() ~= 0")
.unwrap(),
"Whisper enables the frame the target reconnects"
);
// Closing the menu re-parks the driver on its next tick.
s.run("CloseDropDownMenus()").unwrap();
s.tick(0.016);
s.resolve();
assert!(
!s.eval::<bool>("return BenillaUnitPopupDriver:IsShown()")
.unwrap(),
"a closed menu parks the driver again"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Click an open dropdown row through the real hit path.
fn click_row(s: &mut UiScript, row: u32) {
click_frame(s, &format!("DropDownList1Button{row}"));
@@ -0,0 +1,407 @@
//! **The VERB-FIRED event gate** — `reference/1.12-verb-events.tsv` against the module that
//! registers each verb (decision 2251).
//!
//! ## The third question on the event seam
//!
//! Two gates compare event NAMES (`reference_ui`: a stock listener nothing feeds, 1889; a fire the
//! reference has no name for, 1883) and one compares ARGUMENTS (`event_shape_gate`, 2140). None of
//! them asks **who** fires it, and that is the hole B389/2244 fell through. The reference's
//! `SetTrainerServiceTypeFilter` commits through `0x4d8c90`, whose whole body is *write the mask,
//! re-run the finalizer, fire `TRAINER_UPDATE`*; so the stock `Blizzard_TrainerUI.lua` never
//! repaints the list itself — it calls the verb for its side effect. benilla's verb set the mask
//! and fired nothing. The name gate stayed green because the packet arm fires the same event, and
//! the tests stayed green because they asserted the mask. Our own retired `TrainerFrame.xml` had
//! repainted explicitly, so the behaviour left with the file that was compensating for it (1957),
//! and the director found it twelve days later. Every window that went stock (1751) carries the
//! same exposure: a verb the stock Lua calls *for the event*.
//!
//! ## The rule
//!
//! For every pair the reference fires on the verb's own call path (the table: the verb's body, or
//! a helper that only registered verbs call), and every verb benilla registers: **a file that
//! registers the verb also fires the event** — or the pair is declared below, and the declaration
//! is *checked*, never trusted:
//!
//! * [`ELSEWHERE`] — benilla fires it from a named feed instead, on the state the verb changed
//! (the verb bumps a generation, sets a touched flag, queues an intent the app drains). That
//! file must fire it, and the verb's own file must not, or the row is stale.
//! * [`GAP`] — nothing fires it on this verb's path, for a stated reason. The verb's own file
//! must still not fire it, or the row is stale.
//!
//! A verb no file registers is skipped: unbuilt verbs are `scripts/api-coverage.sh`'s queue
//! (1178), not this gate's.
//!
//! ## What "registers" and "fires" mean here, and the limit
//!
//! Text, not execution: the quoted literal `"Verb"` or `"EVENT"` in a non-test source file, with
//! comment lines dropped and the trailing test module cut. Lenient in exactly one direction — any
//! file that names the verb and fires the event passes — because the honest alternative, proving
//! the path *executes*, is a per-verb fixture, and 2244's own test is the measure of a fixture that
//! asserts the wrong thing. What this cannot see: a fire that is in the file but off the verb's
//! path — an `ELSEWHERE` row is a claim the session that wrote it read at the feed. What it sees
//! every time is the migration class itself: a verb whose module fires nothing.
use std::collections::BTreeSet;
use std::path::PathBuf;
/// One row of `reference/1.12-verb-events.tsv`.
struct Pair {
verb: String,
event: String,
shape: String,
via: String,
}
/// Pairs benilla fires from a feed, on the state the verb changes. `(verb, event, the file that
/// fires it)`; the file is matched as a path suffix. Each was read at the feed when the row was
/// written — the trigger is named so the next reader can check it in one grep.
const ELSEWHERE: &[(&str, &str, &str)] = &[
// The sell slot's diff (`auction_sell_item` moved, or `sell_slot_dirty`).
(
"ClickAuctionSellItemButton",
"NEW_AUCTION_UPDATE",
"benilla-app/src/ui_auction/mod.rs",
),
// The query goes to the wire; the inbox feed announces the list when it lands.
(
"CheckInbox",
"MAIL_INBOX_UPDATE",
"benilla-app/src/ui_mail/mod.rs",
),
// `craft_close` / `trade_skill_close` are drained by the app, whose store transition
// `(Some, None)` is the fire.
("CloseCraft", "CRAFT_CLOSE", "benilla-app/src/ui_craft.rs"),
(
"CloseTradeSkill",
"TRADE_SKILL_CLOSE",
"benilla-app/src/ui_tradeskill.rs",
),
// `take_quest_log_collapses`: the app owns the collapse set, re-feeds the list, fires.
(
"CollapseQuestHeader",
"QUEST_LOG_UPDATE",
"benilla-app/src/ui_quest_log.rs",
),
(
"ExpandQuestHeader",
"QUEST_LOG_UPDATE",
"benilla-app/src/ui_quest_log.rs",
),
// `trade_skill_touched`, set by all four verbs, drained by `take_trade_skill_touched`.
(
"CollapseTradeSkillSubClass",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"ExpandTradeSkillSubClass",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"SetTradeSkillInvSlotFilter",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
(
"SetTradeSkillSubClassFilter",
"TRADE_SKILL_UPDATE",
"benilla-app/src/ui_tradeskill.rs",
),
// `repeat_count` moved (`repeat_changed`).
(
"DoTradeSkill",
"UPDATE_TRADESKILL_RECAST",
"benilla-app/src/ui_tradeskill.rs",
),
// `take_loot_roll_confirms`: a Need/Greed on a bind-on-pickup roll is diverted to the popup.
(
"ConfirmLootRoll",
"CANCEL_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"ConfirmLootRoll",
"CONFIRM_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"RollOnLoot",
"CANCEL_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
(
"RollOnLoot",
"CONFIRM_LOOT_ROLL",
"benilla-app/src/ui_loot_roll.rs",
),
// `keybinds.generation` moves; `sync_dispatch` re-derives the table and fires.
(
"SetBinding",
"UPDATE_BINDINGS",
"benilla-app/src/bindings.rs",
),
// The toggle goes to the wire and the bar's pushed key changes on the reply — one round trip
// later than the reference, which fires locally from the toggle.
(
"TogglePetAutocast",
"PET_BAR_UPDATE",
"benilla-app/src/ui_pet/bar.rs",
),
(
"ToggleSpellAutocast",
"PET_BAR_UPDATE",
"benilla-app/src/ui_pet/bar.rs",
),
];
/// Pairs nothing fires on the verb's path, each with its reason. A row is a gap with a name,
/// never a reason to stop; the reference's own site is in the table for the grep into wow-re.
const GAP: &[(&str, &str, &str)] = &[
(
"CancelSkillUps",
"SKILL_LINES_CHANGED",
"the reference's verb (0x4d3e30) calls the temp-point reset and fires unconditionally at \
0x4d3e35; here the reset runs over a table this model keeps empty (`skills.rs`), so \
nothing moves and `ui_char.rs`'s feed has nothing to announce — the stock SkillFrame \
would only repaint under its own Close",
),
(
"ClickTargetTradeButton",
"TRADE_REPLACE_ENCHANT",
"the reference runs the enchant clash check 0x496170 when a spell is on the cursor and \
fires this for the replace dialog (wow-re staticpopup-dialog-bindings.md §3.1); casting \
an enchant onto the partner's slot is not built here — `ui_trade.rs` only mirrors the \
wire's enchant slot — so the verb is the money arm alone",
),
(
"CloseTrade",
"PLAYER_TRADE_MONEY",
"fired only when the trade did not complete (`[0xb71748] == 0`, wow-re \
incoming-trade-request-law.md), zeroing the cancelled offer under a frame TRADE_CLOSED \
hides; here `TradeSession::begin` resets the whole session at the next trade, so the \
next window opens at zero either way",
),
(
"CollapseCraftSkillLine",
"CRAFT_UPDATE",
"the reference commits through the 21-byte thunk 0x4f6be0 (the trainer's 0x4d8c90 shape); \
a no-op here because the craft list is flat — the header law was never ported from \
TradeSkill (0446, 0530's follow-up) — so there is nothing to repaint. Whether any 1.12 \
craft list carries more than one group is not established: 0446 covers Enchanting, \
Beast Training is unchecked",
),
(
"ExpandCraftSkillLine",
"CRAFT_UPDATE",
"the same thunk and the same no-op as CollapseCraftSkillLine",
),
(
"PetDismiss",
"PET_DISMISS_START",
"the worker 0x4bd6e0 sends the dismiss and fires this with a duration (`%d`, 10000 — \
wow-re pet-action-bar-api.md §10.8); no shipped file listens, the dismiss reaches the \
wire through the app's drain, and the pet frame follows the unit's departure",
),
(
"SelectGossipOption",
"GOSSIP_ENTER_CODE",
"a coded option raises the code-entry popup in the reference (the worker 0x4e2320); here \
coded options are greyed and unselectable (0081 v1, `reference_ui`'s UNPRODUCED row), and \
vmangos's `gossip_menu_option` carries zero coded rows, so no NPC on this server reaches it",
),
];
fn table() -> Vec<Pair> {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../reference/1.12-verb-events.tsv"
);
let text = std::fs::read_to_string(tsv).expect("reference/1.12-verb-events.tsv");
text.lines()
.filter(|l| !l.starts_with('#') && !l.starts_with("verb\t"))
.filter_map(|l| {
let f: Vec<&str> = l.split('\t').collect();
(f.len() >= 4).then(|| Pair {
verb: f[0].to_string(),
event: f[1].to_string(),
shape: f[2].to_string(),
via: f[3].to_string(),
})
})
.collect()
}
/// Every non-test `.rs` under `crates/`: comment lines dropped, the trailing test module cut.
fn sources() -> Vec<(PathBuf, String)> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("crates");
let mut out = Vec::new();
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
continue;
}
// The same file-name rule the sibling gates use: a test's own literals are not a
// surface we ship — and neither are the gates' own declared lists, this file's
// included (a `*_gate.rs` is a `#[cfg(test)]` module whose name does not say so).
let name = p.to_string_lossy();
if p.extension().is_none_or(|x| x != "rs")
|| name.contains("test")
|| name.ends_with("_gate.rs")
{
continue;
}
let whole = std::fs::read_to_string(&p).unwrap_or_default();
let head = whole
.rfind("#[cfg(test)]\nmod ")
.map_or(whole.as_str(), |i| &whole[..i]);
let text: String = head
.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n");
out.push((p, text));
}
}
out.sort();
out
}
fn quoted(text: &str, name: &str) -> bool {
text.contains(&format!("\"{name}\""))
}
fn short(p: &std::path::Path) -> String {
let s = p.to_string_lossy();
s.split_once("/crates/")
.map_or(s.to_string(), |(_, t)| t.to_string())
}
/// The files that register `verb`, and whether any of them fires `event`.
fn verb_module_fires(src: &[(PathBuf, String)], verb: &str, event: &str) -> (Vec<String>, bool) {
let registrars: Vec<&(PathBuf, String)> = src.iter().filter(|(_, t)| quoted(t, verb)).collect();
let fires = registrars.iter().any(|(_, t)| quoted(t, event));
(registrars.iter().map(|(p, _)| short(p)).collect(), fires)
}
/// **Every event the reference fires from inside a verb is fired by the module that registers
/// the verb** — or declared, with the declaration checked.
#[test]
fn every_event_the_reference_fires_from_a_verb_is_fired_by_the_verb_s_module() {
let src = sources();
assert!(src.len() > 100, "source walk found {} files", src.len());
let declared: BTreeSet<(&str, &str)> = ELSEWHERE
.iter()
.chain(GAP.iter())
.map(|(v, e, _)| (*v, *e))
.collect();
let mut surprises = Vec::new();
let mut seen = 0;
for pair in table() {
let (registrars, fires) = verb_module_fires(&src, &pair.verb, &pair.event);
if registrars.is_empty() {
continue; // an unbuilt verb: api-coverage.sh's queue, not this gate's
}
seen += 1;
if fires || declared.contains(&(pair.verb.as_str(), pair.event.as_str())) {
continue;
}
let fired_by: Vec<String> = src
.iter()
.filter(|(_, t)| quoted(t, &pair.event))
.map(|(p, _)| short(p))
.collect();
surprises.push(format!(
"{} -> {} [{}{}] registered in {}; {}",
pair.verb,
pair.event,
pair.shape,
if pair.via == "-" {
String::new()
} else {
format!(" via {}", pair.via)
},
registrars.join(", "),
if fired_by.is_empty() {
"fired NOWHERE in benilla".to_string()
} else {
format!("fired only by {}", fired_by.join(", "))
},
));
}
assert!(
seen > 20,
"only {seen} pairs had a registered verb — the walk or the table is off"
);
assert!(
surprises.is_empty(),
"the reference fires these events from inside the verb, and the module that registers \
the verb here fires nothing — the stock Lua that calls the verb for its side effect \
repaints nothing (2244's class). Fire it from the verb, or declare the pair: ELSEWHERE \
when a feed fires it on THIS verb's change and you read that at the feed, GAP with the \
reason otherwise:\n {}",
surprises.join("\n ")
);
}
/// **A declaration is checked, not trusted** — an `ELSEWHERE` file that stopped firing, a verb
/// module that now fires what its row says it does not, a row for a pair the table no longer
/// carries: each fails until the row is corrected.
#[test]
fn the_declared_rows_are_still_true() {
let src = sources();
let table = table();
let in_table =
|verb: &str, event: &str| table.iter().any(|p| p.verb == verb && p.event == event);
let mut wrong = Vec::new();
for (verb, event, file) in ELSEWHERE {
if !in_table(verb, event) {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: not a pair in the table any more"
));
}
let named: Vec<&(PathBuf, String)> = src
.iter()
.filter(|(p, _)| p.to_string_lossy().ends_with(file))
.collect();
if named.is_empty() {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: no source file ends with {file}"
));
} else if !named.iter().any(|(_, t)| quoted(t, event)) {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: {file} no longer fires it"
));
}
if verb_module_fires(&src, verb, event).1 {
wrong.push(format!(
"ELSEWHERE {verb} -> {event}: the verb's own module fires it now — take the row out"
));
}
}
for (verb, event, _) in GAP {
if !in_table(verb, event) {
wrong.push(format!(
"GAP {verb} -> {event}: not a pair in the table any more"
));
}
if verb_module_fires(&src, verb, event).1 {
wrong.push(format!(
"GAP {verb} -> {event}: the verb's own module fires it now — take the row out"
));
}
}
assert!(
wrong.is_empty(),
"stale declarations:\n {}",
wrong.join("\n ")
);
}
@@ -79,16 +79,28 @@ fn roster_named(name: &str, guid: u64) -> Roster {
/// A hermetic state folder holding one addon — the probe — and the guards that point the whole
/// client at it. Every guard must outlive the world.
fn hermetic_probe(tag: &str) -> (std::path::PathBuf, EnvGuard, EnvGuard) {
hermetic_addon(tag, "SwitchProbe", PROBE_TOC, PROBE_LUA)
}
/// [`hermetic_probe`] for any one-file addon: the folder, its `.toc` and its Lua, and the guards.
/// The whole point of every test here is what an addon sees **while its file scope runs**, and
/// that differs per question — so the probe's body is a parameter.
fn hermetic_addon(
tag: &str,
name: &str,
toc: &str,
lua: &str,
) -> (std::path::PathBuf, EnvGuard, EnvGuard) {
// The pid keeps two concurrent `benilla_app` test binaries out of each other's tree, the same
// reason `addons::tests::hermetic_root` carries one.
let tmp =
std::env::temp_dir().join(format!("benilla-world-entry-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
let home = tmp.join("benilla-config");
let dir = home.join("AddOns").join("SwitchProbe");
let dir = home.join("AddOns").join(name);
std::fs::create_dir_all(&dir).expect("probe addon dir");
std::fs::write(dir.join("SwitchProbe.toc"), PROBE_TOC).expect("probe toc");
std::fs::write(dir.join("SwitchProbe.lua"), PROBE_LUA).expect("probe lua");
std::fs::write(dir.join(format!("{name}.toc")), toc).expect("probe toc");
std::fs::write(dir.join(format!("{name}.lua")), lua).expect("probe lua");
let capture = EnvGuard::unset("WOW_CAPTURE");
let benilla_home = EnvGuard::set("BENILLA_HOME", home.to_str().expect("utf-8 temp path"));
(tmp, capture, benilla_home)
@@ -257,7 +269,9 @@ fn quitting_from_the_character_screen_does_not_blank_the_session_it_wrote() {
let mut script = world
.remove_non_send_resource::<benilla_ui::script::UiScript>()
.expect("a boot VM is live at the character screen");
super::shutdown_ui_state(&mut script, identity.as_ref());
// `false`: this models `shutdown_on_exit` at the CHARACTER SCREEN, and that is the one root
// the reference reaches with no active player — see the test below.
super::shutdown_ui_state(&mut script, identity.as_ref(), false);
assert_eq!(
std::fs::read_to_string(&saved).ok().as_deref(),
@@ -269,6 +283,122 @@ fn quitting_from_the_character_screen_does_not_blank_the_session_it_wrote() {
let _ = std::fs::remove_dir_all(&tmp);
}
/// **The login arms the world latch, and the logout spends it** (decision 2239) — the chain that
/// makes `PLAYER_LEAVING_WORLD` fire on the commonest root of all.
///
/// This is the regression the latch design could most easily have caused, so it is pinned rather
/// than argued. The obvious guard for the tail — "is there a player object?" — reads FALSE on a
/// `/logout` by the time the tail runs: `net::apply::session::logged_out` despawns our avatar in
/// the same drain that writes `LoggedOutMessage`, `back_on_logout` sets `NextState` off that same
/// message, and `OnExit(InWorld)` does not run until the next frame's `StateTransition`. A
/// predicate would have silenced the event on every logout to fix a quit on a loading screen.
///
/// The latch does not care: it was armed by the entry load (the reference's `0x490168 call
/// 0x4908c0`) and nothing between there and here spends it.
#[test]
fn the_login_arms_the_world_latch_and_the_logout_spends_it() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("latch");
let mut world = booted_world();
// `booted_world` seats the VM and the state this file needs, not the whole `UiScriptPlugin`,
// so the latch is declared here the way the plugin declares it.
world.init_resource::<super::LeavingWorldArmed>();
assert!(
!world.resource::<super::LeavingWorldArmed>().is_armed(),
"the character screen is not a world — nothing has armed the latch yet"
);
log_in_as(&mut world, "Onehunter", 1);
assert!(
world.resource::<super::LeavingWorldArmed>().is_armed(),
"the entry UI load must arm the latch, or no logout ever fires PLAYER_LEAVING_WORLD"
);
super::end_ui_session(&mut world);
assert!(
!world.resource::<super::LeavingWorldArmed>().is_armed(),
"the shutdown tail must SPEND it — an unspent latch lets the following quit fire again"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A quit from the character screen fires `PLAYER_LOGOUT` and NOT `PLAYER_LEAVING_WORLD`** —
/// the reference's one guard inside the shutdown tail (decision 2238).
///
/// `0x490bd0` tests the object manager's active-player GUID pair (`0x490bee call 0x468550` /
/// `0x490bf3 or eax,edx` / `0x490bf5 je 0x490c25`) and the taken side skips exactly one
/// instruction — `0x490c20 call 0x490a80`, the `PLAYER_LEAVING_WORLD` fire — landing on the
/// `PLAYER_LOGOUT` block. Every in-world root takes the other side and fires both.
///
/// Ours fired both from every root, which was invisible: the only root that reaches the tail
/// without a player is this one, and its VM has no frames and no addon files, so nothing was
/// listening. The test exists because that is an argument about *today's* boot VM, not about the
/// split — and the day a glue addon loads, an unguarded fire would be a bug with no trail back.
///
/// Both directions in one test on purpose: asserting only the absence would pass against a
/// `shutdown_ui_state` that had stopped firing the event at all.
#[test]
fn a_quit_from_the_character_screen_fires_logout_without_leaving_world() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("quitsplit");
let mut world = booted_world();
let mut script = world
.remove_non_send_resource::<benilla_ui::script::UiScript>()
.expect("a boot VM is live at the character screen");
script
.run(
r#"
SeenLeaving = 0 SeenLogout = 0
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LEAVING_WORLD")
f:RegisterEvent("PLAYER_LOGOUT")
f:SetScript("OnEvent", function()
if event == "PLAYER_LEAVING_WORLD" then
SeenLeaving = SeenLeaving + 1
else
SeenLogout = SeenLogout + 1
end
end)
"#,
)
.expect("the probe frame registers");
super::shutdown_ui_state(&mut script, None, false);
assert_eq!(
script.eval::<i64>("return SeenLeaving").unwrap(),
0,
"the glue-screen quit fired PLAYER_LEAVING_WORLD — the reference's 0x490bf5 skips it"
);
assert_eq!(
script.eval::<i64>("return SeenLogout").unwrap(),
1,
"…and it must still fire PLAYER_LOGOUT, which is the whole of the taken side"
);
super::shutdown_ui_state(&mut script, None, true);
assert_eq!(
script.eval::<i64>("return SeenLeaving").unwrap(),
1,
"an in-world root must fire PLAYER_LEAVING_WORLD — the control for the assertion above"
);
assert_eq!(
script.eval::<i64>("return SeenLogout").unwrap(),
2,
"…and PLAYER_LOGOUT on every root, guarded or not"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// Between a logout and the next login there is **no in-game UI at all** — the character screen is
/// native, and the previous session's frame tree must not survive behind it.
///
@@ -759,7 +889,20 @@ fn the_login_one_shots_wait_for_the_in_game_ui() {
.expect("probe global")
};
// …but the in-game UI is still owed. Three frames inside the deferral window.
// **The frame the latch cannot see** (B376): the descriptor above came off the same drain as
// `Connected`, so the wire is in-world while the state still says glue and no load is armed
// yet — `OnEnter(InWorld)` runs next frame. A feed gated only on the latch runs here.
app.insert_resource(State::new(crate::char_select::ClientState::CharSelect));
app.update();
assert_eq!(
seen(&app),
0,
"the drain's own frame: in-world wire, a boot VM, and no latch yet"
);
// …the transition ran, and the in-game UI is still owed. Three frames inside the deferral
// window.
app.insert_resource(State::new(crate::char_select::ClientState::InWorld));
app.insert_resource(super::PendingEntryUiLoad);
for frame in 1..=3 {
app.update();
@@ -880,20 +1023,27 @@ fn an_addon_that_fails_to_load_without_raising_is_readable_in_the_error_log() {
script
.eval::<()>("BenillaScriptLog_Update() return nil")
.expect("the window repaints over a real log without raising");
let row_label: String = script
.eval::<Option<String>>("return BenillaScriptLogRow1Label:GetText()")
.expect("eval")
.unwrap_or_default();
// **A ROW shows it — not necessarily row 1.** The log is ordered by first occurrence and it
// carries warnings now (2135), which a world entry produces before any addon is reached, so
// asserting an index would be asserting how many warnings the stock UI happens to raise.
// What this pins is the row TEXT, which is the window's own trim path over a real message.
let row_labels: Vec<String> = (1..=13)
.filter_map(|i| {
script
.eval::<Option<String>>(&format!("return BenillaScriptLogRow{i}Label:GetText()"))
.expect("eval")
})
.collect();
assert!(
row_label.contains("AaMissing"),
"row 1 shows the failure, trimmed to the row's width: {row_label:?}"
row_labels.iter().any(|l| l.contains("AaMissing")),
"a row shows the failure, trimmed to the row's width: {row_labels:?}"
);
let summary: String = script
.eval::<Option<String>>("return BenillaScriptLogSummary:GetText()")
.expect("eval")
.unwrap_or_default();
assert!(
summary.contains("error"),
summary.contains("problem"),
"the summary line counted them: {summary:?}"
);
@@ -964,3 +1114,546 @@ fn a_repeating_error_is_one_row_with_a_count_not_a_flood() {
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
// ── B353 · the layout cache is a resident of the shutdown tail ───────────────────────────────
//
// st1rk, 2026-09-01: *"Unlock a chat window (right-click tab → Unlock Window), resize or drag it,
// `/logout` or `/reload`, log back in. It's back at the original size. `benilla-config/layout/`
// isnt created."* The engine seam and the file round trip were already proven by
// [`crate::ui_script::chat_resize_tests::the_geometry_round_trips_through_the_save_file`]; what
// was wrong is which edge writes. [`crate::ui_layout`] hung its saver off `OnExit(InWorld)`, and a
// `/reload` never leaves `InWorld` — [`super::run_pending_reload`] calls the shutdown and the
// rebuild back to back — so on the root a player uses most nothing was written at all. These two
// drive the roots themselves, which is the only place that distinction is visible.
/// A window the player has placed, made the way a drag makes one: movable first, then the
/// userPlaced bit (`SetUserPlaced` refuses a frame that is neither movable nor resizable).
/// Parentless, so its anchor is the screen root — the file's `-` target — which is what lets this
/// need no FrameXML and no install.
/// **Both flags, deliberately**: the layout cache's apply is gated per arm — position behind
/// `movable`, size behind `resizable` (decision 2193) — so a probe standing in for a window the
/// player both moved and resized has to carry both, or half its geometry is correctly left behind.
fn place_a_window(world: &mut World) {
world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("a VM to place a window in")
.run(
"local f = CreateFrame(\"Frame\", \"B353Probe\") \
f:SetWidth(413) f:SetHeight(147) \
f:SetPoint(\"BOTTOMLEFT\", 61, 29) \
f:SetMovable(true) f:SetResizable(true) f:SetUserPlaced(true)",
)
.expect("place the probe window");
}
/// The layout cache the shutdown left behind for this character, if any.
fn layout_cache(character: &str) -> Option<String> {
let path = crate::local_state::layout_character_path("Realm", character)?;
std::fs::read_to_string(path).ok()
}
/// What a saved window's row has to say for the player to get it back.
fn assert_probe_row(text: &str) {
for want in [
"Frame: B353Probe",
"W: 413",
"H: 147",
"Point: BOTTOMLEFT - BOTTOMLEFT 61 29",
] {
assert!(
text.contains(want),
"the saved row is missing `{want}`:\n{text}"
);
}
}
/// **Logging out writes the window's geometry** — the tail's step three, on the root that leaves
/// the world.
#[test]
fn a_placed_window_is_written_at_logout() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-logout");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
assert!(
layout_cache("Onehunter").is_none(),
"nothing is written while the session is running"
);
super::end_ui_session(&mut world);
assert_probe_row(&layout_cache("Onehunter").expect("the logout wrote the layout cache"));
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **`/reload` writes it too — B353 itself.** The reload root never leaves `InWorld`, so it is
/// exactly the root an `OnExit(InWorld)` saver cannot see: pre-fix this finds no file at all, and
/// the player's unlocked chat window comes back on its authored anchors.
#[test]
fn a_placed_window_is_written_at_reload() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-reload");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
reload(&mut world, crate::char_select::ClientState::InWorld);
assert_probe_row(&layout_cache("Onehunter").expect(
"the reload wrote the layout cache — it runs the shutdown tail without a state edge",
));
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// The file is **per character**, and the tail writes back to the one the UI loaded under: a
/// second character's logout must not answer with the first one's windows.
#[test]
fn each_character_gets_its_own_layout_cache() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-two-chars");
let mut world = booted_world();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
super::end_ui_session(&mut world);
log_in_as(&mut world, "Onewarrior", 2);
super::end_ui_session(&mut world);
assert_probe_row(&layout_cache("Onehunter").expect("the first character's file"));
let second = layout_cache("Onewarrior").expect("the second character's file");
assert!(
!second.contains("B353Probe"),
"a character who placed nothing must not inherit another's window:\n{second}"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// A window as FrameXML would author it — the shape the restore has to overwrite. Same name as
/// [`place_a_window`]'s, different geometry, and **not** user-placed: this is the fresh tree a
/// relog meets.
///
/// It carries the same `movable`/`resizable` pair, because those are the window's **authored**
/// state — XML attributes on a real resizable window, rebuilt with it — and the layout cache's
/// apply reads them off the live frame to decide which arm runs (decision 2193).
fn author_a_window(world: &mut World) {
world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("a VM to author a window in")
.run(
"local f = CreateFrame(\"Frame\", \"B353Probe\") \
f:SetWidth(100) f:SetHeight(100) \
f:SetPoint(\"BOTTOMLEFT\", 0, 0) \
f:SetMovable(true) f:SetResizable(true)",
)
.expect("author the probe window");
}
/// The probe window's live geometry, as the player sees it.
fn window_geometry(world: &World) -> (f32, f32, String, f32, f32) {
world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("a VM")
.eval::<(f32, f32, String, f32, f32)>(
"local p, _, _, x, y = B353Probe:GetPoint(1) \
return B353Probe:GetWidth(), B353Probe:GetHeight(), p, x, y",
)
.expect("read the probe window back")
}
/// **The whole loop, on the root that reported it** — st1rk's retest, in one test: place a window,
/// `/reload`, meet a fresh tree that has it on its authored anchors, and let the loader seat the
/// saved geometry back over the top.
///
/// [`crate::ui_layout::load_layout`] is run directly because this harness drives the world's edges
/// rather than its schedules; the authored window stands in for the FrameXML the real reload
/// rebuilds.
#[test]
fn a_placed_window_comes_back_after_a_reload() {
use bevy::ecs::system::RunSystemOnce;
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_probe("layout-roundtrip");
let mut world = booted_world();
world.init_resource::<crate::ui_layout::LayoutFile>();
log_in_as(&mut world, "Onehunter", 1);
place_a_window(&mut world);
let placed = window_geometry(&world);
reload(&mut world, crate::char_select::ClientState::InWorld);
author_a_window(&mut world);
assert_ne!(
window_geometry(&world),
placed,
"the rebuilt tree starts on its authored anchors — otherwise this proves nothing"
);
world
.run_system_once(crate::ui_layout::load_layout)
.expect("the layout loader ran");
assert_eq!(
window_geometry(&world),
placed,
"the window the player placed is back where they left it"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **A clean world entry raises the KNOWN warnings and no others** — the tripwire that keeps
/// decision 2135's channel worth having.
///
/// The stock FrameXML is loaded here with no addons at all, so every row is a gap of *ours*.
/// Before 2135 these reached a terminal and nothing else, and four of them had been sitting in
/// every session for as long as the interface has been stock: two `OnCursorChanged` refusals (the
/// mail body and the GM ticket box, which therefore did not scroll as you typed — 2141), one
/// `OnInputLanguageChanged`, and an unregistered `useUiScale` that `ContainerFrame.lua` and
/// `UIDropDownMenu.lua` both read.
///
/// The allowlist is deliberately a **list of names, not a count**: a new silent gap in a stock
/// file reddens this instead of scrolling past, and closing one means deleting a line here.
#[test]
fn a_clean_world_entry_raises_only_the_warnings_we_have_named() {
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (_tmp, _c, _h) = hermetic_probe("clean-entry-warnings");
let mut world = booted_world();
world.init_resource::<crate::ui_chat::ChatLog>();
log_in_as(&mut world, "Onehunter", 1);
let script = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("VM");
// **`OnInputLanguageChanged` stays out permanently**, and this is where that is said out loud.
// It is a real 1.12 slot (`FloatingChatFrame.xml` wires it to the IME language indicator) with
// zero corpus call sites, and benilla has no IME — so nothing here could ever fire it, and
// `SCRIPT_KINDS`' rule is that a name we cannot fire stays out. The refusal is the honest
// answer; the row is the price of saying it out loud.
//
// **`gxRefresh` stays out permanently too** (decision 2177). The stock VIDEO options window
// reads it in `OptionsFrameRefreshDropDown_OnLoad` — one of the two `<OnLoad>` paths that run
// on the spot when that file loads — and benilla does not register it, because a refresh rate
// is only selectable through an exclusive mode-set and this client ships none on any target
// (`crate::video`'s module doc walks each). `GetRefreshRates` therefore returns the
// reference's own "no rates available" sentinel, the dropdown greys itself, and nothing ever
// reads the variable. Registering it would be a key with no reader — 1134 §4's silent
// pretence — so the warn-once is the honest answer and this row is the price of saying so.
const KNOWN: [&str; 2] = ["OnInputLanguageChanged", "unknown CVar 'gxRefresh'"];
let unexpected: Vec<String> = script
.diagnostics()
.into_iter()
.filter(|d| d.kind == benilla_ui::script::diagnostics::DiagnosticKind::Warning)
.map(|d| d.message)
.filter(|m| !KNOWN.iter().any(|k| m.contains(k)))
.collect();
assert!(
unexpected.is_empty(),
"a stock world entry warned about something new — fix it or name it here: {unexpected:#?}"
);
}
// ─────────────────── The predicate every in-world feed runs on (B376) ───────────────────
/// **`not(ingame_ui_pending)` was only half the gate**, and the missing half is a whole frame
/// wide.
///
/// [`super::lifecycle::PendingEntryUiLoad`] is armed at `OnEnter(InWorld)`, and that edge trails
/// the wire by one frame: `apply_net_updates` drains `Connected` and the login burst behind it in
/// a single `try_iter`, `enter_on_connected` sets `NextState` out of that same drain, and the
/// transition — with it the park (1978) and this latch — does not run until the next frame's
/// `StateTransition`. So there is exactly one frame holding in-world wire state and a live *boot*
/// VM, and a feed gated only on the latch runs straight through it: B376's `GUILD_MOTD` fired
/// into a VM with no `ChatFrame1`, spent its `VmMemo` edge, and the login line never printed.
///
/// The `InWorld` term is what closes it — the state flips on the same edge that arms the latch,
/// so the pair is shut at both ends.
#[test]
fn the_ui_is_not_up_in_the_frame_between_the_wire_and_the_state() {
let mut world = World::new();
// **THE FRAME.** A live boot VM, no load owed — and the state still says glue, because
// `Connected` has only just been drained (so the guild/unit burst is already in ECS state)
// and the transition it queued runs next frame. The latch alone reads this as "the UI is up".
world.insert_resource(State::new(crate::char_select::ClientState::CharSelect));
assert!(
!run_ingame_ui_up(&mut world),
"the wire is in-world a frame before the state is — the boot VM must stay out of reach"
);
// The transition ran: `OnEnter` parked the VM and armed the latch.
world.insert_resource(State::new(crate::char_select::ClientState::InWorld));
world.insert_resource(super::PendingEntryUiLoad);
assert!(
!run_ingame_ui_up(&mut world),
"the deferral window — 1978's parked VM, and nothing to receive an event"
);
// The deferred load ran: the frame tree exists.
world.remove_resource::<super::PendingEntryUiLoad>();
assert!(
run_ingame_ui_up(&mut world),
"in the world with the in-game UI up — the first frame a feed may push"
);
// Leaving drops it again, before `end_ui_session`'s fresh boot VM can be fed anything.
world.insert_resource(State::new(crate::char_select::ClientState::CharSelect));
assert!(!run_ingame_ui_up(&mut world), "the world is gone with it");
}
fn run_ingame_ui_up(world: &mut World) -> bool {
use bevy::ecs::system::RunSystemOnce;
world
.run_system_once(super::lifecycle::ingame_ui_up)
.expect("the condition runs")
}
/// **The map catalog is in the VM before the first addon file runs** (decision 2240) — Questie's
/// `Astrolabe.lua:62: attempt to index local 'zoneData' (a nil value)`, from the side that causes
/// it.
///
/// `GetMapContinents`/`GetMapZones` are static DBC data, and the corpus reads them at **file
/// scope**: Astrolabe — the positioning library under Questie and Cartographer — builds its whole
/// continent → zone table inside `AceLibrary:Register`'s synchronous `activate`, and every icon it
/// ever places indexes that table. Pushed from an `Update` system, the catalog landed ~210 ms after
/// this edge returned (measured live, both logins of a round trip: `conts=0 zones(1)=0`), so the
/// table was built from two empty lists and every placement afterwards indexed a nil zone.
///
/// The catalog is planted rather than built: the question here is the ORDER, and the build itself
/// stands against the real chain in
/// [`super::world_map_tests::the_real_feralas_catalog_names_dire_maul_under_the_cursor`].
#[test]
fn an_addon_reads_the_map_catalog_at_file_scope() {
const MAP_PROBE_TOC: &str = "\
## Interface: 11200
MapProbe.lua
";
// Astrolabe's own two calls, in its own idiom — a list constructor around a multi-return.
const MAP_PROBE_LUA: &str = "\
MapProbeContinents = table.getn({ GetMapContinents() })
MapProbeZones = table.getn({ GetMapZones(1) })
";
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("mapcatalog", "MapProbe", MAP_PROBE_TOC, MAP_PROBE_LUA);
let mut world = booted_world();
world.insert_resource(crate::ui_world_map::WorldMapCatalog(vec![
benilla_ui::script::WorldMapContinentView {
name: "Kalimdor".into(),
zones: vec![
benilla_ui::script::WorldMapZoneView {
name: "Durotar".into(),
..Default::default()
},
benilla_ui::script::WorldMapZoneView {
name: "Mulgore".into(),
..Default::default()
},
],
..Default::default()
},
]));
log_in_as(&mut world, "Onewarrior", 1);
let read = |expr: &str| {
world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.and_then(|s| s.eval::<Option<u32>>(expr).ok())
.flatten()
};
assert_eq!(
read("MapProbeContinents"),
Some(1),
"an addon's file scope must see the continent list — it is DBC data the reference has held \
since load, not a feed that arrives later"
);
assert_eq!(
read("MapProbeZones"),
Some(2),
"…and the zone list with it: this is the one Astrolabe builds its whole table from"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **The keybinding table is in the VM before the first addon file runs** (decision 2241).
///
/// Stock `ActionButton_OnLoad` paints its hotkey corner from `GetBindingText(GetBindingKey(action))`
/// at OnLoad, and an addon that rebinds a stock command needs that command to exist. Seeded from an
/// `Update` system, the table held only the addons' own `Bindings.xml` rows for the whole load edge:
/// `GetBindingKey("TOGGLEWORLDMAP")` answered nothing and `SetBinding` on a stock command was a
/// silent nil.
///
/// No fixture: the registry is a compile-time table, which is exactly why its absence during the
/// burst was a timing bug and nothing else.
#[test]
fn an_addon_reads_the_keybinding_table_at_file_scope() {
const TOC: &str = "\
## Interface: 11200
BindProbe.lua
";
const LUA: &str = "\
BindProbeKey = GetBindingKey(\"TOGGLEWORLDMAP\")
BindProbeSet = SetBinding(\"J\", \"TOGGLEWORLDMAP\")
";
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("bindprobe", "BindProbe", TOC, LUA);
// No `BindingFiles` planted on purpose: the seed takes it optionally, which is what keeps
// every other harness in this file working.
let mut world = booted_world();
log_in_as(&mut world, "Onewarrior", 1);
let script = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("VM");
assert_eq!(
script
.eval::<Option<String>>("BindProbeKey")
.ok()
.flatten()
.as_deref(),
Some("M"),
"an addon's file scope must read the stock binding — `M` is TOGGLEWORLDMAP's own default"
);
assert_eq!(
script.eval::<Option<u32>>("BindProbeSet").ok().flatten(),
Some(1),
"…and a rebind of a stock command must take, not answer the empty table's silent nil"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **The zone-channel catalog is in the VM before the first addon file runs** (decision 2241) —
/// and this one puts a packet on the wire when it is not.
///
/// An empty catalog is not "no zone yet" to `JoinChannelByName`: it is *"no such built-in
/// channel"*, so `JoinChannelByName("General")` takes the custom-channel leg and queues a real
/// `CMSG_JOIN_CHANNEL("General")` — a custom channel of that name on the server, and the chat
/// cache damage `ui_chat::channels` documents. Seeded zone-less, the same call matches the row,
/// finds `resolved: None`, and does nothing — the reference's own answer while there is no zone
/// text.
#[test]
fn an_addon_that_joins_general_at_file_scope_puts_nothing_on_the_wire() {
const TOC: &str = "\
## Interface: 11200
JoinProbe.lua
";
const LUA: &str = "JoinChannelByName(\"General\")\n";
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("joinprobe", "JoinProbe", TOC, LUA);
let mut world = booted_world();
world.insert_resource(crate::ui_chat::ChannelState {
channels: benilla_formats::ChatChannelsCatalog::from_rows(vec![
// Row 1 as the shipped table has it: auto-joined, and its `%s` is the zone's name —
// which is what makes it unanswerable before a zone and answerable after.
benilla_formats::ChatChannelRow {
id: 1,
flags: benilla_formats::chat_channel_flags::INITIAL
| benilla_formats::chat_channel_flags::ZONE_DEP,
pattern: "General - %s".into(),
shortcut: "General".into(),
},
]),
..Default::default()
});
log_in_as(&mut world, "Onewarrior", 1);
let queued = world
.get_non_send_resource_mut::<benilla_ui::script::UiScript>()
.expect("VM")
.take_channel_commands();
assert!(
queued.is_empty(),
"a built-in shortcut with no zone text yet is a no-op, not a custom channel on the \
server — queued {queued:?}"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}
/// **The screen size is in the VM before the first `<OnLoad>` runs** (decision 2242) — the
/// director's "the map is missing the bg again", from the side that causes it.
///
/// A fresh `Model` starts at 1024×768 and only `tick_script` (an `Update` system) corrects it, so
/// since 2226 gave the entry its own VM, every OnLoad and every addon file scope read that default.
/// Anchored frames survive it — the first frame's resize re-solves them — but a number a file
/// *computes once* does not, and the stock `WorldMapFrame_OnLoad` computes exactly one: the size of
/// `BlackoutWorld`, the quad that hides the world behind the map. At 1024×768 units on a wider
/// window it stops short of the edges and the world shows through.
#[test]
fn an_addon_reads_the_real_screen_size_at_file_scope() {
const TOC: &str = "\
## Interface: 11200
ScreenProbe.lua
";
const LUA: &str = "\
ScreenProbeWidth = GetScreenWidth()
ScreenProbeHeight = GetScreenHeight()
";
let _l = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (tmp, _c, _h) = hermetic_addon("screenprobe", "ScreenProbe", TOC, LUA);
let mut world = booted_world();
// 2560×1440 with no `UiScaleCvar` planted (so the dial is 1): the seam scale is 1440/768 =
// 1.875, which puts the VM's screen at 1365.33 × 768 units. The WIDTH is the discriminator —
// 768 is what the height reads under any window, and 1024 is what the width read before this.
world.spawn((
Window {
resolution: bevy::window::WindowResolution::new(2560, 1440),
..Default::default()
},
bevy::window::PrimaryWindow,
));
log_in_as(&mut world, "Onewarrior", 1);
let script = world
.get_non_send_resource::<benilla_ui::script::UiScript>()
.expect("VM");
let read = |expr: &str| script.eval::<Option<f32>>(expr).ok().flatten();
let width = read("ScreenProbeWidth").expect("the probe ran at file scope");
assert!(
(width - 2560.0 * 768.0 / 1440.0).abs() < 0.01,
"an addon's file scope must read the REAL screen width in UI units — got {width}, and \
1024 is the fresh model's default, i.e. the bug"
);
assert_eq!(
read("ScreenProbeHeight"),
Some(768.0),
"…and the height is the 768-tall virtual base (decision 0582)"
);
drop(world);
let _ = std::fs::remove_dir_all(&tmp);
}

Some files were not shown because too many files have changed in this diff Show More