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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user