Comments in benilla-app, the UI script layer: one line of fact each, no history, no record numbers (#184)

Part of #41: the first of five `benilla-app` pieces. This one is the UI script layer: `ui_script` with its test harnesses, the text engine and layout (`ui_text`), the UI pass and the UI model panes. Its comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 22,041 comment lines become 7,133.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - On a fresh login the reference fires `PLAYER_LOGIN` and `PLAYER_ENTERING_WORLD` from the player's create (`0x5deb60` → `0x4908c0`); only a `/reload` goes through `0x490168`. The comments now state both paths.
  - `ComputeStep` (`0x5ca2d0`) applies negative pair kerns and rounds up. It does have a kerning term.
  - A fallback icon global and the retired `UiPanels.xml` panels no longer exist. The comments that named them now describe the stock FrameXML mechanism that replaced them.
  - Stock FrameXML line citations are checked against the stock 1.12 files and corrected, and several misattributed files are fixed (`MainMenuBarArtFrame` lives in `MainMenuBar.xml`).
  - Dozens more turned up on the way: docs sitting on the wrong item, removed functions, stale counts and links.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. An unbuilt behaviour says so plainly.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Five second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference.

Behaviour differences the rewrite surfaced are filed as their own issues (#183 among them). The comments here state what the code does. String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-25 08:20:21 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent fd386e75ae
commit eba558a5d2
120 changed files with 6953 additions and 21868 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+46 -164
View File
@@ -1,28 +1,6 @@
//! The **bank window** driven end-to-end, engine-only (no Bevy): the reference's own
//! `Interface\FrameXML\BankFrame.xml`, executed off the player's patch chain behind the bag
//! chain — decision 0604 phase 4's machine checks, re-pointed at the real file by 1751 (window 2).
//! Split from `merchant_tests`/`bag_tests` along the folder's one-file-per-window convention.
//!
//! **What decision 1751 changed in here, in two passes.** First the bank lost six popout windows
//! of its own — `BenillaBankBagFrame1..6`, six copies of `BagFrame.xml`'s window template — because
//! a bank bag is an ordinary `ContainerFrame` at container id `NUM_BAG_SLOTS + slot` (5..10), which
//! is what the real client always did and what 0604 already said it did. Then the window itself
//! went, and every name below is the reference's: `BankFrameItem1..24`, `BankFrameBag1..6`,
//! `BankFramePurchaseButton`, `BankFrameDetailMoneyFrame`.
//!
//! Two consequences worth stating, because both are places a test could quietly assert our old
//! behaviour instead of the client's:
//!
//! · **The bag buttons' `GetID()` is the CONTAINER id, 5..10** — not a bag number. That is what
//! `ButtonInventorySlot` hands `BankButtonIDToInvSlotID`, and what `ToggleBag` takes.
//! · **Opening the bank does NOT open your bags.** Decision 0561 (a window that opens your bags
//! opens every equipped one) is ours and stays for the vendor; the bank goes faithful, and the
//! reference's `BankFrame` OnShow plays one sound and nothing else. The director's call.
//!
//! The knock-on for the fixtures: the reference generates a window only for a container that
//! exists (`OpenBag`'s `size > 0` gate), where our own `BenillaBagFrame*` were static frames that
//! `Show()` regardless. So a test that wants a bank bag open has to feed a bag at id 5 — which is
//! the honest fixture anyway: an empty bank-bag slot has no window in the real client either.
//! The bank window, engine-only: the reference's own `BankFrame.xml` off the player's patch chain.
//! A bank bag is an ordinary `ContainerFrame` at container id `NUM_BAG_SLOTS + slot` (5..10), its
//! bag button's `GetID()`; `OpenBag` opens only a container that exists, so tests feed one at 5.
use benilla_ui::script::{
BankState, ContainerSlot, ContainerState, ExtractedQuad, QuadContent, ScriptValue,
@@ -31,7 +9,6 @@ use benilla_ui::script::{
use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI};
/// A container fixture with no items in it — the shape every "is this window open" test wants.
fn empty_bag(name: &str, num_slots: u32) -> Option<ContainerState> {
Some(ContainerState {
name: Some(name.into()),
@@ -40,20 +17,7 @@ fn empty_bag(name: &str, num_slots: u32) -> Option<ContainerState> {
})
}
/// The bank's own dependency chain, in `benilla.toc` order: [`BAG_UI`] carries it whole — Fonts
/// before anything with a font `inherits`, the four templates stock `ContainerFrame.xml` inherits,
/// UiPanels before any UIPanel/StaticPopup use, GameTooltip before any slot's hover, the
/// reference's own container file, our bag bar, and the reference's own `BankFrame.xml` (which
/// BAG_UI already needs, because `updateContainerFrameAnchors` measures every open bag against
/// `BankFrame:GetRight()`).
///
/// `MerchantFrame.xml` used to load here too, for the `BenillaMoney_*` coin rig our own bank
/// window's purse and cost rows called. The reference's rows are `SmallMoneyFrameTemplate`
/// instances off `MoneyFrame.xml`, which BAG_UI already carries — so the merchant is not a bank
/// dependency any more.
///
/// **Every caller needs client data**: `BAG_UI` names a chain entry, so each test below opens with
/// `wow_data_or_skip!`.
/// [`BAG_UI`] carries the bank's whole chain, `BankFrame.xml` included; it needs client data.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -69,27 +33,14 @@ fn has_icon(quads: &[ExtractedQuad], needle: &str) -> bool {
)
}
/// Test 1 — BANKFRAME_OPENED shows the frame (through ShowUIPanel, landing at the left slot — the
/// same NPC-session chain Merchant/Trainer use) and sets the title to the event arg.
///
/// **It does NOT open your bags, and that is the change.** Decision 0561 — a window that opens
/// your bags opens every equipped one — is this client's, and the vendor keeps it; the bank went
/// faithful with 1751's swap (the director's call), and the reference's `BankFrame` OnShow plays
/// `igMainMenuOpen` and nothing else. So the one sound is the whole sound list, which is a
/// stronger assertion than it looks: a stray `OpenBackpack` anywhere on this path would put
/// `igBackPackOpen` in front of it.
///
/// **And the title comes from `UnitName("npc")`, not from the event.** The reference's
/// `BankFrame_OnEvent` reads no `arg1` (BankFrame.lua:125), so the banker's name has to arrive on
/// the interaction token `crate::ui_session` points at the banker — which is why this test seats
/// an `"npc"` unit and why `feed_bank` stopped firing a name argument the reference never fires.
/// The title is `UnitName("npc")`: `BankFrame_OnEvent` reads no event argument (BankFrame.lua:127).
/// The bank does not open your bags: its OnShow plays `igMainMenuOpen` and nothing else.
#[test]
fn bankframe_opened_shows_and_sets_the_title() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// The reference gives `BankFrameTitleText` no `text=` — it is empty until an event names the
// banker (our own file used to author a "Banker" placeholder).
// The reference gives `BankFrameTitleText` no `text=`: it is empty until the bank opens.
assert_eq!(
s.eval::<String>("return BankFrameTitleText:GetText() or \"\"")
.unwrap(),
@@ -97,7 +48,7 @@ fn bankframe_opened_shows_and_sets_the_title() {
);
assert!(!s.eval::<bool>("return BankFrame:IsVisible()").unwrap());
let _ = s.take_sounds(); // ignore anything from load (every frame is hidden; nothing should fire)
let _ = s.take_sounds();
s.set_money(0);
s.set_container(0, empty_bag("Backpack", 16));
@@ -134,31 +85,21 @@ fn bankframe_opened_shows_and_sets_the_title() {
);
}
/// Test 2 — Hiding the frame (BANKFRAME_CLOSED, routed through HideUIPanel) queues the CloseBankFrame
/// intent (`take_bank_close()`), closes any open bank bag with it, and plays the close
/// sound — the reference's own OnHide order (CloseBankBagFrames(); CloseBankFrame(); PlaySound).
///
/// **"Popout" is this test's own history, not a widget any more** (1751). It was
/// `BenillaBankBagFrame1`, one of six copies of `BagFrame.xml`'s window template that our own bank
/// file owned, and the assertion was `:Show()` it then check `:IsShown()`. A bank bag is an
/// ordinary container now — id `NUM_BAG_SLOTS + 1` = 5 — so it is OPENED with the reference's own
/// `OpenBag` and asked for with `IsBagOpen`, and `CloseBankBagFrames` is now the reference's own
/// `CloseBag(5..10)` loop rather than a transcription of it. The property is unchanged and is the
/// point of the test: closing the bank must close the bank BAGS, not merely play a sound.
/// The reference's OnHide order: `CloseBankBagFrames()`, `CloseBankFrame()`, then the close sound
/// (BankFrame.xml:521-526). A "popout" is a bank bag's `ContainerFrame`.
#[test]
fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(0);
// A bag in the FIRST bank bag slot — container 5, which is `BankFrameBag1`'s own `GetID()`.
// The first bank bag slot: container 5, `BankFrameBag1`'s `GetID()`.
s.set_container(5, empty_bag("Bank Bag", 8));
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![ScriptValue::Str("Banker".into())]);
let _ = s.take_sounds();
let _ = s.take_bank_close(); // nothing queued yet — the drain would otherwise see a stale flag
let _ = s.take_bank_close();
// A bank bag open when the main window closes (ref CloseBankBagFrames' own reason to exist) —
// opened through the reference's own verb, which is what the bag button calls.
// A bank bag left open when the bank closes: what `CloseBankBagFrames` exists for.
s.run("OpenBag(5)").unwrap();
assert!(bag_open(&s, 5), "the bank bag's window is up");
let _ = s.take_sounds();
@@ -189,8 +130,6 @@ fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() {
);
}
/// Test 3 — A pushed container −1 with an item in slot 3 paints that slot button's icon;
/// PLAYERBANKSLOTS_CHANGED(3) repaints it after a later change.
#[test]
fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_changed() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -229,7 +168,7 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha
"slot 3's icon painted after PLAYERBANKSLOTS_CHANGED(3)"
);
// A later change to the same slot (a stack count bump) repaints again.
// A stack-count change on the same slot repaints too.
let mut slots2 = std::collections::HashMap::new();
slots2.insert(
3,
@@ -258,9 +197,7 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha
);
}
/// Test 4 — Bag buttons: with `set_bank` num_purchased=2, buttons 1-2 read normal (white) and 3-6
/// read the red tint (1.0, 0.1, 0.1 — the reference's exact tint, `UpdateBagSlotStatus`
/// BankFrame.lua:88-95); the bank-bag inventory band drives button 1's icon.
/// `UpdateBagSlotStatus` tints an unpurchased bag button (1.0, 0.1, 0.1) (BankFrame.lua:96).
#[test]
fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -270,9 +207,8 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
num_purchased: 2,
next_cost: 100_000,
}));
// Bank bag slot 1 holds a bag: an inventory slot at live id 64, which is where the
// reference's `BankFrameItemButton_OnUpdate` reads its icon from
// (`GetInventoryItemTexture("player", ButtonInventorySlot())`).
// Bank bag slot 1 holds a bag: inventory id 64, where `BankFrameItemButton_OnUpdate` reads
// the icon (`GetInventoryItemTexture`).
let mut bags: benilla_ui::script::BankBagSlots = Default::default();
bags[0] = Some(benilla_ui::script::InvSlotView {
item_id: 4500,
@@ -287,9 +223,7 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
s.resolve();
let quads = s.extract();
// BagFrame.xml's own bag-bar slots (CharacterBag0Slot..4) fall back to this SAME empty-slot
// texture, so the search must be scoped to each named bank button's own rect, not the path
// alone — get each button's center via eval, then read the color off the quad sitting there.
// The bag bar's slots show the same empty-slot art, so each lookup is scoped to one button.
let center = |name: &str| -> (f32, f32) {
let (x, y): (f64, f64) = s
.eval(&format!(
@@ -316,7 +250,6 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
(c[0] - r).abs() < 0.01 && (c[1] - g).abs() < 0.01 && (c[2] - b).abs() < 0.15
};
// Button 1: the live inventory slot's own bag icon, purchased -> white.
let (x1, y1) = center("BankFrameBag1");
let c1 = color_at(x1, y1, "INV_Misc_Bag_08").expect("button 1 shows the banked bag's icon");
assert!(
@@ -324,8 +257,8 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
"button 1 is purchased: white {c1:?}"
);
// Button 2: purchased but empty -> the paper-doll empty bag art, still white. The art comes
// from `GetInventorySlotInfo(strsub("BankFrameBag2", 10))` — the DBC's own `"Bag2"` row.
// Button 2 is purchased and empty: `GetInventorySlotInfo("Bag2")`'s art, from
// `PaperDollItemFrame.dbc`.
let (x2, y2) = center("BankFrameBag2");
let c2 = color_at(x2, y2, "UI-PaperDoll-Slot-Bag").expect("button 2 shows the empty-slot art");
assert!(
@@ -333,7 +266,6 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
"button 2 is purchased: white {c2:?}"
);
// Buttons 3..6: unpurchased -> the reference's red tint.
for i in 3..=6 {
let (x, y) = center(&format!("BankFrameBag{i}"));
let c = color_at(x, y, "UI-PaperDoll-Slot-Bag")
@@ -345,23 +277,14 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_band() {
}
}
/// The purse row at the bottom of the window, and the director's "the gold at bottom you see
/// 97…" report.
///
/// Our own window painted the purse with `BenillaMoney_Set` into a single FontString, which is
/// what could run out of room. The reference's `BankFrameMoneyFrame` is an ordinary
/// `SmallMoneyFrameTemplate` — the same three-button gold/silver/copper kit every other window in
/// this client already uses — so a large amount splits across three buttons instead of
/// overflowing one string. Asserted on the DIGITS in each button, not on the picture: whether it
/// reads right on screen is the director's call, but "98765 / 43 / 21 in three separate buttons"
/// is a fact a test can hold.
/// The purse is a `SmallMoneyFrameTemplate` (BankFrame.xml:501): a large amount must not truncate.
#[test]
fn the_purse_row_splits_a_large_amount_across_three_coin_buttons() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![]);
// 98765g 43s 21c — wider than any single-string purse could hold.
// 98765g 43s 21c.
s.set_money(987_654_321);
s.fire_event("PLAYER_MONEY", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -378,15 +301,9 @@ fn the_purse_row_splits_a_large_amount_across_three_coin_buttons() {
}
}
/// Test 5 — The purchase flow: the Purchase button's click shows the `CONFIRM_BUY_BANK_SLOT` popup;
/// the popup's accept queues `take_bank_purchase()`; the whole row hides once `num_purchased`
/// reaches 6 (`GetNumBankSlots()`'s `full`).
///
/// Two things this pins that only the swap made checkable. `BankFramePurchaseButton` is declared
/// `virtual="true"` INSIDE `<Frames>` — a reference quirk Classic Era still carries — and only a
/// TOP-LEVEL element is ever a template, so the button really exists and really clicks. And the
/// dialog itself lives in `UiPanels.xml` now: the reference keeps it in `StaticPopup.lua`, which
/// this client replaces, so without the re-home the button would be a silent no-op.
/// `BankFramePurchaseButton` is declared `virtual="true"` inside `<Frames>` (BankFrame.xml:466),
/// and only a top-level element is a template, so the button exists and clicks. The row hides once
/// `GetNumBankSlots()` reports `full`.
#[test]
fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -414,9 +331,8 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the click shows the confirm popup"
);
// The dialog carries the cost, which is why 1580's `hasMoneyFrame` had to be live before the
// reference's own StaticPopup entry could be re-homed into UiPanels.xml: its OnShow reads
// `BankFrame.nextSlotCost`, set by `UpdateBagSlotStatus` off `GetBankSlotCost`.
// `CONFIRM_BUY_BANK_SLOT`'s OnShow prints `BankFrame.nextSlotCost` (StaticPopup.lua:54-56),
// which `UpdateBagSlotStatus` sets from `GetBankSlotCost`.
assert!(
s.eval::<bool>("return StaticPopup1MoneyFrame:IsShown()")
.unwrap(),
@@ -436,8 +352,7 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
);
assert!(!s.take_bank_purchase(), "drained");
// Six purchased: full -> the whole row hides (PLAYERBANKBAGSLOTS_CHANGED is the no-packet
// buy's own repaint trigger, decision 0604).
// Six is full. A successful buy has no reply packet: `PLAYERBANKBAGSLOTS_CHANGED` repaints.
s.set_bank(Some(BankState {
num_purchased: 6,
next_cost: 999_999_999,
@@ -451,27 +366,14 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
);
}
/// The bank's bag buttons take BOTH mouse buttons (decision 0908): the ref's
/// `BankItemButtonBagTemplate` OnLoad runs `BankFrameBagButton_OnLoad` →
/// `BankFrameBaseButton_OnLoad`, which registers `("LeftButtonUp","RightButtonUp")`
/// (BankFrame.lua:12), and `BankFrameItemButtonBag_OnClick` reads no button. Ours registered
/// nothing, so the widget default (`{"LeftButtonUp"}`) swallowed every right-click.
///
/// It was asserted on the click sound alone, because "the one observable that does not need a bag
/// object fed into the slot" was all there was while the popout was a `BenillaBankBagFrame` our
/// own file drove by hand. The reference's handler has a real tail — `ToggleBag(this:GetID())` —
/// so a bag IS fed here now and the right-click is followed all the way to the window it opens.
///
/// **The sound ORDER is the reference's, and it is the reverse of ours.** Its handler opens the
/// bag FIRST and plays `BAGMENUBUTTONPRESS` after (BankFrame.lua:190-197), so the window's own
/// `igBackPackOpen` leads. Ours played the press kit first. Nothing depends on it; it is here
/// because a transcription that gets the order wrong is exactly the kind of thing the swap is
/// meant to stop having to notice.
/// The bag buttons register both mouse buttons (`BankFrameBaseButton_OnLoad`, BankFrame.lua:12),
/// and `BankFrameItemButtonBag_OnClick` reads no button. It opens the bag before it plays
/// `BAGMENUBUTTONPRESS` (BankFrame.lua:193-194), so `igBackPackOpen` leads.
#[test]
fn a_bank_bag_button_answers_the_right_button_too() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// A bag in bank slot 1 ⇒ container id 5, which IS `BankFrameBag1:GetID()`.
// Bank bag slot 1: container 5, `BankFrameBag1:GetID()`.
s.set_container(5, empty_bag("Bank Bag", 8));
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![]);
@@ -505,10 +407,8 @@ fn a_bank_bag_button_answers_the_right_button_too() {
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
}
/// Test 6 — Clicking item slot 1 with an empty cursor queues a `PickupContainerItem(-1, 1)` — read off
/// the cursor's own resulting payload (`bag_tests`' pattern: drive the real click through
/// `mouse_button`, not a bare `run()`, so the XML's `OnClick`/`RegisterForClicks` wiring is
/// actually under test).
/// Driven through `mouse_button`, not `run()`, so the XML's `OnClick` and `RegisterForClicks`
/// wiring is under test.
#[test]
fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -565,14 +465,13 @@ fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() {
);
}
/// The icon-texture quad whose path contains `needle`, for the desaturation assertions below.
fn icon_quad<'a>(quads: &'a [ExtractedQuad], needle: &str) -> Option<&'a ExtractedQuad> {
quads.iter().find(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)),
)
}
/// Whether that icon is drawn desaturated (`SetItemButtonDesaturated`'s shader arm).
/// Whether the icon is drawn desaturated (`SetItemButtonDesaturated`).
fn is_greyed(quads: &[ExtractedQuad], needle: &str) -> bool {
match icon_quad(quads, needle).map(|q| &q.content) {
Some(QuadContent::Texture { desaturated, .. }) => *desaturated,
@@ -580,9 +479,8 @@ fn is_greyed(quads: &[ExtractedQuad], needle: &str) -> bool {
}
}
/// A bag view for one bank BAG slot — the drop the director made. `contents_count: Some(_)` is
/// what makes it a CONTAINER to `GetInventoryItemCount`, and the value is deliberately non-zero:
/// the binding's `0x16` short-circuit has to be what zeroes it, not an already-zero sum.
/// A bag in a bank bag slot. `contents_count: Some(_)` makes it a container to
/// `GetInventoryItemCount`, non-zero so that the binding's slot-`0x16` cutoff does the zeroing.
fn a_bag(locked: bool) -> benilla_ui::script::InvSlotView {
benilla_ui::script::InvSlotView {
item_id: 4500,
@@ -595,20 +493,9 @@ fn a_bag(locked: bool) -> benilla_ui::script::InvSlotView {
}
}
/// **The bug of 1771, both halves.** A bag dropped into a bank bag slot came up greyed and stayed
/// greyed until the window was reopened, and a bag moved between two bag slots stayed drawn in the
/// one it left.
///
/// Both are the same defect: the six bag buttons repaint from `BankFrameItemButton_OnUpdate`,
/// which is reached ONLY from `BankFrameItemButton_OnEvent` on `PLAYERBANKSLOTS_CHANGED` or
/// `BANKFRAME_OPENED` — despite the name, it is not an `OnUpdate` handler
/// (`BankItemButtonTemplate`'s `<OnUpdate>` is `CursorOnUpdate()`). benilla fired that event only
/// for the vault's 24 slots, so the bag buttons showed whatever had been true at the last
/// *unrelated* vault change, and `ITEM_LOCK_CHANGED` — the one event that did reach them — repaints
/// the desaturation only, never the icon.
///
/// This test drives the reference's own file through the exact sequence and asserts what the
/// player sees at each step.
/// Despite its name, `BankFrameItemButton_OnUpdate` is no `OnUpdate` handler: the bag buttons
/// repaint only on `PLAYERBANKSLOTS_CHANGED` or `BANKFRAME_OPENED` (BankFrame.lua:206-212), and
/// `ITEM_LOCK_CHANGED` repaints the desaturation, never the icon.
#[test]
fn a_bank_bag_button_paints_the_drop_and_lets_go_of_the_lock() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -636,27 +523,23 @@ fn a_bank_bag_button_paints_the_drop_and_lets_go_of_the_lock() {
let mut bags: benilla_ui::script::BankBagSlots = Default::default();
bags[0] = Some(a_bag(true));
s.set_bank_bag_slots(bags.clone());
// The event carries NO arguments (`0x703e50`, `__fastcall(ecx = id)`, no vararg push) — it is
// a broadcast, and every bank button repaints from its own `GetInventorySlot()`.
// The event carries no arguments (`0x703e50`): every bank button repaints its own slot.
s.fire_event("PLAYERBANKSLOTS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
is_greyed(&s.extract(), "INV_Misc_Bag_08"),
"in flight, the button greys — BankFrameItemButton_UpdateLock's desaturate arm"
);
// **And no digit.** `BankFrameBagButton_OnLoad` sets `isBag = 1`, so `SetItemButtonCount`'s
// `isBag and count > 0` arm would print any positive number in the corner — the "1" the
// director saw. `GetInventoryItemCount 0x4c8680` short-circuits every container past 0-based
// slot 0x16 to a literal 0, and the bank's six bag slots are all in that band.
// No count: a bag button's `isBag` makes `SetItemButtonCount` print any count above 0, but
// `GetInventoryItemCount` (`0x4c8680`) returns 0 for a container past 0-based slot 0x16.
assert!(
!s.eval::<bool>("return BankFrameBag1Count:IsShown()")
.unwrap(),
"a bank bag counts nothing — the count fontstring stays hidden"
);
// The lock lets go. `ITEM_LOCK_CHANGED` is all the button gets, and it is enough — PROVIDED
// the snapshot it reads has already been corrected, which is what moving the resolving clear
// ahead of both feeds buys (`ui_items::feed::resolve_item_locks`).
// The lock lets go: `ITEM_LOCK_CHANGED` alone must ungrey it, which holds only because
// `ui_items::feed::resolve_item_locks` corrects the snapshot ahead of both feeds.
bags[0] = Some(a_bag(false));
s.set_bank_bag_slots(bags.clone());
s.fire_event("ITEM_LOCK_CHANGED", vec![]);
@@ -665,8 +548,7 @@ fn a_bank_bag_button_paints_the_drop_and_lets_go_of_the_lock() {
"the stuck grey: unlocked, the button must come back to full colour"
);
// Moved to the next bag slot: the icon has to LEAVE the first button, which only the repaint
// event can do — the lock event never touches the texture.
// Moved to the next bag slot: only the repaint event takes the icon off the first button.
bags[0] = None;
bags[1] = Some(a_bag(false));
s.set_bank_bag_slots(bags);
@@ -1,15 +1,11 @@
//! 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`).
//! So every test here enters from Lua the way those addons do.
//! The stock `BasicControls.xml`: `message`, `_ERRORMESSAGE`, `TEXT` and the `DialogBoxFrame`
//! kit, driven from Lua the way addons call them.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// Fonts then BasicControls — the manifest's own order, and the whole dependency this file has.
/// Fonts.xml is BasicControls.xml's one dependency.
fn basic_controls() -> UiScript {
let mut s = UiScript::new().unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
@@ -19,8 +15,6 @@ fn basic_controls() -> UiScript {
s
}
/// **`message("...")` puts its text in the dialog and shows it** — the whole contract, and the
/// reason the frame has to exist rather than the function being a stub that prints somewhere.
#[test]
fn message_shows_the_script_errors_dialog_carrying_its_text() {
benilla_formats::wow_data_or_skip!();
@@ -39,7 +33,7 @@ fn message_shows_the_script_errors_dialog_carrying_its_text() {
"Validation error: [nil]"
);
// The ref guard: a second message while the dialog is up does NOT overwrite the first.
// A second message while the dialog is up keeps the first (`BasicControls.xml:7`).
s.run(r#"message("second")"#).unwrap();
assert_eq!(
s.eval::<String>("return ScriptErrors_Message:GetText()")
@@ -48,14 +42,11 @@ fn message_shows_the_script_errors_dialog_carrying_its_text() {
"`if not ScriptErrors:IsVisible()` — the first error is the one you keep"
);
// And OKAY hides it, which is the only way it closes.
s.run("ScriptErrorsButton:Click()").unwrap();
assert!(!s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `_ERRORMESSAGE` is the reference's own name for the body, it **returns its argument**, and it
/// is replaceable — which is the entire point for the two corpus addons that replace it.
#[test]
fn error_message_returns_its_argument_and_can_be_replaced() {
benilla_formats::wow_data_or_skip!();
@@ -66,7 +57,7 @@ fn error_message_returns_its_argument_and_can_be_replaced() {
"the ref returns `message` — a caller may chain on it"
);
// ImprovedErrorFrame's shape: swap the global, then `message` must route through the swap.
// ImprovedErrorFrame's pattern: `message` calls whatever `_ERRORMESSAGE` is now.
s.run(
r#"Captured = nil
_ERRORMESSAGE = function(m) Captured = m return m end
@@ -77,11 +68,7 @@ fn error_message_returns_its_argument_and_can_be_replaced() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The reference's closing line is transcribed since decision 1305: BasicControls installs
/// `seterrorhandler(_ERRORMESSAGE)`, so a script error pops the dialog like the real client's —
/// and the host channel stays sighted because the ENGINE records every caught error before the
/// dispatch ever runs (that dual channel is what dissolved the old divergence, whose guard this
/// test used to be).
/// `BasicControls.xml:16` installs `_ERRORMESSAGE` as the error handler.
#[test]
fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
benilla_formats::wow_data_or_skip!();
@@ -91,8 +78,7 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
.unwrap(),
"the reference's own default handler is installed (1305)"
);
// An addon's pcall wrapper calls geterrorhandler()(msg) — with _ERRORMESSAGE installed that
// pops the dialog, exactly as it does in the real client.
// An addon's pcall wrapper reports through `geterrorhandler()`, which pops the dialog.
s.run(r#"geterrorhandler()("reported")"#).unwrap();
assert!(
s.eval::<bool>("return ScriptErrors:IsVisible()").unwrap(),
@@ -103,8 +89,7 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
.unwrap(),
"reported"
);
// And an ENGINE-caught error still reaches `UiScript::errors()` — the instruments' channel —
// with the dispatch adding the dialog on top, not instead.
// An engine-caught error still lands in `errors()`; the dispatch adds the dialog on top.
s.run("BrokenProbe = CreateFrame('Frame') BrokenProbe:RegisterEvent('B271_PROBE') BrokenProbe:SetScript('OnEvent', function() error('fault') end)")
.unwrap();
s.fire_event("B271_PROBE", vec![]);
@@ -120,7 +105,6 @@ fn error_message_is_the_installed_handler_and_the_host_channel_stays_sighted() {
);
}
/// `TEXT` is the identity function, and 26 corpus addons call it because shipped FrameXML does.
#[test]
fn text_is_the_identity_function_the_corpus_expects() {
benilla_formats::wow_data_or_skip!();
@@ -132,9 +116,6 @@ fn text_is_the_identity_function_the_corpus_expects() {
assert_eq!(s.eval::<i64>("return TEXT(7)").unwrap(), 7);
}
/// The dialog kit an addon inherits from directly: `DialogBoxFrame` plus the three named textures.
/// The OKAY button must hide *the caller's* frame, and `$parent` must resolve against the caller's
/// name — `MyDialogButton`, never `DialogBoxFrameButton`.
#[test]
fn a_dialog_box_from_the_template_names_and_closes_the_callers_frame() {
benilla_formats::wow_data_or_skip!();
@@ -1,24 +1,6 @@
//! The **battle map** — `Blizzard_BattlefieldMinimap`, the reference's own LoadOnDemand addon,
//! driven through the road stock `UIParent.lua` drives it down: `ToggleBattlefieldMinimap()`
//! (SHIFT-M) → `BattlefieldMinimap_LoadUI()` → `UIParentLoadAddOn`.
//!
//! benilla has registered the addon as a chain row since 1957 and a live run confirmed it loads —
//! and that was the whole of the evidence. Nothing had ever checked that its twelve overlay
//! textures get cut from `GetMapOverlayInfo`, that its POI pool grows, that the battleground blips
//! and the flag carrier reach it, that its arrow is the *mini* singleton rather than the world
//! map's, or that its tab, dropdown and opacity slider work at all. This file is that check: the
//! whole addon, top to bottom, against the engine verbs it actually calls.
//!
//! **The harness is the whole manifest, not a cut dependency prefix** — the deliberate difference
//! from `tradeskill_frame.rs` / `talent_frame.rs` / `auction_frame.rs`, whose windows each sit on
//! a handful of FrameXML files. This one reads `MiniMapBattlefieldFrame.status` (Minimap.xml),
//! `GetNumWorldStateUI` and `SHOW_BATTLEFIELD_MINIMAP` (WorldStateFrame.lua), `UIOptionsFrame`
//! (UIOptionsFrame.xml), `OpacityFrameSlider` (ColorPickerFrame.xml), the `WorldMapUnitTemplate`
//! family (WorldMapFrameTemplates.xml) and `MAX_PARTY_MEMBERS`/`MAX_RAID_MEMBERS` — five corners
//! of the manifest, which is exactly the state a player is in when they press SHIFT-M. It also
//! lives *in* the crate rather than under `tests/`, because the LoadOnDemand road needs
//! [`super::test_ui::seat_chain_addon`] and an integration test cannot reach it (see
//! `tests/common/mod.rs`'s own note on the `#[cfg(test)]` boundary).
//! The battle map, the `Blizzard_BattlefieldMinimap` addon, loaded as SHIFT-M loads it
//! (`ToggleBattlefieldMinimap`, `UIParent.lua:216-220`). The harness is the whole manifest: the
//! addon reads globals from many of its files.
use benilla_ui::script::{
BattlefieldFlagView, BattlefieldPositionView, QuadContent, UiScript, UnitState,
@@ -26,15 +8,10 @@ use benilla_ui::script::{
WorldStateUiView, ARROW_MODEL,
};
/// **The manifest's own warning floor** — one row, and it is a decided permanent gap rather than
/// anything this addon does: stock `OptionsFrame.lua:300` reads `GetCVar("gxRefresh")` and 2177
/// keeps that CVar unregistered on purpose, because nothing here can set a refresh rate.
/// `world_entry_tests`' own `KNOWN` list carries the same row for the same reason. Pinning the
/// *exact* set rather than allowing any warning is what makes [`quiet`] a real assertion: a
/// warning the addon causes shows up as a second row.
/// The manifest's one warning. Deviation: `gxRefresh`, read at `OptionsFrame.lua:300`, is not
/// registered, because nothing here can set a refresh rate.
const MANIFEST_WARNINGS: [&str; 1] = ["unknown CVar 'gxRefresh' (not host-registered) — ignored"];
/// Zero Lua errors, and no host warning the manifest did not already carry.
fn quiet(s: &UiScript) {
assert!(s.errors().is_empty(), "script errors: {:#?}", s.errors());
assert_eq!(
@@ -44,12 +21,8 @@ fn quiet(s: &UiScript) {
);
}
/// The full interface with the addon **registered but not loaded** — the state a character is in
/// the moment before SHIFT-M.
///
/// The player exists because the in-game UI materializes on world entry (1051) and several stock
/// OnLoads format `UnitName("player")` into their labels; a manifest load with no player is a
/// state the client never reaches (1848).
/// The full interface with the addon registered but not loaded. A player exists because stock
/// OnLoads format `UnitName("player")` into their labels.
fn session() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -70,10 +43,8 @@ fn session() -> UiScript {
s
}
/// The arrow file's facts, as the host hands them over (2007/2015) — `MinimapArrow.m2`'s one
/// looping 3.333 s Stand and its header box, which the implicit rect and the re-centring read.
/// Seated *after* the manifest on purpose: the world map's own arrow is created during that load,
/// so this is the mini arrow's facts and only the mini arrow's.
/// `MinimapArrow.m2`'s facts (one looping 3.333 s Stand, the header box), seated after the
/// manifest load that creates the world map's arrow, so only the mini arrow gets them.
fn arrow_facts(s: &mut UiScript) {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
s.set_model_facts(
@@ -90,7 +61,6 @@ fn arrow_facts(s: &mut UiScript) {
);
}
/// One `WorldMapOverlay` row, revealed by a single explore bit.
fn overlay(texture: &str, w: u32, h: u32, ox: u32, oy: u32, bit: u32) -> WorldMapOverlayView {
WorldMapOverlayView {
texture: format!("Interface\\WorldMap\\WarsongGulch\\{texture}"),
@@ -104,17 +74,9 @@ fn overlay(texture: &str, w: u32, h: u32, ox: u32, oy: u32, bit: u32) -> WorldMa
}
}
/// A one-zone catalog whose zone **has overlays that make the slicing loop do real work**.
///
/// The sizes are chosen so both arms of the reference's tile walk run. `SilverwingHold` is
/// 300×200: `ceil(300/256) = 2` wide, `ceil(200/256) = 1` tall, so its second column takes the
/// remainder arm (`mod(300,256) = 44` px in a 64-px power-of-two file) while the first takes the
/// full-tile arm, and its single row takes the remainder arm vertically (`mod(200,256) = 200` in a
/// 256-px file). `WarsongLumberMill` is exactly 256×256 — the one case where *both* `mod`s come
/// out zero and the reference's `if ( texturePixelWidth == 0 ) then texturePixelWidth = 256` guard
/// is what keeps the tile from collapsing. Between them they need three textures, which is also
/// what makes the second half of [`the_overlay_textures_are_cut_from_the_revealed_overlays`] — the
/// tail that parks — observable.
/// Overlays that run both arms of the stock tile walk (`Blizzard_BattlefieldMinimap.lua:139-168`):
/// `SilverwingHold` (300×200) takes a full and a remainder column and a remainder row, and
/// `WarsongLumberMill` (256×256) zeroes both `mod`s, leaving it to the `== 0 then 256` guard.
fn catalog() -> Vec<WorldMapContinentView> {
vec![WorldMapContinentView {
name: "Kalimdor".into(),
@@ -135,13 +97,9 @@ fn catalog() -> Vec<WorldMapContinentView> {
}]
}
/// Put the session in a battleground and press SHIFT-M.
///
/// The world-state push is the gate, not scenery: `BattlefieldMinimap_Toggle` shows nothing unless
/// `MiniMapBattlefieldFrame.status == "active"` **or** `GetNumWorldStateUI() > 0`, which is the
/// reference refusing to open a battle map outside a battle. The catalog + explored bitset put the
/// displayed map on a zone that has revealed overlays, and `player_zone` is what the window's own
/// `<OnShow>` `SetMapToCurrentZone()` lands on.
/// Put the session in a battleground and press SHIFT-M. The world-state push is the gate:
/// `BattlefieldMinimap_Toggle` shows nothing unless `MiniMapBattlefieldFrame.status == "active"`
/// or `GetNumWorldStateUI() > 0` (`Blizzard_BattlefieldMinimap.lua:21`).
fn open(s: &mut UiScript) {
s.set_world_map_catalog(catalog());
s.set_world_map_explored(vec![0b110; 64]);
@@ -168,8 +126,6 @@ fn open(s: &mut UiScript) {
);
}
/// The window's own per-frame update, driven the way its `<OnUpdate>` script runs it: `this` is
/// the frame and the elapsed time its one argument.
fn update(s: &mut UiScript, elapsed: f64) {
s.run(&format!(
"this = BattlefieldMinimap BattlefieldMinimap_OnUpdate({elapsed}) this = nil"
@@ -177,8 +133,6 @@ fn update(s: &mut UiScript, elapsed: f64) {
.unwrap();
}
/// A frame's centre in the screen units the pointer speaks (its own layout centre × its effective
/// scale) — what a hit test and a `mouse_move` want.
fn screen_centre(s: &mut UiScript, frame: &str) -> (f32, f32) {
s.resolve();
let (x, y, eff) = s
@@ -189,9 +143,6 @@ fn screen_centre(s: &mut UiScript, frame: &str) -> (f32, f32) {
((x * eff) as f32, (y * eff) as f32)
}
/// The mini arrow's screen centre. It is anonymous and it is the window's **last** child (its
/// `Create…` runs in the `<OnLoad>`, after the XML's own forty-eight), so it is found by kind
/// rather than by name or position.
fn arrow_centre(s: &mut UiScript) -> (f32, f32) {
s.resolve();
let (x, y, eff) = s
@@ -217,18 +168,6 @@ fn num(s: &UiScript, expr: &str) -> f64 {
.unwrap_or_else(|e| panic!("{expr} — {e}"))
}
/// **The addon loads on demand and every frame its XML declares materializes.**
///
/// Driven down the reference's own road — `ToggleBattlefieldMinimap()` is what SHIFT-M is bound
/// to, and its first line is `BattlefieldMinimap_LoadUI()` → `UIParentLoadAddOn(...)` → the
/// registry's `LoadAddOn`. Loading it any other way (running the `.xml` as a chain file, the way
/// the auction and tradeskill suites load theirs) would skip the registry, `ADDON_LOADED` and its
/// `arg1` filter — and the `ADDON_LOADED` arm is where the tab gets its seat, the dropdown gets
/// initialized and the opacity gets applied, so it would skip most of the window's initial state
/// too.
///
/// The gate is asserted in both directions first: the load happens either way, but a battle map
/// opens only inside a battle.
#[test]
fn the_addon_loads_on_demand_through_uiparents_own_road() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -240,8 +179,6 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() {
"nothing is loaded before the first toggle"
);
// Out of a battleground: the addon still loads — `BattlefieldMinimap_LoadUI()` runs
// unconditionally — and then `BattlefieldMinimap_Toggle`'s own gate declines to show it.
s.run("ToggleBattlefieldMinimap()").unwrap();
assert_eq!(
s.eval::<i64>("return IsAddOnLoaded(\"Blizzard_BattlefieldMinimap\")")
@@ -259,8 +196,6 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() {
"the RegisterForSave global stays at WorldStateFrame.lua's own boot value"
);
// Every frame, region and font string the `.xml` declares, by name. The loop answers the FIRST
// missing one rather than a bare false, so a regression names itself.
let missing: Option<String> = s
.eval(
r#"
@@ -284,8 +219,6 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() {
.unwrap();
assert_eq!(missing, None, "a declared frame never materialized");
// `BattlefieldMinimapRaidUnitTemplate`'s own OnLoad ran on each of the forty: the unit token
// from the frame's `id=`, and the party blip art on its `$parentIcon`.
assert_eq!(
s.eval::<String>("return BattlefieldMinimapRaid17.unit")
.unwrap(),
@@ -297,7 +230,7 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() {
"Interface\\WorldMap\\WorldMapPartyIcon"
);
// The `ADDON_LOADED` arm: the saved-variable table came up on `BattlefieldMinimapDefaults`…
// The `ADDON_LOADED` arm seeds the saved table from `BattlefieldMinimapDefaults`,
assert_eq!(
s.eval::<(f64, bool, bool)>(
"return BattlefieldMinimapOptions.opacity, BattlefieldMinimapOptions.locked, \
@@ -306,8 +239,7 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() {
.unwrap(),
(0.7, true, true)
);
// …the tab took its default seat (no saved position → the `-225-CONTAINER_OFFSET_X` corner,
// whose two constants are ContainerFrame.lua's and UIParent.lua's)…
// seats the tab at the `-225-CONTAINER_OFFSET_X` corner when no position is saved,
let (point, relative, rel_point, x, y) = s
.eval::<(String, String, String, f64, f64)>(
"local p, r, rp, ox, oy = BattlefieldMinimapTab:GetPoint(1) \
@@ -325,14 +257,13 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() {
num(&s, "BATTLEFIELD_TAB_OFFSET_Y")
)
);
// …and `BattlefieldMinimap_SetOpacity()` ran off the seeded slider value, so the window is
// already wearing the default 0.7 opacity (its alpha is `1 - value`) before it is ever shown.
// and runs `BattlefieldMinimap_SetOpacity()` off the seeded slider, so the default 0.7 opacity
// (alpha `1 - value`) is on before the window first shows.
assert!(
(num(&s, "BattlefieldMinimapBackground:GetAlpha()") - 0.3).abs() < 1e-6,
"the load-time opacity pass reached the border texture"
);
// Now inside a battleground, the same toggle opens it.
open(&mut s);
assert_eq!(
s.eval::<String>("return SHOW_BATTLEFIELD_MINIMAP").unwrap(),
@@ -345,26 +276,15 @@ fn the_addon_loads_on_demand_through_uiparents_own_road() {
quiet(&s);
}
/// **The twelve map tiles and the overlay textures are cut from the map the engine is showing.**
///
/// Two loops, both reading host verbs the addon shares with the world map. The tiles are
/// `GetMapInfo()`'s art folder repeated `NUM_WORLDMAP_DETAIL_TILES` times; the overlays are
/// `GetNumMapOverlays()` / `GetMapOverlayInfo(i)` sliced into 256-px tiles, each scaled by the
/// window's own `BattlefieldMinimap1:GetWidth()/256` — a **56/256 = 0.21875** shrink, which is the
/// whole reason this window's overlay geometry is not the world map's and has to be checked
/// separately.
///
/// Every number below is that arithmetic done by hand from [`catalog`]'s two overlays, so the test
/// fails on a wrong scale, a wrong remainder, a dropped power-of-two round-up or a sign flip in
/// the y offset — not merely on "something was drawn".
/// Overlays are cut into 256-px tiles scaled by `BattlefieldMinimap1:GetWidth()/256` (56/256);
/// every number below is that arithmetic done by hand from [`catalog`].
#[test]
fn the_overlay_textures_are_cut_from_the_revealed_overlays() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
open(&mut s);
// The window's `<OnShow>` already ran `SetMapToCurrentZone()` and `BattlefieldMinimap_Update()`,
// so the displayed map is the player's zone and the tiles are laid.
// The `<OnShow>` already ran `SetMapToCurrentZone()` and `BattlefieldMinimap_Update()`.
assert_eq!(
s.eval::<String>("return GetMapInfo()").unwrap(),
"WarsongGulch"
@@ -385,7 +305,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() {
"2 + 1 tiles: the 300×200 overlay needs two, the 256×256 one needs one"
);
// The reference reads back `(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy)` since 1840; the old
// The reference's `GetTexCoord` returns `(ULx, ULy, LLx, LLy, URx, URy, LRx, LRy)`; the
// `(left, right, top, bottom)` rect is positions 1, 5, 2, 4.
let tex_rect = |s: &UiScript, n: u32| -> (f64, f64, f64, f64) {
let (l, t, _, b, r, ..): (f64, f64, f64, f64, f64, f64, f64, f64) = s
@@ -403,8 +323,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() {
};
const SCALE: f64 = 56.0 / 256.0;
// Tile 1 of SilverwingHold: a full 256-px column, 200 px tall in a 256-px file, at the
// overlay's own (100, 50) offset — x right, y DOWN, both through the shrink.
// Tile 1: a full 256-px column, 200 px of a 256-px file tall, at the (100, 50) offset, y down.
assert_eq!(
s.eval::<String>("return BattlefieldMinimapOverlay1:GetTexture()")
.unwrap(),
@@ -428,8 +347,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() {
)
);
// Tile 2: the remainder column — 44 px of art in the 64-px power-of-two file the client rounds
// up to, seated exactly one full tile (256 × scale) to the right of tile 1.
// Tile 2, the remainder column: 44 px in a 64-px power-of-two file, one tile right of tile 1.
assert_eq!(
s.eval::<String>("return BattlefieldMinimapOverlay2:GetTexture()")
.unwrap(),
@@ -450,9 +368,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() {
)
);
// Tile 3 is the second overlay's only tile: exactly 256×256, the case where both `mod`s are
// zero and the reference's `== 0 then 256` guards are load-bearing — a missing guard would
// give this a zero-width, zero-height, `0..0` cropped tile.
// Tile 3: 256×256 zeroes both `mod`s, so only the `== 0 then 256` guards keep it whole.
assert_eq!(
s.eval::<String>("return BattlefieldMinimapOverlay3:GetTexture()")
.unwrap(),
@@ -473,8 +389,7 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() {
);
}
// Un-explore the lumber mill: the host stops returning that overlay, the repaint lights two
// tiles and PARKS the third — the pool never shrinks, exactly as the world map's does not.
// Un-explore the lumber mill: two tiles stay lit and the third parks; the pool never shrinks.
s.set_world_map_explored(vec![0b010; 64]);
s.tick(0.0); // the push queues WORLD_MAP_UPDATE, the addon's own repaint event
assert_eq!(s.eval::<i64>("return GetNumMapOverlays()").unwrap(), 1);
@@ -491,14 +406,8 @@ fn the_overlay_textures_are_cut_from_the_revealed_overlays() {
quiet(&s);
}
/// **The POI layer** — `GetNumMapLandmarks` / `GetMapLandmarkInfo` → `BattlefieldMinimap_CreatePOI`
/// → `WorldMap_GetPOITextureCoords`, the same three verbs the world map's own pool walks, but
/// seated against the 225×150 battle map and sized by `GetBattlefieldMapIconScale()` rather than
/// left at the flat 12 px the world map uses.
///
/// That scale is the point of the second half: the reference multiplies `DEFAULT_POI_ICON_SIZE` by
/// it on every repaint, so a battleground whose `MinimapIconScale` is not 1 draws bigger icons on
/// the battle map and identical ones on the world map.
/// Each repaint sizes an icon `DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()`
/// (`Blizzard_BattlefieldMinimap.lua:111-112`), seated on the 225×150 window.
#[test]
fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -512,8 +421,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
texture_index: icon,
uv,
};
// Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell — two different cells of
// the 8×8 `POIIcons` atlas, so a hard-coded crop cannot pass both.
// Icon 6 is `ICON_POI_REDFLAG`, icon 9 the second row's first cell of the 8×8 `POIIcons` atlas.
s.set_world_map_landmarks(vec![
landmark("Silverwing Flag", 6, (0.25, 0.5)),
landmark("Warsong Flag", 9, (0.75, 0.25)),
@@ -522,7 +430,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
assert_eq!(s.eval::<i64>("return NUM_BATTLEFIELDMAP_POIS").unwrap(), 2);
assert!(shown(&s, "BattlefieldMinimapPOI1") && shown(&s, "BattlefieldMinimapPOI2"));
// Cell 6 = column 6, row 0; cell 9 = column 1, row 1. `coordIncrement` is 16/128 = 0.125.
// Cell 6 is column 6, row 0; cell 9 is column 1, row 1; `coordIncrement` is 16/128 = 0.125.
let cell = |s: &UiScript, n: u32| -> (f64, f64) {
let (l, t, ..): (f64, f64, f64, f64, f64, f64, f64, f64) = s
.eval(&format!(
@@ -533,8 +441,7 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
};
assert_eq!(cell(&s, 1), (0.75, 0.0));
assert_eq!(cell(&s, 2), (0.125, 0.125));
// Seated CENTER against the window's TOPLEFT, x × its width and y × MINUS its height — v runs
// down the sheet where frame y runs up, and the sign is what mirrors every icon if it is wrong.
// CENTER on the window's TOPLEFT at (x × width, -y × height): v runs down, frame y runs up.
assert_eq!(
s.eval::<(String, String, String, f64, f64)>(
"local p, r, rp, x, y = BattlefieldMinimapPOI1:GetPoint(1) return p, r:GetName(), rp, x, y"
@@ -563,7 +470,6 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
"DEFAULT_POI_ICON_SIZE × GetBattlefieldMapIconScale()"
);
// One landmark left: the pool keeps both frames, re-seats the first and parks the second.
s.set_world_map_landmarks(vec![landmark("Warsong Flag", 9, (0.75, 0.25))]);
s.tick(0.0);
assert_eq!(
@@ -581,14 +487,9 @@ fn the_poi_pool_grows_from_the_landmarks_and_parks_its_tail() {
quiet(&s);
}
/// **The battleground blips** — the four position verbs the window polls every frame, plus the
/// party slots it shares with the world map, driven through the real `<OnUpdate>`.
///
/// The team-member loop is the subtle one: with no raid up, `playerCount` stays 0, so
/// `BattlefieldMinimapRaid<i>` takes `GetBattlefieldPosition(i)` — the raid frames double as the
/// battleground roster. The `(0, 0)` pair is the hide sentinel on every one of these verbs, and
/// the name each row carries is what the blip's tooltip shows for a teammate who is in neither
/// your party nor your raid.
/// With no raid up `playerCount` stays 0, so `BattlefieldMinimapRaid<i>` takes
/// `GetBattlefieldPosition(i)`: the raid frames double as the battleground roster
/// (`Blizzard_BattlefieldMinimap.lua:287-301`). `(0, 0)` hides a blip on every position verb.
#[test]
fn the_battleground_blips_and_the_flag_follow_the_position_family() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -667,7 +568,6 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() {
"a slot with no position hides"
);
// The carrier takes flag frame 1, wearing the token's own art; the second frame stays parked.
assert!(shown(&s, "BattlefieldMinimapFlag1"));
assert_eq!(
s.eval::<String>("return BattlefieldMinimapFlag1Texture:GetTexture()")
@@ -676,8 +576,7 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() {
);
assert!(!shown(&s, "BattlefieldMinimapFlag2"));
// Hovering a teammate's blip names them — `BattlefieldMinimapUnit_OnEnter` prefers the row's
// own `name` over `UnitName(unit)`, which is the only way a non-group teammate gets a label.
// The tooltip prefers the row's `name` over `UnitName` (`Blizzard_BattlefieldMinimap.lua:485`).
let (bx, by) = screen_centre(&mut s, "BattlefieldMinimapRaid1");
assert_eq!(
s.hit_test_name(bx, by).as_deref(),
@@ -693,12 +592,9 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() {
);
s.mouse_move(5.0, 5.0);
// The dropdown's "Show Teammates" off: the party and raid blips go in one pass, before any of
// the position verbs are consulted — and **the flag carrier stays**. That asymmetry is the
// reference's own: `Blizzard_BattlefieldMinimap.lua:245-252`'s `not showPlayers` arm hides
// `BattlefieldMinimapParty1..4` and `BattlefieldMinimapRaid1..40` and nothing else, so the two
// flag frames simply keep whatever the last showPlayers-on pass left on them. Pinned in both
// directions on purpose: "hide everything" is the obvious tidy-up and it is not what 1.12 does.
// Show Teammates off hides the party and raid blips and leaves the flag carrier up: the
// `not showPlayers` arm (`Blizzard_BattlefieldMinimap.lua:245-252`) hides only
// `BattlefieldMinimapParty1..4` and `BattlefieldMinimapRaid1..40`.
s.run("BattlefieldMinimapOptions.showPlayers = false")
.unwrap();
update(&mut s, 0.1);
@@ -710,7 +606,6 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() {
"…but the carrier's flag is not in that arm's two loops, so it stays up"
);
// Back on, and then leaving the battleground — the empty push — hides them for real.
s.run("BattlefieldMinimapOptions.showPlayers = true")
.unwrap();
update(&mut s, 0.1);
@@ -724,17 +619,9 @@ fn the_battleground_blips_and_the_flag_follow_the_position_family() {
quiet(&s);
}
/// **The player arrow is the MINI singleton, not the world map's.**
///
/// The two are separate slots in the engine (`Arrow::World` / `Arrow::Mini`), each created once per
/// session by its own `Create…` verb and never freed — so the failure this guards against is the
/// addon's `CreateMiniWorldMapArrowFrame(BattlefieldMinimap)` being answered with the world map's
/// existing arrow, which would leave the battle map with no arrow at all and yank the world map's
/// across the screen on every `PositionMini…`.
///
/// Told apart by the one property that distinguishes them at the renderer: the mini's model scale
/// is `G48 · 10/9` where the world map's is `G48 · 5/3` (`G48 = 1/√(aspect²+1)`, so at 4:3 that is
/// 0.6667 against 1.0). The seat is cross-checked in Lua against the window's own TOPLEFT.
/// The world map and the battle map each own one arrow (`Arrow::World`, `Arrow::Mini`), created
/// once by its own `Create…` verb. The mini's model scale is `G48 · 10/9` and the world map's
/// `G48 · 5/3`, with `G48 = 1/√(aspect²+1)`: 0.6667 against 1.0 at 4:3.
#[test]
fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -742,7 +629,6 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() {
arrow_facts(&mut s);
open(&mut s);
// `BattlefieldMinimap_OnLoad` made it: an anonymous `Model` child of the window.
assert_eq!(
s.eval::<i64>(
r#"local n = 0
@@ -766,8 +652,6 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() {
);
update(&mut s, 0.1);
// The seat, read back in Lua — the arrow is anonymous, so it is found among the children by
// kind. Same CENTER-on-TOPLEFT law as every blip.
let seat = s
.eval::<(bool, String, String, f64, f64)>(
r#"for _, c in ipairs({ BattlefieldMinimap:GetChildren() }) do
@@ -789,8 +673,6 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() {
)
);
// The renderer's side: the arrow model, turned to the player's facing by
// `UpdateWorldMapArrowFrames`, at the MINI's own model scale, on a rect centred where Lua says.
let (cx, cy) = arrow_centre(&mut s);
let pane = s
.extract()
@@ -821,8 +703,7 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() {
{model_scale}"
);
// Off the displayed map: the `(0, 0)` sentinel routes through `ShowMiniWorldMapArrowFrame(nil)`
// and the pane leaves the render list entirely.
// Off the displayed map, `(0, 0)` calls `ShowMiniWorldMapArrowFrame(nil)`.
s.set_world_map_feed(Some((1, 1)), None, 0.0, None, Vec::new(), Vec::new());
update(&mut s, 0.1);
s.resolve();
@@ -836,17 +717,9 @@ fn the_player_arrow_is_the_minis_own_singleton_seated_by_the_update() {
quiet(&s);
}
/// **The tab** — the strip of chat-frame art the window hangs from: it comes and goes with the
/// window, fades in under the cursor on the reference's own three-frame ramp, and refuses to be
/// dragged while the options say the map is locked.
///
/// The hover ramp is worth spelling out because it looks like a bug and is not. Frame 1 takes the
/// `else` arm and only *starts* hovering (it stores the cursor into the globals `CURSOR_OLD_X/Y`
/// and never into `BattlefieldMinimap.oldX/oldy` — the reference's own slip). Frame 2 therefore
/// compares a nil `oldX` against the cursor, misses, and resets `hoverTime` to 0 while finally
/// recording the position. Only frame 3 can accumulate, so the `BATTLEFIELD_TAB_SHOW_DELAY` clock
/// does not start until the third update after the cursor arrives. A "fix" that fades on frame 2
/// goes red here, which is the point.
/// The hover ramp is the reference's own slip (`Blizzard_BattlefieldMinimap.lua:330-356`): frame 1
/// stores the cursor in `CURSOR_OLD_X/Y`, never `BattlefieldMinimap.oldX`, so frame 2 misses and
/// resets `hoverTime`, and `BATTLEFIELD_TAB_SHOW_DELAY` counts from frame 3.
#[test]
fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -865,7 +738,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock()
"…and starts invisible: the OnLoad's own SetAlpha(0)"
);
// The hover ramp.
let (mx, my) = screen_centre(&mut s, "BattlefieldMinimap");
s.mouse_move(mx, my);
update(&mut s, 0.3);
@@ -892,8 +764,8 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock()
(0.3, Some(1)),
"frame 3 finally crosses BATTLEFIELD_TAB_SHOW_DELAY and starts the fade"
);
// `UIFrameFadeIn` runs on the fade manager's own update, from 0 to DEFAULT_BATTLEFIELD_TAB_ALPHA
// over BATTLEFIELD_TAB_FADE_TIME.
// `UIFrameFadeIn` runs on the fade manager's update, from 0 to `DEFAULT_BATTLEFIELD_TAB_ALPHA`
// (0.75) over `BATTLEFIELD_TAB_FADE_TIME` (0.15 s).
s.tick(0.05);
let part_way = num(&s, "BattlefieldMinimapTab:GetAlpha()");
assert!(
@@ -903,7 +775,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock()
s.tick(0.2);
assert_eq!(num(&s, "BattlefieldMinimapTab:GetAlpha()"), 0.75);
// The cursor leaves: the else arm fades it back to the alpha it was remembered at.
s.mouse_move(5.0, 5.0);
update(&mut s, 0.1);
assert_eq!(
@@ -915,8 +786,7 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock()
s.tick(0.2);
assert_eq!(num(&s, "BattlefieldMinimapTab:GetAlpha()"), 0.0);
// The lock. A left click on a locked tab returns before `StartMoving`, and `StartMoving` is
// what stamps the userPlaced bit (2193), so the bit is the observable in both directions.
// A left click on a locked tab returns before `StartMoving`, which sets the user-placed bit.
assert!(!s
.eval::<bool>("return BattlefieldMinimapTab:IsUserPlaced()")
.unwrap());
@@ -963,7 +833,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock()
"a tab nobody placed clears the saved row"
);
// The second toggle closes it, and the tab goes with it through the window's own OnHide.
s.run("ToggleBattlefieldMinimap()").unwrap();
assert!(!shown(&s, "BattlefieldMinimap") && !shown(&s, "BattlefieldMinimapTab"));
assert_eq!(
@@ -971,8 +840,6 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock()
"0"
);
// And leaving the battleground closes it on the client's own event, not on a toggle: the
// `PLAYER_ENTERING_WORLD` arm hides a battle map with no battle behind it.
s.run("ToggleBattlefieldMinimap()").unwrap();
assert!(shown(&s, "BattlefieldMinimap"));
s.set_world_state_ui(Vec::new());
@@ -985,29 +852,17 @@ fn the_tab_follows_the_window_fades_in_on_hover_and_gates_its_drag_on_the_lock()
quiet(&s);
}
/// **The tab's right-click menu and the opacity slider it opens** — the window's whole options
/// surface, and the only place `BattlefieldMinimapOptions` is written by a player.
///
/// Worth stating what this path does **not** touch, because it is easy to assume otherwise:
/// `BattlefieldMinimap_SetOpacity` reads no CVar. Its input is `OpacityFrameSlider:GetValue()` —
/// ColorPickerFrame.xml's shared vertical slider, which the addon borrows by pointing
/// `OpacityFrame.opacityFunc` at itself — and what persists is the addon's own
/// `SavedVariablesPerCharacter` table plus WorldStateFrame.lua's `RegisterForSave` global
/// `SHOW_BATTLEFIELD_MINIMAP`. There is no battle-map opacity CVar in 1.12 to register or to give
/// a reference default to, so 1804's rule does not reach this window.
///
/// The alpha formula has two tiers and both are pinned: the border and the twelve detail tiles get
/// `1 - value`, while the overlays, the close button and the corner get a further 0.15 off it
/// (and, below 0.15, nothing off at all) — so a regression that applies one tier everywhere shows
/// up as the map art and its explored overlays being the same brightness.
/// `BattlefieldMinimap_SetOpacity` reads no CVar: its input is the shared `OpacityFrameSlider`, and
/// what persists is the `SavedVariablesPerCharacter` table and `SHOW_BATTLEFIELD_MINIMAP`. The
/// border and the twelve tiles take `1 - value`; the overlays, close button and corner take a
/// further 0.15 off unless the alpha is under 0.15 (`Blizzard_BattlefieldMinimap.lua:376-390`).
#[test]
fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = session();
open(&mut s);
// A real right-click on the tab — `RegisterForClicks` admits `RightButtonUp`, and the handler's
// first arm opens the menu on the tab's own anchor.
// A real right-click: the tab registers `RightButtonUp` (`Blizzard_BattlefieldMinimap.xml:96`).
s.run("BattlefieldMinimapTab:Click(\"RightButton\")")
.unwrap();
assert!(shown(&s, "DropDownList1"), "the menu opened");
@@ -1019,8 +874,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() {
.unwrap(),
)
};
// Both toggles come up checked off `BattlefieldMinimapDefaults`; the opacity row is an action,
// so it carries no check.
// Both toggles are checked off `BattlefieldMinimapDefaults`; the opacity row is an action.
assert_eq!(row(&s, 1), ("Show Teammates".into(), true));
assert_eq!(row(&s, 2), ("Lock Battle Map".into(), true));
assert_eq!(row(&s, 3), ("Change Opacity".into(), false));
@@ -1045,8 +899,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() {
"row 2 flips the lock"
);
// Row 3 borrows the shared opacity frame: re-anchored onto the battle map's left edge, with
// both of its hooks pointed at this window.
// Row 3 re-anchors the shared opacity frame on the battle map and points its hooks here.
s.run("BattlefieldMinimapTab:Click(\"RightButton\")")
.unwrap();
s.run("DropDownList1Button3:Click()").unwrap();
@@ -1065,8 +918,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() {
)
);
// Dragging the slider repaints through `OpacityFrame.opacityFunc` — the slider's own
// `<OnValueChanged>` is the only caller, so this is the live path, not a direct call.
// The slider's `<OnValueChanged>` repaints through `OpacityFrame.opacityFunc`.
s.run("OpacityFrameSlider:SetValue(0.25)").unwrap();
let alpha = |s: &UiScript, f: &str| num(s, &format!("{f}:GetAlpha()"));
for f in [
@@ -1098,7 +950,7 @@ fn the_dropdown_and_the_opacity_slider_drive_the_saved_options() {
"under 0.15 the overlays keep the plain alpha"
);
// Dismissing the frame runs `saveOpacityFunc`, which is what puts the value in the saved table.
// Dismissing the frame runs `saveOpacityFunc`, which writes the saved table.
s.run("OpacityFrameCloseButton:Click()").unwrap();
assert!(!shown(&s, "OpacityFrame"));
assert!(
@@ -1,20 +1,15 @@
//! 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.
//! The stock battleground queue UI, `BattlefieldFrame.xml` and the minimap queue icon, driven by
//! pushed queue and list state.
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).
/// The stock pair plus what they call: the panel kit, the confirm dialog, the icon's menu.
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.
// The list verbs answer nothing without a local player, so seat one.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -25,8 +20,7 @@ fn session() -> UiScript {
}),
);
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.
// `FloatingChatFrame.lua:9`'s value, read by the icon's fade-in (`BattlefieldFrame.lua:142`).
s.run("CHAT_FRAME_FADE_TIME = 0.15").unwrap();
for f in [
"Interface\\FrameXML\\Fonts.xml",
@@ -77,8 +71,6 @@ fn visible(s: &UiScript, frame: &str) -> bool {
.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!();
@@ -98,7 +90,7 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() {
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.
// `SecondsToTime` ends every unit with a space (`UIParent.lua:1023`).
assert!(
tooltip.contains("Average wait time: 1 Min (Last 10 players)"),
"{tooltip}"
@@ -110,7 +102,7 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() {
);
assert!(!visible(&s, "StaticPopup1"), "queued is not yet a question");
// Ready: the confirm dialog with the map name and the port countdown.
// Status 2, ready to enter: the confirm dialog 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!(
@@ -131,11 +123,10 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() {
),
"{tooltip}"
);
// The dialog's Enter Battle is AcceptBattlefieldPort(slot, 1).
// Enter Battle calls `AcceptBattlefieldPort(slot, 1)` (`StaticPopup.lua:101`).
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!(
@@ -144,9 +135,6 @@ fn the_queue_icon_follows_the_slots_across_update_battlefield_status() {
);
}
/// 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!();
@@ -209,7 +197,7 @@ fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() {
"…disabled while not leading a group"
);
// Join the default selection (row 1 = first available), then a specific instance.
// Row 1, the default selection, is First Available and joins instance 0.
s.run("BattlefieldFrameJoinButton:Click()").unwrap();
assert_eq!(s.take_battlefield_join_requests(), vec![(0, false)]);
assert!(!visible(&s, "BattlefieldFrame"), "joining hides the panel");
@@ -228,8 +216,6 @@ fn the_list_window_opens_on_battlefields_show_and_joins_the_selection() {
);
}
/// 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!();
@@ -1,13 +1,11 @@
//! The innkeeper bind confirm (decision 1331, BinderConfirm.xml): the dialog `ui_binder`'s feed
//! raises, its Accept, and the range poll that takes it away — driven exactly as that feed and the
//! app's NPC-session guard drive it.
//! The innkeeper bind confirm, the stock `CONFIRM_BINDER` popup, driven as `ui_binder`'s feed
//! and the app's NPC range guard drive it.
use benilla_ui::script::{ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
/// The app's own pre-state: a question is pending and in range, which is what
/// `CheckBinderDist()` reports while the dialog is up.
/// A bind question pending and in range, so `CheckBinderDist()` holds.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -25,9 +23,7 @@ fn setup() -> UiScript {
s
}
/// `CONFIRM_BINDER(area)` raises the dialog with the area name filled in, and Accept queues the
/// one `ConfirmBinder()` that becomes `CMSG_BINDER_ACTIVATE`. The whole point of the arc: before
/// this wiring the click produced no dialog and no packet at all (B249).
/// Accept's one `ConfirmBinder()` becomes `CMSG_BINDER_ACTIVATE`.
#[test]
fn the_confirm_shows_the_area_and_accept_queues_the_bind() {
benilla_formats::wow_data_or_skip!();
@@ -55,8 +51,7 @@ fn the_confirm_shows_the_area_and_accept_queues_the_bind() {
);
}
/// Cancel and ESC both send **nothing**: declining an innkeeper is silent on the wire (there is
/// no decline opcode), so the only observable is that no confirm was queued.
/// There is no decline opcode: Cancel and Escape send nothing.
#[test]
fn declining_sends_nothing() {
benilla_formats::wow_data_or_skip!();
@@ -74,9 +69,7 @@ fn declining_sends_nothing() {
);
}
/// Walking away takes the question off screen: the entry's OnUpdate polls `CheckBinderDist()`,
/// which the app drives from the shared NPC-session range guard. While it holds, ticking changes
/// nothing; the frame it goes false, the dialog hides itself — with no packet either way.
/// The popup's OnUpdate hides it once `CheckBinderDist()` fails (`StaticPopup.lua:1329`).
#[test]
fn leaving_the_innkeepers_range_hides_the_confirm() {
benilla_formats::wow_data_or_skip!();
@@ -1,34 +1,12 @@
//! **The bottom-of-screen clearance law, enforced** (decision 1499).
//!
//! The bottom band of the screen is shared: the main bar sits on it, the extra action bars, the
//! stance/pet bar and the reputation watch bar stack above it, and the bag windows, the cast bar,
//! the chat panes and the default tooltip anchor all have to keep clear of whatever is standing.
//! `UIParent_ManageFramePositions()` (UIParent.xml) is the ONE thing that decides that — it seats
//! the managed frames and writes the managed globals — and every one of the defects below was a
//! frame that quietly opted out of it.
//!
//! Three guards, each pinning a different way to opt out:
//!
//! 1. [`no_shipped_file_declares_its_own_copy_of_a_managed_offset`] — the *shadow copy*.
//! `BagFrame.xml` kept `local BENILLA_CONTAINER_OFFSET_X/Y = 0/70`. Locals, so the pass's
//! writes could never reach them; the open-bag stack sat at the no-bars corner forever and a
//! raised bottom multibar drew straight through the lowest bag window (director-caught — the
//! screenshot that opened 1499). `GameTooltip.xml` carried the softer `X = X or 0` form of the
//! same thing. A value with two written statements has no owner.
//!
//! 2. [`every_bottom_anchored_top_level_frame_is_accounted_for`] — the *new frame nobody wired
//! up*. This is the guard that has to hold for features that do not exist yet: a top-level
//! frame anchored to the screen's bottom edge is, by construction, in the contested band, so it
//! must be a managed row, a listener consumer, or an exemption someone wrote a reason for.
//!
//! 3. [`no_bottom_band_frame_overlaps_a_raised_bar`] — the *symptom itself*, over the real shipped
//! XML: for every combination of raised bars, no bottom-band frame's rect may intersect a
//! visible bar's. This is the one that would have failed on the director's screenshot.
//! The bottom-band clearance guards. The main bar, the extra action bars, the stance and pet bars
//! and the reputation bar stack in the bottom band; everything else there (the bags, the cast bar,
//! the chat panes, the default tooltip anchor) clears them through `UIParent_ManageFramePositions`
//! (`UIParent.lua`), which seats the managed frames and writes the managed offsets.
use benilla_ui::script::UiScript;
/// The globals `UIPARENT_MANAGED_FRAME_POSITIONS`'s `isVar` rows own. Only `UIParent.xml` may
/// write these; everyone else reads them fresh at use (falling back inline, never by assigning).
/// Offsets the stock FrameXML writes and the manage pass uses: our files may read them, never
/// assign them.
const MANAGED_GLOBALS: &[&str] = &[
"CONTAINER_OFFSET_X",
"CONTAINER_OFFSET_Y",
@@ -41,10 +19,8 @@ fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// Blank out `<!-- … -->` regions, keeping every newline so line numbers still point at the file.
/// The scan below reads Lua, and an XML comment is prose: `BagFrame.xml`'s own explanation of the
/// backpack anchor says "CONTAINER_OFFSET_X=0 / CONTAINER_OFFSET_Y=70" in English, which is a
/// description of the law, not a violation of it.
/// Blanks out `<!-- … -->` regions, newlines kept so line numbers hold: an XML comment is prose,
/// not Lua, even when it spells a managed name.
fn without_xml_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
@@ -77,31 +53,21 @@ fn shipped_xml() -> Vec<(String, String)> {
})
.collect();
out.sort();
// 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).
// A floor for the walk, not a census: `assets/ui` shrinks as windows move to the stock files.
assert!(out.len() >= 6, "only {} xml files swept", out.len());
out
}
/// **Nothing outside `UIParent.xml` may ASSIGN a managed offset** — not as a local, not as a
/// global, not as an `or`-guarded default.
///
/// Reading one is fine and expected (`-CONTAINER_OFFSET_X` in the right stack, `CONTAINER_OFFSET_Y`
/// in the bag stack and the tooltip anchor). Falling back at the point of use is fine
/// (`local offsetY = CONTAINER_OFFSET_Y or 70`) — that is a read with a default, and it is what the
/// per-window harnesses that ship no `UIParent.xml` need. What is forbidden is *storing* the value
/// anywhere the pass cannot reach, because the pass recomputes these on every bar change and a
/// stored copy is stale from the moment the next bar goes up.
///
/// The rule is spelled as "an assignment statement whose left-hand side names a managed global,
/// or a name containing one" — the second half is what catches `BENILLA_CONTAINER_OFFSET_Y`, which
/// was not the global's name at all and is exactly why nothing noticed it for so long.
/// No file of ours assigns a managed offset, as a local, a global or an `or`-guarded default: the
/// pass recomputes them on every bar change, so a stored copy goes stale. Reading one with an
/// inline fallback (`CONTAINER_OFFSET_Y or 70`) is fine; a left-hand side that only contains the
/// name counts as an assignment.
#[test]
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 == r"Interface\FrameXML\UIParent.xml" {
continue; // the owner: its var rows are where these numbers are defined
continue; // the owner: its var rows define these
}
for (n, line) in without_xml_comments(&text).lines().enumerate() {
let code = line.split("--").next().unwrap_or("");
@@ -130,10 +96,8 @@ fn no_shipped_file_declares_its_own_copy_of_a_managed_offset() {
);
}
/// Frames whose seat the manage pass owns — the `UIPARENT_MANAGED_FRAME_POSITIONS` keys that name
/// a frame, plus the two the pass's custom tail seats by hand. Kept here as literal text so this
/// test and `UIParent.xml` cannot drift apart silently: the next guard asserts every one of these
/// really appears in the shipped pass.
/// Frames the manage pass seats: the `UIPARENT_MANAGED_FRAME_POSITIONS` frame rows and four that
/// its tail seats by hand. The next guard checks each still appears in the stock `UIParent.lua`.
const MANAGED_FRAMES: &[&str] = &[
"MultiBarBottomLeft",
"GroupLootFrame1",
@@ -149,9 +113,8 @@ const MANAGED_FRAMES: &[&str] = &[
"QuestWatchFrame",
];
/// Top-level frames that ARE anchored to the screen's bottom edge and are deliberately not managed.
/// Every entry carries the reason, because "it was already like that" is how the bag stack got its
/// shadow copy. Adding a frame here is a decision; adding one silently is what this test forbids.
/// Top-level frames anchored to the screen's bottom edge that the pass does not manage, each with
/// its reason.
const BOTTOM_EXEMPT: &[(&str, &str)] = &[
(
"MainMenuBar",
@@ -196,19 +159,12 @@ const BOTTOM_EXEMPT: &[(&str, &str)] = &[
),
];
/// **A top-level frame anchored to the screen's bottom edge is either managed, or exempt with a
/// stated reason.** There is no third option, and this is the guard that covers features nobody
/// has written yet.
///
/// The band at the bottom of the screen is contested by construction: the main bar owns it, and
/// every optional bar stacks upward into it. So the moment a new window anchors `BOTTOM`,
/// `BOTTOMLEFT` or `BOTTOMRIGHT` to the screen root, it has taken on a clearance problem — and the
/// only correct answers are to join `UIPARENT_MANAGED_FRAME_POSITIONS`, to seat itself from a
/// managed global and register a listener (the bag stack's answer), or to say out loud why it does
/// not need to. Failing here is not a bug report; it is a prompt to pick one.
/// A top-level frame anchored to the screen's bottom edge sits in the contested band, so it has a
/// `UIPARENT_MANAGED_FRAME_POSITIONS` row, seats itself from a managed offset with a listener, or
/// is listed in `BOTTOM_EXEMPT` with its reason.
#[test]
fn every_bottom_anchored_top_level_frame_is_accounted_for() {
// The pass is the stock `UIParent.lua`'s since 1988 — read off the player's chain.
// The pass is the stock `UIParent.lua`'s, 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")
@@ -244,13 +200,9 @@ fn every_bottom_anchored_top_level_frame_is_accounted_for() {
);
}
/// 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
/// to the same full-screen rect.
///
/// Only the frame's OWN anchors, not its children's: a child that anchors BOTTOM to its parent is
/// stating a position inside a window, which is nobody's clearance problem.
/// Top-level instances (not `virtual`, no `parent`) whose own anchors put a BOTTOM point on the
/// screen root: no `relativeTo`, or `UIParent` or `WorldFrame`, all the full screen. A child's
/// anchors place it inside its window, so they do not count.
fn bottom_anchored_top_level(doc: &benilla_ui::framexml::ParsedDocument) -> Vec<(String, String)> {
let mut out = Vec::new();
for item in &doc.items {
@@ -285,13 +237,8 @@ fn bottom_anchored_top_level(doc: &benilla_ui::framexml::ParsedDocument) -> Vec<
out
}
/// Every bar a player can raise into the band the tenants below have to share.
///
/// The two VERTICAL bars are in here deliberately, even though they are the only pair that moves
/// the tenants sideways rather than upward: `CONTAINER_OFFSET_X`'s `rightLeft = 90` /
/// `rightRight = 45` arithmetic has never once run in a shipped session, because until the
/// visibility options (1500) both bars were unconditionally hidden and the flags could not be set.
/// Half of the managed table was dead arithmetic nothing exercised. It runs here.
/// The bars a player can raise into the band. The two vertical bars move the tenants sideways,
/// through the `rightLeft` and `rightRight` terms of `CONTAINER_OFFSET_X`.
const RAISABLE_BARS: &[&str] = &[
"MultiBarBottomLeft",
"MultiBarBottomRight",
@@ -300,22 +247,14 @@ const RAISABLE_BARS: &[&str] = &[
"MultiBarLeft",
];
/// **The symptom, pinned: with any combination of bars raised, nothing in the bottom band overlaps
/// a raised bar.**
///
/// This is the assertion that would have failed on the screenshot that opened 1499 — an open bag
/// window drawn straight through a bottom multibar's buttons. It runs against the REAL shipped
/// XML, drives the bars' visibility directly (so it keeps holding however the toggles are
/// eventually wired), and re-runs the pass between combinations exactly as a live bar change does.
/// With any combination of bars raised, nothing in the bottom band overlaps a raised bar. Runs the
/// full UI and reruns the pass after each combination, as a live bar change does.
#[test]
fn no_bottom_band_frame_overlaps_a_raised_bar() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
// The in-game UI loads on world entry, so a player always exists by then.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -328,18 +267,14 @@ fn no_bottom_band_frame_overlaps_a_raised_bar() {
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// Open every bag window. The stack only exists once something is in it, and an EMPTY stack is
// exactly the state that hid this defect from every earlier test — `bag_tests` never loads
// `MultiBars.xml` and the multibar tests never open a bag, so no suite had both on screen at
// once. Shown through the real OnShow, so the stack layout runs the way a B keypress runs it.
s.run(
"for _, f in ipairs({BenillaBagFrame, BenillaBagFrame1, BenillaBagFrame2, \
BenillaBagFrame3, BenillaBagFrame4}) do if f then f:Show() end end",
)
.unwrap();
// Everything that sits in the band and must give way. Each is checked only while shown, so a
// frame that never appears contributes nothing — which is why the pair counter below matters.
// The frames in the band that must clear the bars. Each is checked only while shown, hence
// the pair floor below.
const TENANTS: &[&str] = &[
"BenillaBagFrame",
"BenillaBagFrame1",
@@ -355,11 +290,8 @@ 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).
// The stock pass takes its bottom-bar flags from `SHOW_MULTI_ACTIONBAR_1`/`_2`, not
// from the frames' shown state (`UIParent.lua:1599-1607`).
let global = match *bar {
"MultiBarBottomLeft" => Some("SHOW_MULTI_ACTIONBAR_1"),
"MultiBarBottomRight" => Some("SHOW_MULTI_ACTIONBAR_2"),
@@ -402,9 +334,7 @@ fn no_bottom_band_frame_overlaps_a_raised_bar() {
}
}
// Never let this pass by testing nothing: a renamed bag frame or a bar that fails to show
// would otherwise turn the whole guard green while checking zero pairs. With three raisable
// bars over seven tenants the real number is in the dozens.
// A floor on the pairs compared, so the sweep cannot pass by comparing nothing.
assert!(
pairs >= 24,
"only {pairs} bar/tenant pairs were actually compared — the sweep is not exercising the \
@@ -427,44 +357,22 @@ fn rect(s: &UiScript, name: &str) -> (f32, f32, f32, f32) {
.unwrap_or_else(|e| panic!("rect of {name}: {e}"))
}
/// Touching edges do not overlap — the stack seats windows flush against each other by design.
/// Touching edges do not overlap: the stack seats windows flush against each other.
fn overlaps(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> bool {
a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3
}
/// **The item-push card's travel band overlaps a raised bottom bar — and so does the reference's.**
///
/// The director reported the looted-item card as "hidden behind" the extra action bar in the same
/// breath as the bag overlap, so this measures it instead of reasoning about it. The answer is not
/// the one the bag half had:
///
/// ```text
/// card at its opaque peak x 1253.7..1298.0 y 48.9..93.1
/// MultiBarBottomRight x 806.0..1306.0 y 57.0..95.0
/// ```
///
/// The card is MEDIUM (a child of a MEDIUM bag button); the bars are `frameStrata="HIGH"`
/// (`MultiActionBars.xml` l.36/151/266/381). **The reference has exactly this arrangement** — its
/// bag buttons are `parent="MainMenuBarArtFrame"` under a `MainMenuBar` with no strata at all, and
/// its `MultiBarBottomRight` runs from `MultiBarBottomLeft`'s right edge +10 for 500 px, which puts
/// its last buttons directly over the bag bar at every resolution. So a push into a bag draws
/// behind that bar's twelfth button whenever that slot is filled, in the real 1.12.1 client too.
/// It goes unnoticed because a multibar's EMPTY wells are hidden, so most players have nothing
/// there; the director's screenshot has a chicken in exactly that slot.
///
/// This is therefore a **finding test, not a guard**: it records what the reference's own geometry
/// produces so that changing either side has to come and edit these numbers on purpose. If we ever
/// diverge deliberately — raising the card's strata, or lifting its band clear of the bars — this
/// is the test that says so out loud rather than the change slipping through green.
/// The item-push card overlaps a raised bottom bar, as in the reference: the card is MEDIUM under
/// the bag button, the multibars are `frameStrata="HIGH"` (`MultiActionBars.xml:36`), and
/// `MultiBarBottomRight`'s last buttons sit over the bag bar, so a push draws behind a filled
/// twelfth button. At 1600x900 the card peaks at x 1253.7..1298.0, y 48.9..93.1, and the bar
/// spans x 806..1306, y 57..95.
#[test]
fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference_does() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
// The in-game UI loads on world entry, so a player always exists by then.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -476,8 +384,8 @@ 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).
// The stock `<Model>` card sizes from its file's box: `ForcedBackpackItem.m2` has one 1000 ms
// non-looping sequence and a 0.02707 x 0.07962 box, in model units.
s.set_model_facts(
r"Interface\ItemAnimations\ForcedBackpackItem.mdx",
benilla_ui::widget::ModelFileFacts {
@@ -501,17 +409,15 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
s.tick(0.133); // the opaque peak — the instant the card is most visible
s.tick(0.133); // the card's opaque peak
s.resolve();
assert!(
shown(&s, "MainMenuBarBackpackButtonItemAnim"),
"the card plays"
);
// 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).
// The pane is the file's box in layout units at 16:9: 42.41 x 124.72, off the button's
// BOTTOMRIGHT (-10, 0). The reference's card peaks at 48.9..93.1 above the floor, inside it.
let card = rect(&s, "MainMenuBarBackpackButtonItemAnim");
let bar = rect(&s, "MultiBarBottomRight");
assert!(
+61 -224
View File
@@ -1,19 +1,5 @@
//! 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.
//!
//! **The window runs on the 1.12 verbs and the reference's index space** (b2ede294 landed the
//! `GetPlayerBuff` family): a button's `id` is a 0-based ordinal within its own filter,
//! `GetPlayerBuff` turns it into a CACHE POSITION, and every read below — texture, dispel class,
//! stacks, time left, cancel, tooltip — consumes that position. The tests drive it the way the
//! corpus does, including the `while GetPlayerBuff(i) >= 0 do` walk and a cancel by position.
//!
//! State is read back through the widget (`IsShown`/`GetText`/`GetAlpha`) and the paint through the
//! [`draw list`](UiScript::extract) (icon path + border tint), the same two-lens split the cast-bar
//! tests use — Lua-visible state is blind to what actually renders.
//! The stock buff bar (`BuffFrame.xml`) under a stubbed aura feed. A button's `id` is an ordinal
//! within its filter; `GetPlayerBuff` maps it to the cache position every other buff verb takes.
use benilla_ui::script::{AuraState, ExtractedQuad, QuadContent, UiScript};
@@ -22,18 +8,14 @@ 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);
// The one-letter duration strings (DAY/HOUR/MINUTE/SECOND_ONELETTER_ABBR) the bar prints
// 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.
// The one-letter duration strings, which `SecondsToTimeAbbrev` formats unguarded.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
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, "Interface\\FrameXML\\Fonts.xml");
// `BuffButton_Update` asks `GameTooltip:IsOwned(this)` unguarded (`BuffFrame.lua:104`).
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.
// `SecondsToTimeAbbrev` comes with `UIParent.xml` (`UIParent.lua:1034`).
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -48,27 +30,17 @@ fn harness() -> UiScript {
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.
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// Timers on for these tests; the 1.12 default is "0" (`UIOptionsFrame.lua:104`).
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`).
// `BuffFrame_OnLoad` does not call `BuffButtons_UpdatePositions`: in 1.12 that is
// `UIOptionsFrame.lua`'s job and we have no counterpart to that file. A session that skips it
// is laid out for durations-OFF while the setting says on.
// As the app does: `BuffFrame_OnLoad` never calls `BuffButtons_UpdatePositions`, which 1.12
// runs on `VARIABLES_LOADED` (`UIOptionsFrame.lua:206`).
super::manifest::apply_buff_durations(&s).unwrap();
s
}
/// Build an [`AuraState`] as the feed would push it. `expiration_time` 0 = permanent (no wire
/// duration — the reference's "until cancelled"); the `GetTime()` clock starts at 0 in the harness.
/// An [`AuraState`]: `expiration_time` 0 is permanent, and the harness's `GetTime()` starts at 0.
fn aura(
spell_id: u32,
name: &str,
@@ -89,20 +61,13 @@ fn aura(
expiration_time,
helpful,
cancelable,
// What the feed derives for a permanent aura: no finite duration to display. Here the two
// agree by construction, but they are different questions in the real feed — the flag is
// DBC-derived and correct on the frame the aura appears, before any duration packet lands
// (`benilla::ui_aura::until_cancelled`).
// The feed takes this from the spell (`ui_aura::until_cancelled`); here, from the expiry.
until_cancelled: expiration_time == 0.0,
channeled: false,
}
}
/// Push the list, then fire the discrete-change event the app's feed fires — the buttons repaint.
///
/// `PLAYER_AURAS_CHANGED`, no args: the reference's own event, which `ui_aura` fires beside the
/// Era-shaped `UNIT_AURA` on the same rebuild. It is what the buttons register for now, and it is
/// what every corpus aura addon registers for.
/// Push the list and fire `PLAYER_AURAS_CHANGED`, as the app's feed does (`BuffFrame.lua:113`).
fn push(s: &mut UiScript, auras: Vec<AuraState>) {
s.set_auras("player", Some(auras));
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
@@ -128,7 +93,7 @@ fn alpha(s: &UiScript, name: &str) -> f64 {
s.eval::<f64>(&format!("return {name}:GetAlpha()")).unwrap()
}
/// The first quad drawn from `Interface\...\<leaf>` — texture regions keyed by their art.
/// The first texture quad whose path ends in `leaf`.
fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQuad> {
quads.iter().find(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => p.ends_with(leaf),
@@ -167,13 +132,11 @@ fn buffs_and_debuffs_fill_their_own_rows_with_counts_and_the_dispel_tint() {
);
let quads = frame(&mut s, 0.1);
// The buff takes the first helpful button, the debuff the first harmful one; the rest hide.
assert!(shown(&s, "BuffButton0"), "first buff -> BuffButton0");
assert!(!shown(&s, "BuffButton1"), "no second buff");
assert!(shown(&s, "BuffButton16"), "first debuff -> BuffButton16");
assert!(!shown(&s, "BuffButton17"), "no second debuff");
// Both icons drew.
assert!(
tex_quad(&quads, "Spell_Nature_Regeneration").is_some(),
"buff icon drew"
@@ -183,7 +146,7 @@ fn buffs_and_debuffs_fill_their_own_rows_with_counts_and_the_dispel_tint() {
"debuff icon drew"
);
// The debuff border wears the Magic tint (DebuffTypeColor["Magic"] = 0.20, 0.60, 1.00).
// `DebuffTypeColor["Magic"]` is 0.20, 0.60, 1.00 (`BuffFrame.lua:11`).
let border = tex_quad(&quads, "UI-Debuff-Overlays").expect("debuff border drew");
match &border.content {
QuadContent::Texture {
@@ -197,7 +160,7 @@ fn buffs_and_debuffs_fill_their_own_rows_with_counts_and_the_dispel_tint() {
&& (c[2] - 1.00).abs() < 1e-3,
"Magic tint, got {c:?}"
);
// The reference's overlay-quadrant crop rode through unchanged.
// The overlay crop of `BuffFrame.xml:72`.
let uv = uv.edges();
assert!(
(uv[0] - 0.296875).abs() < 1e-6 && (uv[3] - 0.515625).abs() < 1e-6,
@@ -207,7 +170,7 @@ fn buffs_and_debuffs_fill_their_own_rows_with_counts_and_the_dispel_tint() {
other => panic!("border is not a tinted, cropped texture: {other:?}"),
}
// Stack count shows only above 1: the 3-stack debuff shows "3", the single buff shows nothing.
// The count shows only above 1 (`BuffFrame.lua:97`).
assert_eq!(text(&s, "BuffButton16Count"), "3");
assert_eq!(text(&s, "BuffButton0Count"), "");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -220,7 +183,6 @@ fn a_timed_aura_counts_down_and_a_permanent_one_shows_no_timer() {
push(
&mut s,
vec![
// Permanent (a stance): no wire duration -> expiration 0 -> no timer.
aura(
2457,
"Battle Stance",
@@ -231,7 +193,7 @@ fn a_timed_aura_counts_down_and_a_permanent_one_shows_no_timer() {
0.0,
false,
),
// Timed: 120s out. After a 0.1s tick GetTime()=0.1, so 119.9s -> SecondsToTimeAbbrev -> "2 m".
// Timed, 120 s out: 119.9 s left after the tick, which rounds up to "2 m".
aura(
1126,
"Mark of the Wild",
@@ -266,7 +228,7 @@ fn the_warning_flash_pulses_a_low_aura_but_leaves_a_long_one_solid() {
push(
&mut s,
vec![
// 5 min out — never inside the 31s warning window, so it stays at full alpha.
// 5 min out: never inside the 31 s `BUFF_WARNING_TIME` window, so full alpha.
aura(
1,
"Long",
@@ -277,7 +239,6 @@ fn the_warning_flash_pulses_a_low_aura_but_leaves_a_long_one_solid() {
300.0,
true,
),
// 20s out — inside the window the whole run, so its button pulses.
aura(
2,
"Short",
@@ -331,8 +292,7 @@ fn right_clicking_a_cancelable_buff_queues_its_spell_cancel() {
s.take_cancel_aura_requests().is_empty(),
"nothing queued yet"
);
// The RightButtonUp handler -> CancelPlayerBuff(this.buffIndex) -> the app drains one
// CMSG_CANCEL_AURA by spell id.
// `CancelPlayerBuff(this.buffIndex)`; the app sends `CMSG_CANCEL_AURA` by spell id.
s.eval::<()>("this = BuffButton0; BuffButton_OnClick()")
.unwrap();
assert_eq!(s.take_cancel_aura_requests(), vec![1126]);
@@ -359,9 +319,7 @@ fn an_emptied_bar_hides_every_button() {
frame(&mut s, 0.1);
assert!(shown(&s, "BuffButton0"), "shown while the aura is up");
// The aura drops: the feed pushes an empty list and re-fires. The button hides, and so does its
// timer — a HIDDEN region since 1139, where it used to be blanked, so the text it last drew is
// stale and beside the point; what the bar promises is that nothing paints.
// An empty list and a re-fire hide the button and its timer (`BuffFrame.lua:64-67`).
push(&mut s, vec![]);
frame(&mut s, 0.1);
assert!(!shown(&s, "BuffButton0"), "hidden once the aura is gone");
@@ -369,18 +327,8 @@ fn an_emptied_bar_hides_every_button() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Decision 0846's second defect, pinned.** The bar's countdown is a per-frame POLL, not a value
/// cached when the aura event fires — exactly as the reference does it (`BuffButton_OnUpdate` →
/// `GetPlayerBuffTimeLeft`, extracted 1.12 `BuffFrame.lua` l.128-130, which caches only the buff
/// *index* on `PLAYER_AURAS_CHANGED`).
///
/// This is what makes a *refresh* work. A recast, a reapplied DoT, cast pushback and the server's
/// replay of every duration at world entry all change an aura's expiry while changing nothing
/// discrete about it — same spell, same stacks, same dispel class — so the feed's change key does
/// not trip and **no aura event fires**. The push below therefore deliberately does NOT fire one.
/// Against the old event-cached bar the button kept counting the stale expiry down, reached zero
/// and sat at `"0 s"` for as long as the buff lived (live-measured: `app left 59.9s` beside
/// `lua left -9.9s`).
/// The countdown polls `GetPlayerBuffTimeLeft` every frame (`BuffFrame.lua:129-130`), so a refresh
/// (recast, pushback, the replay at world entry), which fires no aura event, still reaches the bar.
#[test]
fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -397,13 +345,12 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
true,
)
};
// 20s out. After the tick GetTime()=0.1 -> 19.9s -> "19 s" (SecondsToTimeAbbrev floors via %d).
// 20 s out: 19.9 s after the tick, which `%d` truncates to "19 s".
push(&mut s, vec![mark(20.0)]);
frame(&mut s, 0.1);
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
// The refresh: a new expiry, everything discrete unchanged — so the feed pushes the list and
// fires NOTHING. The button must still pick the new expiry up on its next frame.
// The refresh: a new expiry and no event.
s.set_auras("player", Some(vec![mark(300.0)]));
frame(&mut s, 0.1);
assert_eq!(
@@ -411,23 +358,13 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
"5 m",
"the countdown re-reads the aura every frame; it does not wait for an event"
);
// ...and the flash follows it back out of the warning window (20s pulsed, 300s is solid).
assert!(
(alpha(&s, "BuffButton0") - 1.0).abs() < 1e-6,
"no longer inside the 31s warning window, so full alpha"
);
// The same poll is what shows a timer that arrived AFTER the icon did — the fresh-apply case,
// where the duration packet's stamp lands a frame later than the descriptor delta.
//
// **What the icon-first frame looks like MOVED when the bar came onto the 1.12 verbs.** The
// permanence test used to be `expirationTime > 0`, which cannot tell "no duration, ever" from
// "the duration has not landed yet" and so drew nothing for both. It is now `untilCancelled` —
// GetPlayerBuff's second return, derived from the SPELL (`ui_aura::until_cancelled`) — so a
// timed aura awaiting its stamp is known to be timed, and the reference counts it from `0 s`
// and pulses it for the frame or two until the packet joins. `mark` is a timed spell, so that
// is what it does here; the permanent case (a stance) is
// [`a_timed_aura_counts_down_and_a_permanent_one_shows_no_timer`], and it still shows nothing.
// A fresh apply, stamp a frame behind the icon: `untilCancelled` comes from the spell, so a
// timed aura with no stamp yet shows "0 s" and pulses until the stamp joins.
let mut pending = mark(0.0);
pending.until_cancelled = false; // a timed spell, stamp not yet arrived
s.set_auras("player", Some(vec![pending]));
@@ -447,13 +384,8 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`SHOW_BUFF_DURATIONS`** (decision 1139) — 0255 shipped the durations-shown geometry with no
/// switch because there was no panel to hang one on, and said the other branch was already there
/// waiting. This is it: the global — the reference's "0" since 1804, planted "1" by this file's
/// harness — hides the timer text and closes the 15px gutter each row leaves for it, down to the
/// 5px the columns use. What it does NOT touch is the warning flash — with the numbers gone, the
/// pulse is the only thing left saying an aura is about to drop, and the reference pulses from
/// `BuffButton_OnUpdate` regardless of the setting.
/// `SHOW_BUFF_DURATIONS` "0" hides the timers and closes each row's 15px gutter to 5px
/// (`BuffFrame.lua:152-160`); the warning pulse runs regardless of it (`BuffFrame.lua:131-135`).
#[test]
fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -467,7 +399,7 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
true,
1,
None,
20.0, // inside the 31s warning window, so the flash is live either way
20.0, // inside the 31 s warning window, so the flash runs either way
true,
)],
);
@@ -483,7 +415,6 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
.unwrap();
assert!((shown_gap - 15.0).abs() < 1e-3, "gutter: {shown_gap}");
// The switch, applied the way the options row applies it.
s.run("SHOW_BUFF_DURATIONS = \"0\"; BuffButtons_UpdatePositions()")
.unwrap();
frame(&mut s, 0.1);
@@ -499,7 +430,6 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
"the last-31s pulse is not gated on the timers"
);
// And back: the text is re-written from the poll, not from anything it kept.
s.run("SHOW_BUFF_DURATIONS = \"1\"; BuffButtons_UpdatePositions()")
.unwrap();
frame(&mut s, 0.1);
@@ -508,10 +438,9 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A mixed bar: three buffs and two debuffs in one insertion-ordered cache.
/// Positions 0 to 4 of one cache: buff, debuff (Magic, 3), buff, debuff (Poison, 5), a stance.
fn mixed_bar() -> Vec<AuraState> {
vec![
// pos 0 — buff, cancelable, timed
aura(
1126,
"Mark of the Wild",
@@ -522,7 +451,6 @@ fn mixed_bar() -> Vec<AuraState> {
120.0,
true,
),
// pos 1 — debuff, Magic, 3 stacks
aura(
589,
"Shadow Word: Pain",
@@ -533,7 +461,6 @@ fn mixed_bar() -> Vec<AuraState> {
18.0,
false,
),
// pos 2 — buff, cancelable
aura(
1459,
"Arcane Intellect",
@@ -544,7 +471,6 @@ fn mixed_bar() -> Vec<AuraState> {
600.0,
true,
),
// pos 3 — debuff, Poison
aura(
2818,
"Deadly Poison",
@@ -555,7 +481,6 @@ fn mixed_bar() -> Vec<AuraState> {
12.0,
false,
),
// pos 4 — buff, NOT cancelable (a stance): right-click must be refused
aura(
2457,
"Battle Stance",
@@ -569,15 +494,9 @@ fn mixed_bar() -> Vec<AuraState> {
]
}
/// **The two index spaces, end to end.** The button `id` is a 0-based ordinal within its own
/// filter; `GetPlayerBuff` turns it into a CACHE POSITION that is absolute across filters. The bar
/// is only correct if the two agree: button N of a row must draw the aura the corpus's own walk
/// finds at that ordinal, and the position it cached must be the one an unfiltered walk reports.
///
/// The walk is spelled the way the corpus spells it — `while GetPlayerBuff(i) >= 0 do`
/// (`_LazyPig/LazyPig.lua:1174`, `Zorlen/Zorlen.lua:2797`) — because `-1`, not nil, is the
/// terminator, and because the default filter is `HELPFUL|HARMFUL`: the walk must see the debuffs
/// too or the whole thing silently halves.
/// A button's `id` is an ordinal within its filter and `GetPlayerBuff` maps it to a position
/// shared across filters. The addon walk `while GetPlayerBuff(i) >= 0 do` stops on `-1`, not nil,
/// and under the default `HELPFUL|HARMFUL` filter it sees the debuffs too.
#[test]
fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -585,8 +504,7 @@ fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
push(&mut s, mixed_bar());
frame(&mut s, 0.1);
// The corpus walk: five auras, buffs AND debuffs, positions identical to the ordinals under
// the default filter — and it terminates.
// Unfiltered, the walk visits all five, each position equal to its ordinal.
let visited = s
.eval::<i64>(
r#"local i = 0
@@ -600,8 +518,7 @@ fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
.unwrap();
assert_eq!(visited, 5, "three buffs and two debuffs, one cache");
// Each row filled from its own filter, in cache order — and each button's CACHED position is
// the absolute one, not its ordinal.
// Each button caches the position, not its ordinal.
for (button, position) in [
("BuffButton0", 0), // helpful ordinal 0 -> position 0
("BuffButton1", 2), // helpful ordinal 1 -> position 2 (the debuff at 1 is skipped)
@@ -617,7 +534,6 @@ fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
"{button} caches the CACHE POSITION, not its per-filter ordinal"
);
}
// The rows stop where the filter runs out; nothing bleeds across.
assert!(!shown(&s, "BuffButton3"), "only three buffs");
assert!(!shown(&s, "BuffButton18"), "only two debuffs");
assert_eq!(
@@ -626,8 +542,6 @@ fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
"a miss is -1, the sentinel the corpus terminates on — never nil"
);
// The reads that hang off the position land on the right aura: the 3-stack Magic debuff and
// the 5-stack Poison one are in the right buttons, tinted from their own dispel classes.
assert_eq!(text(&s, "BuffButton16Count"), "3");
assert_eq!(text(&s, "BuffButton17Count"), "5");
assert_eq!(
@@ -646,20 +560,14 @@ fn the_buttons_and_the_corpus_walk_agree_on_the_cache_positions() {
} if p.ends_with("UI-Debuff-Overlays") => Some(*c),
_ => None,
})
// DebuffTypeColor["Poison"] = 0.00, 0.60, 0.00
// `DebuffTypeColor["Poison"]` is 0.00, 0.60, 0.00 (`BuffFrame.lua:14`).
.any(|c| c[0].abs() < 1e-3 && (c[1] - 0.60).abs() < 1e-3 && c[2].abs() < 1e-3);
assert!(poison, "the second debuff button wears the Poison tint");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Right-click cancels by cache position.** `BuffButton_OnClick` is
/// `CancelPlayerBuff(this.buffIndex)` — the reference's own line — so the button that gets
/// cancelled is the one under the cursor even when its ordinal and its position differ, which is
/// the case for every buff after the first debuff.
///
/// The gate is the app's, unchanged: a cancelable buff queues its SPELL id, a stance and a debuff
/// are silent no-ops. That last part is what a per-filter id could not guarantee on its own — here
/// it is structural, because a harmful button's handle simply names a harmful record.
/// `BuffButton_OnClick` is `CancelPlayerBuff(this.buffIndex)` (`BuffFrame.lua:149`): it cancels by
/// position, and a stance or a debuff queues nothing.
#[test]
fn right_clicking_cancels_the_aura_under_the_cursor_by_its_cache_position() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -668,8 +576,7 @@ fn right_clicking_cancels_the_aura_under_the_cursor_by_its_cache_position() {
frame(&mut s, 0.1);
assert!(s.take_cancel_aura_requests().is_empty());
// The SECOND buff button: helpful ordinal 1, cache position 2, spell 1459. A bar that cancelled
// by ordinal would send 589 — the debuff sitting at position 1.
// Ordinal 1 is position 2 (spell 1459); by ordinal it would cancel 589, the debuff at 1.
s.eval::<()>("this = BuffButton1; BuffButton_OnClick()")
.unwrap();
assert_eq!(
@@ -678,7 +585,6 @@ fn right_clicking_cancels_the_aura_under_the_cursor_by_its_cache_position() {
"the aura under the cursor, not the record at the ordinal"
);
// A stance (helpful, not cancelable), both debuff buttons, and an empty slot: all silent.
for button in ["BuffButton2", "BuffButton16", "BuffButton17", "BuffButton7"] {
s.eval::<()>(&format!("this = {button}; BuffButton_OnClick()"))
.unwrap();
@@ -688,47 +594,29 @@ fn right_clicking_cancels_the_aura_under_the_cursor_by_its_cache_position() {
"a stance, a debuff and an empty button are all no-ops"
);
// The first buff still cancels normally.
s.eval::<()>("this = BuffButton0; BuffButton_OnClick()")
.unwrap();
assert_eq!(s.take_cancel_aura_requests(), vec![1126]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The temporary-enchant row** (ref `BuffFrame_Enchant_OnUpdate`, l.162-233). With nothing
/// enchanted it hides both slots and parks the bar on its resting point; with a weapon enchanted it
/// shows the icon, counts the expiry down from MILLISECONDS, and slides the top buff row left to
/// clear it — while rows 2 and 3, which hang off TempEnchant1 and TemporaryEnchantFrame, stay put.
/// The temporary-enchant row (`BuffFrame.lua:162-233`): idle, it hides both slots and parks the
/// bar; enchanted, it counts down from milliseconds and slides the top buff row left.
#[test]
fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// A LOADED bar, deliberately: the enchant slots must be driven by GetWeaponEnchantInfo and by
// nothing else. The reference builds them from `BuffButtonTemplate` and blanks the four aura
// handlers with empty `<OnLoad/>`/`<OnEvent/>`/`<OnClick/>` elements; our loader treats an
// empty handler body as "none given" and so KEEPS the inherited one (`compile_handler` returns
// None and `apply_scripts` never calls SetScript). Under that idiom TempEnchant1 would take
// `BuffButton_OnLoad`, register PLAYER_AURAS_CHANGED, and paint the first buff in the corner —
// and a right-click on it would cancel that aura. Hence the standalone template, and hence a
// populated bar here: an empty aura list cannot tell the two apart.
// Auras up, so one leaking into an enchant slot would show.
push(&mut s, mixed_bar());
frame(&mut s, 0.1);
// Nothing enchanted: both slots hidden, and the bar sits where it has always sat.
assert!(
!shown(&s, "TempEnchant1"),
"the enchant slots are not buff buttons: no aura may reach them"
);
assert!(!shown(&s, "TempEnchant2"));
// The direct probe, and its exact shape is the point. `BuffButtonTempEnchant` inherits
// `BuffButtonTemplate` and blanks its three aura handlers with `<OnLoad>`↵`</OnLoad>` — a
// WHITESPACE body, which 1.12 compiles into a valid empty function rather than storing nil
// (`SetScript 0x7025c0` tests `text[0]`, and nothing trims). So each handler is still a
// FUNCTION here, and it is the
// reference's own no-op rather than `BuffButton_OnLoad`.
//
// Asserting `== nil` instead would be asserting a bug: an addon reading
// `TempEnchant1:GetScript("OnLoad")` gets a function on a real client.
// `BuffFrame.xml:101-109` blanks three inherited handlers with whitespace bodies, which 1.12
// compiles to empty functions, not nil: `0x7025c0` clears only a NULL or empty body.
for handler in ["OnLoad", "OnEvent", "OnClick"] {
assert!(
s.eval::<bool>(&format!(
@@ -738,9 +626,7 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
"an enchant slot's {handler} is the reference's compiled no-op, not nil"
);
}
// …and it is NOT the buff-button body: an aura event reaching it would paint the first buff
// into the corner and arm a right-click cancel, which is what a naive "empty means absent"
// loader produces.
// Nor the inherited body, which would paint the first buff into the slot and arm its cancel.
assert!(
s.eval::<bool>(
"return TempEnchant1:GetScript(\"OnLoad\") ~= BuffButton0:GetScript(\"OnLoad\")"
@@ -756,7 +642,6 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
"UIParent TOPRIGHT -175: {resting}"
);
// A main-hand enchant, 8 minutes out. The app feeds it the way `ui_char` does.
s.set_weapon_enchants(
Some(benilla_ui::script::WeaponEnchant {
remaining_ms: Some(480_000),
@@ -781,15 +666,8 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
"480000 ms / 1000 -> 480 s -> SecondsToTimeAbbrev"
);
// The top buff row slid left by one icon plus the 5px gutter; the debuff row did not move.
//
// That is true only because the harness has APPLIED the durations setting, and the
// qualification is worth keeping: `BuffButton16`'s XML anchor is `TOPRIGHT` to `BuffButton8`'s
// `BOTTOM` — inside BuffFrame, so it would travel with it — and the only thing that re-anchors
// it to `TemporaryEnchantFrame` is `BuffButtons_UpdatePositions`, which `BuffFrame_OnLoad`
// does NOT call. A bar that nobody has applied the setting to therefore moves BOTH rows. The
// window's own file cannot reach that state in production (our Interface page applies it), and
// finding it was what showed the harness had to apply it too.
// The debuff row stays only because `BuffButtons_UpdatePositions` re-anchored it to
// `TemporaryEnchantFrame`; its XML anchor is inside `BuffFrame`, which moves.
s.resolve();
let shifted = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
assert!(
@@ -802,7 +680,6 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
s.eval::<f64>("return BuffButton16:GetRight()").unwrap()
);
// And back: the enchant drops, the row hides and the bar returns to its resting point.
s.set_weapon_enchants(None, None);
frame(&mut s, 0.1);
assert!(!shown(&s, "TempEnchant1"));
@@ -811,35 +688,19 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The idle enchant row rewrites the bar every frame, because the reference's does** — and this
/// pins that rather than the write-gating it replaced.
///
/// Our own `BuffFrame.xml` latched the no-enchant branch on a `benillaCleared` flag after its first
/// pass; decision 1396 measured that branch at ~14 µs/frame and took the gate as its post-fix lead.
/// 1751's eighteenth window made the bar `Interface\FrameXML\BuffFrame.xml`, whose
/// `BuffFrame_Enchant_OnUpdate` opens with an unconditional `TempEnchant1:Hide()` … `BuffFrame:
/// SetPoint(…)` and returns. So the writes are back, they are the reference's, and 1751 §2 takes
/// them: an optimisation is not a divergence we get to keep silently once the file is theirs.
///
/// **1396's finding is not withdrawn** — the cost is real and the same measurement would find it
/// again. If it is ever worth paying down, the fix is an adapter over this one handler with a
/// record beside it, not a re-transcription of the window.
///
/// The sentinels below are inverted from what they were: the branch overwrites both, every frame.
/// The rest of the test is unchanged and is the part that always mattered — the row DRIVEN with
/// both hands keeps the reference's off-hand-first packing order, and the drop back to empty
/// re-parks the bar.
/// The idle enchant branch hides both slots and re-parks the bar every frame, unconditionally
/// (`BuffFrame.lua:166-173`), so the sentinels below are overwritten.
#[test]
fn an_idle_enchant_row_rewrites_the_bar_as_the_reference_does() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
push(&mut s, mixed_bar());
frame(&mut s, 0.1); // first pass: the clear + park write once and latch
frame(&mut s, 0.1);
frame(&mut s, 0.1);
s.resolve();
let resting = s.eval::<f64>("return BuffFrame:GetRight()").unwrap();
// Sentinels the no-enchant branch can never leave standing if it still writes.
// Sentinels the idle branch must overwrite.
s.run(
r#"BuffFrame:SetPoint("TOPRIGHT", "TemporaryEnchantFrame", "TOPRIGHT", -41, 0)
TempEnchant1:Show()"#,
@@ -859,8 +720,7 @@ fn an_idle_enchant_row_rewrites_the_bar_as_the_reference_does() {
"…and re-parks the bar with it: got {displaced}, resting {resting}"
);
// The control: both hands enchant. Off hand packs slot 1 (the reference's order — main hand
// sits to its LEFT in slot 2), the row is 64 wide, the bar starts one 5px gutter left of it.
// Both hands: off hand in slot 1, main hand in slot 2 to its left (`BuffFrame.lua:179-223`).
s.set_weapon_enchants(
Some(benilla_ui::script::WeaponEnchant {
remaining_ms: Some(480_000),
@@ -894,7 +754,6 @@ fn an_idle_enchant_row_rewrites_the_bar_as_the_reference_does() {
"the bar clears both columns + the gutter: {resting} -> {both}"
);
// And back to empty: the transition still writes (the latch re-arms, not re-fires).
s.set_weapon_enchants(None, None);
frame(&mut s, 0.1);
assert!(!shown(&s, "TempEnchant1") && !shown(&s, "TempEnchant2"));
@@ -907,20 +766,8 @@ fn an_idle_enchant_row_rewrites_the_bar_as_the_reference_does() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A settled buff button rewrites its alpha and its duration every frame, because the
/// reference's does** — the sibling of the enchant-row test above, and the same trade.
///
/// Our copy gated both writes on what was last written (the 1396 class, one row down); the
/// reference's `BuffButton_OnUpdate` polls `GetPlayerBuffTimeLeft` and then writes
/// `SetAlpha` and `BuffFrame_UpdateDuration` unconditionally, every tick. So the sentinels below —
/// alpha 0.42 and the text "X", neither of which the handler can produce — are overwritten within
/// one frame, and that is what is pinned.
///
/// **The poll itself was never the divergence** (`GetPlayerBuffTimeLeft` every frame is
/// load-bearing, decision 0846) and the controls below are unchanged, because they are the part
/// that describes the WINDOW rather than our implementation of it: the minute rollover drops "5 m"
/// to "4 m", the warning band turns the number white, and inside the last 31s the pulse ramps the
/// alpha. Those three are the reference's behaviour and they still hold.
/// `BuffButton_OnUpdate` writes the alpha and the duration every tick, unconditionally
/// (`BuffFrame.lua:130-138`), so the sentinels (alpha 0.42, text "X") are overwritten.
#[test]
fn a_settled_buff_button_rewrites_alpha_and_duration_as_the_reference_does() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -938,7 +785,7 @@ fn a_settled_buff_button_rewrites_alpha_and_duration_as_the_reference_does() {
true,
)],
);
frame(&mut s, 0.1); // the event repaint + the poll's first write ("5 m", alpha 1.0)
frame(&mut s, 0.1); // the event repaint and the poll's first write ("5 m", alpha 1.0)
frame(&mut s, 0.1);
assert_eq!(text(&s, "BuffButton0Duration"), "5 m");
@@ -959,7 +806,6 @@ fn a_settled_buff_button_rewrites_alpha_and_duration_as_the_reference_does() {
"…and re-writes the duration with it, unchanged or not"
);
// Control 1: a REAL text change flows — the abbreviation drops to "4 m" past the boundary.
s.tick(60.0);
assert_eq!(
text(&s, "BuffButton0Duration"),
@@ -967,9 +813,9 @@ fn a_settled_buff_button_rewrites_alpha_and_duration_as_the_reference_does() {
"the minute rollover overwrites the sentinel: the gate passes real changes"
);
// Control 2: inside the last 31s the pulse writes a fresh ramp value every tick, and the
// number wears the sub-minute HIGHLIGHT white (the band is part of the write tuple).
s.tick(210.0); // ~29.5s left
// In the last 31 s the pulse ramps each tick; under a minute the number is
// `HIGHLIGHT_FONT_COLOR` white (`BuffFrame.lua:251-252`).
s.tick(210.0); // ~29.5 s left
s.resolve();
let a1 = alpha(&s, "BuffButton0");
s.tick(0.2);
@@ -1006,14 +852,8 @@ fn a_settled_buff_button_rewrites_alpha_and_duration_as_the_reference_does() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The duration line against the **real shipped strings**, end to end — the leg the engine's own
/// synthetic-template tests cannot reach.
///
/// The engine formats through `tooltip::duration_text` (`0x52fa50`'s ladder) over whatever
/// `GlobalStrings.lua` the player's install carries; this runs
/// that file into a real VM exactly as the boot's `load_global_strings` does, then asserts the
/// wording that comes back. Every expectation below is a reading the PREVIOUS two-arm formatter got
/// wrong, so this is the whole delta in one place. Skips without client data.
/// The aura tooltip's duration line over the install's own `GlobalStrings.lua`, formatted by
/// `tooltip::duration_text` on the reference's ladder (`0x52fa50`).
#[test]
fn the_duration_line_reads_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
@@ -1067,7 +907,6 @@ fn the_duration_line_reads_the_real_global_strings() {
s.eval::<String>("return BENILLA_LAST").unwrap()
};
// The headline divergence: anything past an hour was reading in MINUTES.
assert_eq!(line(7_200.0), "2 hours remaining", "was '120 minutes'");
assert_eq!(line(3_600.0), "1 hour remaining", "the hour edge, singular");
assert_eq!(
@@ -1075,7 +914,6 @@ fn the_duration_line_reads_the_real_global_strings() {
"60 minutes remaining",
"one ms under the hour stays in minutes — no '1 hour' until it is whole"
);
// The singular half: the shipped pair really is "%d minute" / "%d minutes".
assert_eq!(line(60.0), "1 minute remaining", "was '1 minutes'");
assert_eq!(line(61.0), "2 minutes remaining", "the minute arm ceils");
// The seconds arm truncates where every arm above it ceils.
@@ -1086,7 +924,6 @@ fn the_duration_line_reads_the_real_global_strings() {
"0 seconds remaining",
"the lapsing read, plural at zero"
);
// And the top of the ladder, which had no arm at all.
assert_eq!(line(129_600.0), "2 days remaining", "was '2160 minutes'");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
+42 -116
View File
@@ -1,12 +1,6 @@
//! The cast bar's lifecycle against the transcribed reference behavior (decision 0137 phase 1):
//! the extracted 1.12 `CastingBarFrame.lua` is the spec — orange fill that tracks the clock, green
//! completion flash + fade, red Failed with the 1 s hold, and the channel bar counting *down*.
//!
//! Two halves, and the second one is load-bearing. The state tests read the Lua back
//! (`GetValue`/`GetStatusBarColor`/`GetAlpha`), which is blind to what actually *paints* — the
//! original transcription dropped the reference's two `CastingBarFlash:Hide()` calls and every state
//! test still passed while a full-brightness additive bloom sat over the bar for the whole cast. The
//! [`draw list`](UiScript::extract) tests below are the ones that see it.
//! The stock cast bar (`CastingBarFrame.lua`): the fill tracking the clock, the green completion
//! flash and fade, red Failed with its 1 s hold, the channel bar draining. The state tests read the
//! Lua back, which cannot see what paints; the draw-list tests below see the flash.
use benilla_ui::script::{ExtractedQuad, QuadContent, ScriptValue, UiScript};
@@ -16,9 +10,7 @@ fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
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".
// The stock file sets `CastingBarText` to the `FAILED` global (`CastingBarFrame.lua:61`).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\CastingBarFrame.xml");
s
@@ -33,14 +25,14 @@ fn bar_color(s: &UiScript) -> (f64, f64, f64) {
.unwrap()
}
/// One tick of the app's real order (`tick_script` then `paint_script`): OnUpdate, resolve, then the draw list.
/// One frame in the app's order (`tick_script`, then `paint_script`): OnUpdate, resolve, draw list.
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
s.tick(dt);
s.resolve();
s.extract()
}
/// The quad drawn from `Interface\...\<leaf>`, if any — texture regions keyed by their art.
/// The texture quad whose art path ends in `leaf`, if any.
fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQuad> {
quads.iter().find(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => p.ends_with(leaf),
@@ -73,7 +65,6 @@ fn cast_fills_then_completes_green_and_fades() {
"casting is orange (got {r} {g} {b})"
);
// The fill tracks GetTime: 1.5 s in (15 ticks) the value sits mid-window.
let start = bar_value(&s);
for _ in 0..15 {
s.tick(0.1);
@@ -85,7 +76,6 @@ fn cast_fills_then_completes_green_and_fades() {
mid - start
);
// Completion: green, full, then the fade takes it below full alpha and eventually hides.
s.fire_event("SPELLCAST_STOP", vec![]);
assert_eq!(bar_color(&s), (0.0, 1.0, 0.0), "completed is green");
for _ in 0..30 {
@@ -101,15 +91,13 @@ fn cast_fills_then_completes_green_and_fades() {
#[test]
fn a_hit_pushes_the_bar_back_it_does_not_cancel() {
benilla_formats::wow_data_or_skip!();
// Pushback (`SMSG_SPELL_DELAYED` → `SPELLCAST_DELAYED`): a hit while casting must slide the
// bar's window out (the spark jumps back and it keeps running), never hide or fail it —
// decision 0256's open item, the "disappears on a hit" report.
// Pushback (`SMSG_SPELL_DELAYED` fires `SPELLCAST_DELAYED`) slides the window out and never
// hides or fails the bar (`CastingBarFrame.lua:69-74`).
let mut s = harness();
s.fire_event(
"SPELLCAST_START",
vec![ScriptValue::Str("Fireball".into()), ScriptValue::Int(3000)],
);
// 1.5 s in — half-way through a 3 s cast.
for _ in 0..15 {
s.tick(0.1);
}
@@ -121,7 +109,6 @@ fn a_hit_pushes_the_bar_back_it_does_not_cancel() {
"half-way: ~1.5 s left (got {remaining_before})"
);
// The hit: a 0.5 s pushback.
s.fire_event("SPELLCAST_DELAYED", vec![ScriptValue::Int(500)]);
assert!(
s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
@@ -160,7 +147,6 @@ fn failed_cast_turns_red_holds_then_fades() {
"Failed"
);
// The 1 s hold: still fully opaque half a second in…
for _ in 0..5 {
s.tick(0.1);
}
@@ -169,7 +155,6 @@ fn failed_cast_turns_red_holds_then_fades() {
1.0,
"holds at full alpha inside CASTING_BAR_HOLD_TIME"
);
// …then fades out and hides.
for _ in 0..30 {
s.tick(0.1);
}
@@ -184,9 +169,8 @@ fn failed_cast_turns_red_holds_then_fades() {
fn channel_counts_down_not_up() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
// SPELLCAST_CHANNEL_START(ms, name) — args reversed vs START, per the reference contract.
// The name is whatever `ui_cast::channel_start_args` composed; for all but nine of the 323
// channeled rows that is the literal word, which is what the feed hands Starshards too.
// SPELLCAST_CHANNEL_START is (ms, name), reversed from START. The name is the word
// "Channeling" for all but 9 of the 323 channeled spells (`ui_cast::channel_start_args`).
s.fire_event(
"SPELLCAST_CHANNEL_START",
vec![
@@ -200,13 +184,8 @@ fn channel_counts_down_not_up() {
"Channeling"
);
// **In 1.12 the channel bar is ORANGE** — the same `SetStatusBarColor(1.0, 0.7, 0.0)` the cast
// bar takes (stock `CastingBarFrame.lua` l.76 vs l.21, byte-identical to the copy in the
// player's `patch.MPQ`); green is the COMPLETION flash and nothing else. A channel is told
// apart by draining instead of filling, and by its label. **Classic Era's channel bar IS
// green** (`CastingBarType.Channel`'s `classicFillColor = CASTBAR_CLASSIC_GREEN`) — a real
// behaviour of a different client, which is why this is a gate and not a comment: if we ever
// take Era's colour it is a deliberate deviation that has to come here first (decision 2284).
// A channel opens orange like a cast (`CastingBarFrame.lua:76`, `:21`); green is only the
// completion flash. The Era client's green channel bar is not 1.12's.
let (r, g, b) = bar_color(&s);
assert!(
(r - 1.0).abs() < 1e-6 && (g - 0.7).abs() < 1e-6 && b.abs() < 1e-6,
@@ -224,7 +203,7 @@ fn channel_counts_down_not_up() {
full - after_1s
);
// The server's mid-channel correction re-anchors the window (pushback shortens it).
// The server's mid-channel update re-anchors the window to the time it has left.
s.fire_event("SPELLCAST_CHANNEL_UPDATE", vec![ScriptValue::Int(2000)]);
s.tick(0.1);
let corrected = bar_value(&s);
@@ -245,24 +224,10 @@ fn channel_counts_down_not_up() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A completed channel closes on the BAR's own clock, because the server's stop runs a second
/// late** — and that is the path every naturally-finished channel in the game takes.
///
/// vmangos does not send `MSG_CHANNEL_UPDATE(0)` when a channel simply runs out.
/// `Spell::SendChannelUpdate(0, interrupted=false)` schedules a `ChannelResetEvent` **+1000 ms**
/// ("Else, we have some visual bugs (arcane projectile, last tick)"), and only that event's
/// `CancelSpellChannelingAnimationInstantly()` finally emits the packet. The *interrupt* path calls
/// the same function inline, which is why a broken channel stops at once and a finished one does
/// not.
///
/// So for a whole second after the fill empties there is no stop edge at all, and the stock
/// `OnUpdate` is what ends it: once `GetTime()` reaches `endTime` it clamps, `time == this.endTime`
/// trips, and the frame hands itself to `fadeOut` with **no green flash** — a finished channel
/// fades, where a cast completes green. The late packet then lands on a hidden frame and both of
/// `CastingBarFrame_OnEvent`'s stop-arm guards reject it.
///
/// Worth a test of its own because nothing else exercises it: the sibling test above fires
/// `SPELLCAST_CHANNEL_STOP` by hand, which is the *interrupt* timing, not this one.
/// A channel that runs out closes on the bar's own clock: vmangos defers the stop by 1000 ms on a
/// natural end and sends it at once only on an interrupt (`Spell.cpp:4894-4904`). The stock
/// `OnUpdate` clamps at `endTime` and fades with no green flash, and the late stop lands on a
/// hidden frame, which both stop-arm guards ignore.
#[test]
fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
benilla_formats::wow_data_or_skip!();
@@ -276,7 +241,6 @@ fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
);
assert!(s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap());
// The whole second the channel runs: still up, still orange, no stop packet has been sent.
for _ in 0..9 {
s.tick(0.1);
}
@@ -290,7 +254,6 @@ fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
"never flashes green — a finished channel fades, it does not complete (got {r} {g} {b})"
);
// Past endTime with nothing from the server: the frame ends itself.
for _ in 0..40 {
s.tick(0.1);
}
@@ -299,7 +262,7 @@ fn a_finished_channel_fades_on_its_own_clock_and_the_late_stop_is_inert() {
"the bar closed on its own clock, a full second before the server says so"
);
// ~1 s after the fill emptied, the deferred ChannelResetEvent finally fires.
// The deferred stop, about 1 s after the fill emptied.
s.fire_event("SPELLCAST_CHANNEL_STOP", vec![]);
assert!(
!s.eval::<bool>("return CastingBarFrame:IsShown()").unwrap(),
@@ -315,10 +278,8 @@ const SPARK: &str = "UI-CastingBar-Spark";
const BORDER: &str = "UI-CastingBar-Border";
const FILL: &str = "UI-StatusBar";
/// The completion bloom is an `alphaMode="ADD"` texture the size of the whole frame. The reference
/// keeps it hidden for the entire cast (`CastingBarFlash:Hide()` on every OnUpdate) and only shows
/// it, from alpha 0, once the cast lands. Dropping those two calls painted a full-brightness white
/// smear over the bar from the first frame — invisible to every state assertion above.
/// The completion flash, an additive texture over the whole frame, stays hidden through the cast:
/// every casting `OnUpdate` hides it (`CastingBarFrame.lua:109`).
#[test]
fn the_flash_stays_hidden_for_the_whole_cast() {
benilla_formats::wow_data_or_skip!();
@@ -341,16 +302,13 @@ fn the_flash_stays_hidden_for_the_whole_cast() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// On completion the flash appears at alpha 0 and ramps by CASTING_BAR_FLASH_STEP (0.2) per
/// REFERENCE tick — the ref steps per rendered frame, and decision 0454 normalizes our steps to
/// its 30 Hz tick (`arg1 × CASTING_BAR_REF_TICK`), so a 1/30 s update advances exactly one
/// reference step and the tail is wall-clock stable at any render rate. The spark goes away.
/// The frame is still fully opaque through the ramp — the fade only starts once the flash has
/// finished.
/// On completion the flash shows at alpha 0 and each `OnUpdate` adds `CASTING_BAR_FLASH_STEP`
/// (0.2), once per frame whatever its length (`CastingBarFrame.lua:132-139`). The spark hides, and
/// the frame stays opaque until the ramp ends.
#[test]
fn the_flash_blooms_from_zero_only_on_completion() {
benilla_formats::wow_data_or_skip!();
const REF_TICK: f32 = 1.0 / 30.0; // one reference tick of wall clock
const REF_TICK: f32 = 1.0 / 30.0; // any frame time: the ramp steps once per OnUpdate
let mut s = harness();
s.fire_event(
"SPELLCAST_START",
@@ -359,8 +317,7 @@ fn the_flash_blooms_from_zero_only_on_completion() {
frame(&mut s, 0.1);
s.fire_event("SPELLCAST_STOP", vec![]);
// The event alone shows it at 0; each update adds a step. (The frame is opaque until the ramp
// completes, so the quad's alpha IS the flash's own.)
// The frame is opaque through the ramp, so the quad's alpha is the flash's own.
for (i, expected) in [0.2f32, 0.4, 0.6, 0.8, 1.0].into_iter().enumerate() {
let quads = frame(&mut s, REF_TICK);
let flash = tex_quad(&quads, FLASH).expect("the flash draws after completion");
@@ -377,7 +334,7 @@ fn the_flash_blooms_from_zero_only_on_completion() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A failed cast never flashes — the reference only arms the ramp on STOP/CHANNEL_STOP.
/// Only `SPELLCAST_STOP` and `SPELLCAST_CHANNEL_STOP` arm the ramp (`CastingBarFrame.lua:35-54`).
#[test]
fn a_failed_cast_never_flashes() {
benilla_formats::wow_data_or_skip!();
@@ -398,9 +355,8 @@ fn a_failed_cast_never_flashes() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `drawLayer="BORDER"` on the `<StatusBar>`: the orange fill draws *under* the frame art, so the
/// border's bevel is never painted over. (Omitting the attribute defaults the fill to ARTWORK, the
/// border art's own layer, where declaration order put the fill on top.)
/// `drawLayer="BORDER"` on the `<StatusBar>` (`CastingBarFrame.xml:4`) draws the fill under the
/// ARTWORK border art.
#[test]
fn the_fill_draws_beneath_the_border_art() {
benilla_formats::wow_data_or_skip!();
@@ -420,9 +376,8 @@ fn the_fill_draws_beneath_the_border_art() {
);
}
/// A StatusBar fill CROPs its texture — it never squeezes it (`0x770410`). At a
/// fraction f the quad is f·width wide AND samples u ∈ [0, f], so `UI-StatusBar`'s left-to-right
/// ramp keeps its true gradient at every fill level.
/// A StatusBar fill crops its texture rather than squeezing it (`0x770410`): at fraction f the
/// quad is f of the width and samples u in [0, f].
#[test]
fn the_fill_crops_its_texture_rather_than_stretching_it() {
benilla_formats::wow_data_or_skip!();
@@ -431,7 +386,6 @@ fn the_fill_crops_its_texture_rather_than_stretching_it() {
"SPELLCAST_START",
vec![ScriptValue::Str("Frostbolt".into()), ScriptValue::Int(4000)],
);
// 1.0 s into a 4 s cast: a quarter filled.
for _ in 0..10 {
frame(&mut s, 0.1);
}
@@ -462,12 +416,9 @@ fn bottom(s: &UiScript, name: &str) -> f64 {
.unwrap()
}
/// The managed bottom-stack positions (decision 0272): the ref's UIParent.lua re-anchors the
/// cast bar and the chat window over whatever bottom bars are showing — the XML anchors
/// (55 / 85) are only pre-manage defaults. The bar visibilities are the mechanism's only
/// inputs, so plain Lua stubs exercising IsShown() stand in for the real always-on multibars
/// (0270) and the stance bar; the arithmetic asserted is the ref table's own
/// (base + bottomEither/bottomLeft + pet, and chat's bottomLeft-and-pet +23 extra).
/// The stock manage pass re-seats the cast bar and `ChatFrame1` over the bottom bars shown; the
/// XML anchors (55, 85) are pre-pass defaults. Each sum is the stock table's base plus its bar
/// and `pet` terms, with chat's extra 23 when both apply (`UIParent.lua:1657-1659`).
#[test]
fn managed_positions_track_the_bottom_bar_stack() {
benilla_formats::wow_data_or_skip!();
@@ -482,7 +433,7 @@ fn managed_positions_track_the_bottom_bar_stack() {
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\\GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR for the dropdowns
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
@@ -490,7 +441,7 @@ fn managed_positions_track_the_bottom_bar_stack() {
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.
// The load-time pass with no bars shown: the bare bases.
s.run("UIParent_ManageFramePositions()").unwrap();
s.resolve();
assert_eq!(
@@ -500,25 +451,16 @@ fn managed_positions_track_the_bottom_bar_stack() {
);
assert_eq!(bottom(&s, "ChatFrame1"), 85.0, "chat baseY");
// The always-on bottom multibars appear (0270): bottomEither for the bar, bottomLeft for chat.
// 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.
// **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).
// Both bottom multibars on: `bottomEither` for the cast bar, `bottomLeft` for chat. The flags
// come from `SHOW_MULTI_ACTIONBAR_1`/`_2`, not the frames (`UIParent.lua:1599-1607`).
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.
// 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.
// The stance bar shows: the pet term, plus chat's both-flags extra. The pass's stance-bar arm
// (`UIParent.lua:1705-1732`) shows and hides the bar's three shelf textures by name, unguarded.
s.run(
"local t = { Show = function() end, Hide = function() end } \
ShapeshiftBarLeft, ShapeshiftBarMiddle, ShapeshiftBarRight = t, t, t",
@@ -537,7 +479,6 @@ fn managed_positions_track_the_bottom_bar_stack() {
"85 + 17 + pet 17 + both-flags 23"
);
// It hides again (a druid leaving forms is the live case): everything settles back.
s.run("ShapeshiftBarFrame:Hide() UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
@@ -545,23 +486,9 @@ fn managed_positions_track_the_bottom_bar_stack() {
assert_eq!(bottom(&s, "ChatFrame1"), 102.0);
}
/// **`CastingBarFrameStatusBar` is published, and it IS `CastingBarFrame`.**
///
/// `CastingBarFrame.lua:16` ends `CastingBarFrame_OnLoad` with `CastingBarFrameStatusBar =
/// CastingBarFrame` — a plain alias, because in 1.12 the casting bar is itself the StatusBar and
/// there is no separate child. Our transcription copied the eight `RegisterEvent`s and both field
/// inits and dropped that one line.
///
/// It cost 11 corpus addons, the largest single name in the session-start `attempt to index global`
/// row, and implementing it moved survivors 74 -> 85.
///
/// **This is the second time this file has been caught dropping lines from the same function** (see
/// the module doc: the original transcription also lost two `CastingBarFlash:Hide()` calls). A line
/// missing from INSIDE an otherwise-verbatim function is invisible to every instrument here —
/// `framexml-transcription-diff.py` compares elements and handlers, not bodies.
///
/// Asserted as identity, not just non-nil: an alias that pointed at some other frame would satisfy
/// "exists" and still break every addon that drives the bar through it.
/// `CastingBarFrame_OnLoad` publishes `CastingBarFrameStatusBar` as an alias of the bar itself
/// (`CastingBarFrame.lua:16`): in 1.12 the casting bar is the StatusBar, with no child. Addons
/// drive the bar through it, so this asserts identity, not just existence.
#[test]
fn the_casting_bar_publishes_its_status_bar_alias() {
benilla_formats::wow_data_or_skip!();
@@ -576,7 +503,6 @@ fn the_casting_bar_publishes_its_status_bar_alias() {
.unwrap(),
"the alias IS the bar — 1.12 has no separate child StatusBar"
);
// And it drives: the addons that index it call StatusBar methods straight through.
s.run("CastingBarFrameStatusBar:SetMinMaxValues(0, 10) CastingBarFrameStatusBar:SetValue(4)")
.unwrap();
assert!((bar_value(&s) - 4.0).abs() < 1e-9);
@@ -1,18 +1,7 @@
//! The shipped **character window** driven end-to-end, engine-only (no Bevy) — and since decision
//! 1751's character swap, "shipped" means the **reference's own**
//! `Interface\FrameXML\CharacterFrame.xml`, `PaperDollFrame.xml` and `PetPaperDollFrame.xml`, read
//! off the player's patch chain. Our `assets/ui/CharacterFrame.xml` and `PetPaperDollFrame.xml` are
//! deleted; [`super::test_ui::CHARACTER_UI`] is the load list, in `benilla.toc`'s own order, and
//! every test here opens with `wow_data_or_skip!()` because a chain entry needs the install.
//!
//! The fixtures are unchanged (a synthetic player snapshot + combat stats + one equipped item), but
//! the behaviour under them is the stock file's, not ours. Where the two differed the divergence is
//! named at the test that used to pin it, with the stock file and line that replaces it — the
//! per-window re-examination 1751 asks for, done in the open rather than silently.
//!
//! **Handlers are driven through the mouse, never called by name.** The reference's slot handlers
//! read `this` (`PaperDollItemSlotButton_OnClick(button, ignoreModifiers)` takes the MOUSE button
//! and gets the frame from `this`, `PaperDollFrame.lua:647`), and only the engine sets `this`.
//! The character window, engine-only: the reference's own `CharacterFrame.xml`,
//! `PaperDollFrame.xml` and `PetPaperDollFrame.xml` off the player's patch chain, loaded through
//! [`super::test_ui::CHARACTER_UI`]. Slot handlers are driven through the mouse: they read the
//! frame from `this` (`PaperDollFrame.lua:647`), and only the engine sets `this`.
use benilla_ui::script::{
ExtractedQuad, InvSlotView, InventorySlots, QuadContent, ScriptValue, SoundRequest, UiScript,
@@ -21,7 +10,6 @@ use benilla_ui::script::{
use super::test_ui::load_ui as load_xml;
/// A Night Elf Warrior, level 12 — the fixture every test below shares for the level/name line.
fn player_unit() -> UnitState {
UnitState {
exists: true,
@@ -45,9 +33,7 @@ fn player_unit() -> UnitState {
}
}
/// A minimal combat-stats snapshot: STR effective 15 (a +2 pos buff, so the stat line shows green),
/// 120 armor (school 0), 3 arcane resistance (school 6), a mainhand-only melee weapon (2.6s speed,
/// 10-15 damage, 80 AP) and NO ranged weapon equipped (exercises the "N/A" ranged fallback).
/// Strength 15 with a +2 buff (a green line), 120 armor, 3 arcane (school 6), no ranged weapon.
fn combat_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [15, 12, 20, 8, 9],
@@ -62,9 +48,7 @@ fn combat_stats() -> UnitCombatStats {
}
}
/// One item in the head slot (inventory slot id 1, per `GetInventorySlotInfo("HeadSlot")`) —
/// everything else stays empty (exercises the empty-slot art + slot-name tooltip fallback).
/// `equip_slots: vec![1]` — fits ONLY the head slot, decision 0208 phase 1b's fit rule.
/// A helm in the head slot (inventory slot 1, `GetInventorySlotInfo("HeadSlot")`), the rest empty.
fn inventory_with_head_item() -> InventorySlots {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
@@ -88,8 +72,7 @@ fn inventory_with_head_item() -> InventorySlots {
slots
}
/// A one-item backpack whose slot 1 item fits ONLY the head slot (`equip_slots: vec![1]`) — the
/// bag-side half of the doll-interaction tests below.
/// A backpack whose slot 1 holds a helm that fits only the head slot.
fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
@@ -121,13 +104,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
}
}
/// The loader itself: every file the window depends on parses and materializes with no errors, and
/// the two chain files this module owns each land their own frame count.
///
/// **A count is a fingerprint of a file, not a target** (decision 1800's closing note). These two
/// are the reference's — `CharacterFrame.xml`'s container + close button + name frame + five tabs,
/// and `PaperDollFrame.xml`'s 19 slots + ammo + their cooldown children + the stat/resistance rows
/// + the model pane and its two rotate buttons. They change only when the player's own file does.
/// A frame count fingerprints the player's own file, not a target: it moves only when that does.
#[test]
fn shipped_character_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -149,20 +126,14 @@ fn shipped_character_frame_loads_clean() {
);
}
/// The whole contract in one end-to-end drive: `ToggleCharacter` (the 'C' binding's entry point)
/// opens through `ShowUIPanel`, the level/name lines read the player snapshot, the stat/armor/
/// resistance lines read the combat-stats snapshot (with the ref's own buff-coloring), the ranged
/// block falls back to N/A with no ranged weapon, an equipped item's icon renders once
/// `UNIT_INVENTORY_CHANGED` fires, rotating the model pane advances the booth yaw + plays the kit,
/// and a second toggle closes it again — each transition its own sound.
/// Opens with `ToggleCharacter`, the `C` binding's entry point, reads the snapshot lines, hovers,
/// rotates the model and closes again.
#[test]
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.
// The stock tooltip sizes from its measured lines; a fixed-width font stands in for the atlas.
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);
@@ -172,7 +143,6 @@ fn shipped_character_frame_drives_end_to_end() {
s.set_player_combat_stats(Some(combat_stats()));
s.set_inventory_slots(inventory_with_head_item());
// Hidden at load: no sound queued (never transitions on startup).
assert!(
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
@@ -198,16 +168,13 @@ fn shipped_character_frame_drives_end_to_end() {
.unwrap(),
"Level 12 Night Elf Warrior"
);
// STR effective 15, posBuff 2 > 0 → green (ref PaperDollFrame_SetStats).
// `PaperDollFrame_SetStats` colours a buffed stat green.
assert_eq!(
s.eval::<String>("return CharacterStatFrame1StatText:GetText()")
.unwrap(),
"|cff20ff2015|r"
);
// The labels are the TEMPLATE's own $parentLabel, filled from Lua exactly like the ref
// (PaperDollFrame_SetStats l.140 for the attributes, _OnLoad l.7-13 for the fixed rows) —
// never a re-declared region in an instance, which this engine renders as a SECOND
// anchorless default-font FontString (the white/overlapping-label regression).
// Labels are the template's `$parentLabel`, set in Lua (`PaperDollFrame.lua:7-13`, `:140`).
assert_eq!(
s.eval::<String>("return CharacterStatFrame1Label:GetText()")
.unwrap(),
@@ -218,17 +185,15 @@ fn shipped_character_frame_drives_end_to_end() {
.unwrap(),
"Melee Attack"
);
// Geometry resolves below (GetLeft is nil until the first resolve).
// GetLeft is nil until the first resolve.
s.resolve();
// The template's label sits LEFT-anchored at its row's left edge.
assert_eq!(
s.eval::<f32>("return CharacterStatFrame1Label:GetLeft()")
.unwrap(),
s.eval::<f32>("return CharacterStatFrame1:GetLeft()")
.unwrap(),
);
// Exactly ONE region carries the label text, in the small gold font (GameFontNormalSmall:
// 10px, 1.0/0.82/0) — a duplicate or a default-font fallback both fail here.
// One region, in GameFontNormalSmall: 10 px, 1.0/0.82/0 (`Fonts.xml:97-102`).
let quads = s.extract();
let strength_labels: Vec<_> = quads
.iter()
@@ -255,26 +220,24 @@ fn shipped_character_frame_drives_end_to_end() {
(c[0] - 1.0).abs() < 1e-3 && (c[1] - 0.82).abs() < 1e-3 && c[2].abs() < 1e-3,
"gold NORMAL_FONT_COLOR, got {c:?}"
);
// Armor (school 0) reads straight off UnitArmor's base.
assert_eq!(
s.eval::<String>("return CharacterArmorFrameStatText:GetText()")
.unwrap(),
"120"
);
// MagicResFrame1 = id 6 (arcane) — resistances[6] = 3.
// MagicResFrame1 is id 6, arcane (`PaperDollFrame.xml:654`).
assert_eq!(
s.eval::<String>("return MagicResText1:GetText()").unwrap(),
"3"
);
// No ranged weapon in this fixture: the N/A fallback (ref PaperDollFrame_SetRangedAttack).
// No ranged weapon: `PaperDollFrame_SetRangedAttack` shows N/A.
assert_eq!(
s.eval::<String>("return CharacterRangedAttackFrameStatText:GetText()")
.unwrap(),
"N/A"
);
// The equipped head item's icon shows once UNIT_INVENTORY_CHANGED fires (the real app's own
// sequencing: a snapshot push is followed by the event, never inferred from the push alone).
// The app follows a snapshot push with this event; the slots repaint on the event.
s.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![ScriptValue::Str("player".into())],
@@ -290,7 +253,7 @@ fn shipped_character_frame_drives_end_to_end() {
});
assert!(head_icon, "the head slot renders the equipped item's icon");
// Hovering an EMPTY slot (Neck, never populated) shows the slot-name tooltip fallback.
// An empty slot's tooltip names the slot.
let neck_center = {
let l: f32 = s.eval("return CharacterNeckSlot:GetLeft()").unwrap();
let r: f32 = s.eval("return CharacterNeckSlot:GetRight()").unwrap();
@@ -305,10 +268,7 @@ fn shipped_character_frame_drives_end_to_end() {
"the empty Neck slot shows a tooltip"
);
// Hovering a resistance icon WRAPS its long subtext at the tooltip wrap column — the ref
// passes wrap=1 on every stat/resistance subtext AddLine (ref-PaperDollFrame.xml:115 and
// siblings); a transcription that drops the flag renders the ~380px sentence as one
// unwrapped line (the director's tooltip max-width report).
// The resistance subtext wraps: its `AddLine` passes wrap = 1 (`PaperDollFrame.xml:115`).
let res_center = {
let l: f32 = s.eval("return MagicResFrame1:GetLeft()").unwrap();
let r: f32 = s.eval("return MagicResFrame1:GetRight()").unwrap();
@@ -341,11 +301,8 @@ fn shipped_character_frame_drives_end_to_end() {
"the subtext line wraps at the tooltip wrap column, got width {w}"
);
// Rotating the model pane advances the booth yaw from the ref's default 0.61 by +0.03/click and
// plays the rotate kit. The pane's own rotate button calls `Model_RotateRight(this:GetParent())`
// (stock `PaperDollFrame.xml:265`), the reference's shared turntable out of `UIParent.lua:1421`,
// declared in our `UIParent.xml` — our `BenillaPaperDollModel_*` pair is gone with the file
// that used it (decision 1751).
// `Model_RotateRight` adds 0.03 to `Model_OnLoad`'s 0.61 and plays the rotate kit
// (`UIParent.lua:1421-1442`); the pane's button calls it (`PaperDollFrame.xml:265`).
s.run("Model_RotateRight(CharacterModelFrame)").unwrap();
assert!(
(s.model_pane_facing("CharacterModelFrame") - 0.64).abs() < 0.001,
@@ -357,7 +314,6 @@ fn shipped_character_frame_drives_end_to_end() {
vec![SoundRequest::KitName("igInventoryRotateCharacter".into())]
);
// ToggleCharacter() again closes it through HideUIPanel, playing the close kit.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(s.errors().is_empty(), "close errors: {:?}", s.errors());
assert!(!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap());
@@ -368,13 +324,8 @@ fn shipped_character_frame_drives_end_to_end() {
);
}
/// Regression — the close button's z-order (the reported "missing red X"): the character window
/// carries no border art of its own; its whole frame-and-background IS `PaperDollFrame`'s
/// BACKGROUND layer, a full-window page created AFTER the close button. Every frame defaults to
/// level 0, so within the window draw order is creation order (`order.rs`) — the page's art painted
/// over the earlier-created button and it vanished. The button's `OnLoad` raises its frame level
/// (the same idiom `CharacterNameFrame` uses), so its art must paint AFTER every one of the
/// page's own quads. Checked on the real extracted draw order, not merely the level integer.
/// The window's art is `PaperDollFrame`'s BACKGROUND layer, created after the close button; the
/// button's `OnLoad` raises its level by 4 (`CharacterFrame.xml:75`), so its red X paints above.
#[test]
fn close_button_draws_above_the_paper_doll_page() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -384,21 +335,19 @@ fn close_button_draws_above_the_paper_doll_page() {
super::test_ui::load_ui_strict(&s, f);
}
s.set_unit("player", Some(player_unit()));
// The window must be SHOWN for extract() to emit quads (hidden="true" by default).
// extract() emits only shown frames, and the window is authored hidden.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.resolve();
// extract() returns quads already sorted ascending by draw order, so index == paint order.
// extract() sorts by draw order, so a quad's index is its paint position.
let quads = s.extract();
let owner = |q: &ExtractedQuad| s.quad_owner_name(q.target);
// The window's page art draws first; its LAST quad is the top-most page pixel.
let page_last = quads
.iter()
.rposition(|q| owner(q).as_deref() == Some("PaperDollFrame"))
.expect("the paper-doll page renders its background art");
// The close button's red-X normal texture must render at all …
let close_x = quads
.iter()
.position(|q| {
@@ -408,33 +357,16 @@ fn close_button_draws_above_the_paper_doll_page() {
})
.expect("the close button renders its normal (red-X) texture");
// … and it must paint AFTER the page — otherwise it's buried, the reported bug.
assert!(
close_x > page_last,
"close button (draw #{close_x}) must paint after the paper-doll page (last at #{page_last})"
);
}
/// **The level/race/class line, and the one event that repaints it.** `PaperDollFrame_SetLevel`
/// formats `PLAYER_LEVEL` ("Level %d %s %s", `GlobalStrings.lua:3075`) from `UnitLevel`/`UnitRace`/
/// `UnitClass` and writes it to BOTH `CharacterLevelText` and the honor page's `HonorLevelText`
/// "while we at it" (stock `PaperDollFrame.lua:100-103`); `UNIT_LEVEL` for `"player"` is the only
/// event that calls it again (`PaperDollFrame.lua:48-49`), and only while the page is visible
/// (`:42-44`).
///
/// **Retired divergence (decision 1751's character swap).** This test used to be
/// `level_line_survives_no_player_snapshot_yet`: it opened the window with NO player snapshot and
/// pinned `"Level 0 ? ?"`, because our deleted `assets/ui/CharacterFrame.xml` carried `or "?"`
/// placeholders around the two nils. The reference has no such guard — `PaperDollFrame.lua:101`
/// passes `UnitRace("player")` straight into `format`, which raises `bad argument #3 to 'format'`
/// on a nil — so on the stock file that state is not "survived", it is a hard error, and pinning
/// our softening would have been pinning the thing the swap deleted.
///
/// It is also **unreachable in the app**, which is why nothing replaces the guard rather than the
/// expectation moving to "it raises": `ui_unit::feed_units` pushes `set_unit("player", …)` earlier
/// in the same system than it fires `PLAYER_ENTERING_WORLD`, and that fire is gated on our avatar's
/// descriptor existing (decisions 1087/1094) — while the window can only be opened in-world at all.
/// So the state under test is the one below: a snapshot is always there first.
/// `PaperDollFrame_SetLevel` writes `PLAYER_LEVEL` to `CharacterLevelText` and `HonorLevelText`
/// (`PaperDollFrame.lua:100-103`); `UNIT_LEVEL` repaints it while the page shows (`:42-49`). A nil
/// race raises in its `format`, and `ui_unit::feed_units` always pushes the player snapshot before
/// `PLAYER_ENTERING_WORLD`, so the test seats one first.
#[test]
fn level_line_reads_the_snapshot_and_repaints_on_unit_level() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -453,14 +385,13 @@ fn level_line_reads_the_snapshot_and_repaints_on_unit_level() {
.unwrap(),
"Level 12 Night Elf Warrior"
);
// The honor page's twin, written by the same call — the reason `HonorFrame.xml` is in
// CHARACTER_UI at all (its absence loads clean and raises on the first SHOW).
// Written by the same call, which is why `HonorFrame.xml` is in the load list.
assert_eq!(
s.eval::<String>("return HonorLevelText:GetText()").unwrap(),
"Level 12 Night Elf Warrior"
);
// Ding: a new snapshot plus `UNIT_LEVEL` for the player, the app's own sequencing.
// A level-up as the app sends it: a new snapshot, then `UNIT_LEVEL` for the player.
let mut leveled = player_unit();
leveled.level = 13;
s.set_unit("player", Some(leveled));
@@ -473,7 +404,6 @@ fn level_line_reads_the_snapshot_and_repaints_on_unit_level() {
"UNIT_LEVEL repaints the line (PaperDollFrame.lua:48-49)"
);
// …and it is really on screen, not merely in the FontString.
s.resolve();
let quads: Vec<ExtractedQuad> = s.extract();
assert!(
@@ -483,24 +413,9 @@ fn level_line_reads_the_snapshot_and_repaints_on_unit_level() {
);
}
/// Decision 0208 phase 1b, end to end: clicking an OCCUPIED doll slot picks the item up onto the
/// cursor (the SAME payload `GetCursorInfo`/`CursorHasItem` read) and dims the slot's icon
/// (`IsInventoryItemLocked` — the held-here derivation, no server round-trip). A second click on
/// the SAME slot cancels: the cursor empties and the dim clears.
///
/// **Driven through the mouse since decision 1751's character swap**, not `s.run("Handler(frame)")`:
/// the stock handler is `PaperDollItemSlotButton_OnClick(button, ignoreModifiers)`
/// (`PaperDollFrame.lua:647`), which takes the MOUSE button as its first argument and reads the
/// frame off `this` — and only the engine sets `this`. That also puts the template's own
/// `RegisterForClicks("LeftButtonUp", "RightButtonUp")` (`PaperDollFrame.lua:86`) and its
/// `<OnClick>PaperDollItemSlotButton_OnClick(arg1)</OnClick>` (`PaperDollFrame.xml:15-17`) under
/// test, which the old by-name call skipped.
///
/// **Retired divergence:** the dim used to be asserted as "darker than 0.5". The reference's exact
/// value is 0.5 — `PaperDollItemSlotButton_UpdateLock` calls
/// `SetItemButtonDesaturated(this, 1, 0.5, 0.5, 0.5)` (`PaperDollFrame.lua:729-737`), and
/// `ItemButtonTemplate.lua:61-82` both desaturates the icon and vertex-colours it with exactly
/// those numbers. So the assertion is the pair, `desaturated` included.
/// A click picks the item up and locks the slot, and a second click on it cancels. A locked slot's
/// icon is desaturated and tinted 0.5 (`PaperDollFrame.lua:729-737`,
/// `ItemButtonTemplate.lua:61-82`).
#[test]
fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -512,8 +427,7 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
s.set_inventory_slots(inventory_with_head_item());
// The window must be SHOWN for `extract()` to emit its quads below (hidden="true" by
// default) — the icon-dim check needs a real render pass, not just the engine-side state.
// Shown, so extract() emits the slot quads.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
s.resolve();
@@ -522,11 +436,8 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
super::test_ui::click(&mut s, "CharacterHeadSlot", "LeftButton");
// The repaint is one frame behind the pickup, and that is the reference's shape, not a
// convenience: `PickupInventoryItem` locks the slot and QUEUES `ITEM_LOCK_CHANGED(-1, id)`
// (`benilla-ui/src/script/cursor/doll.rs:78`, the one transition seam of decision 0216), and
// that event is what runs `PaperDollItemSlotButton_UpdateLock` (stock
// `PaperDollFrame.lua:601-604`). The app ticks every frame; a test has to say so.
// `PickupInventoryItem` queues `ITEM_LOCK_CHANGED`, whose arm repaints the lock
// (`PaperDollFrame.lua:601-604`); the tick delivers it.
s.tick(0.0);
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
@@ -539,7 +450,6 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap(),
"the picked slot locks"
);
// The icon desaturates and vertex-dims to the reference's own 0.5 triple.
s.resolve();
let head_icon_dim = s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), color: Some(c), desaturated, .. }
@@ -547,7 +457,6 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
});
assert!(head_icon_dim, "the picked slot's icon dims");
// Clicking the SAME slot again cancels — mirrors the bag's own same-slot-cancel contract.
super::test_ui::click(&mut s, "CharacterHeadSlot", "LeftButton");
s.tick(0.0);
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
@@ -560,22 +469,9 @@ fn clicking_an_occupied_doll_slot_picks_it_up_and_locks_it() {
assert!(head_icon_lit, "cancelling clears the dim");
}
/// Decision 0208 phase 1b's `CURSOR_UPDATE` highlight: holding a BAG item that fits ONLY the
/// head slot lights the head slot's highlight ring and leaves every other slot dark (the neck slot
/// checked here). Stock `PaperDollItemSlotButton_OnEvent`'s `CURSOR_UPDATE` arm is
/// `if ( CursorCanGoInSlot(this:GetID()) ) then this:LockHighlight() else this:UnlockHighlight()`
/// (`PaperDollFrame.lua:609-616`).
///
/// **Retired divergence (decision 1751's character swap).** This used to spy on a pair of our own
/// `BenillaPaperDollSlot_LockHighlight`/`_UnlockHighlight` wrappers, which vertex-tinted the
/// Quickslot2 ring because the engine had no `LockHighlight`. It has both verbs now
/// (`benilla-ui/src/script/button.rs`), the stock file calls them, and the emulation is gone with
/// the file that held it — so the assertion is the real locked-highlight state, read where a player
/// reads it: `ItemButtonTemplate`'s own `HighlightTexture`
/// (`Interface\Buttons\ButtonHilight-Square`, `ItemButtonTemplate.xml:44`) is emitted for a locked
/// button and not for an unlocked, unhovered one (`ButtonState::region_visible`). The mouse is
/// parked clear of the doll first so nothing is hovered and the lock is the only thing that can
/// light a ring.
/// `CURSOR_UPDATE` locks the highlight on each slot `CursorCanGoInSlot` accepts
/// (`PaperDollFrame.lua:609-616`); the lit ring is the template's `HighlightTexture`
/// (`ItemButtonTemplate.xml:44`). The mouse is parked off the doll, so only a lock lights a ring.
#[test]
fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -586,12 +482,11 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
}
s.set_unit("player", Some(player_unit()));
s.set_player_combat_stats(Some(combat_stats()));
// The window must be SHOWN for extract() to emit the slots' quads at all.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
super::test_ui::unhover(&mut s);
s.resolve();
/// Is `slot`'s highlight ring in the render list? (`None` for "no such owner drew anything".)
/// Whether `slot`'s highlight ring is in the render list.
fn ring_lit(s: &mut UiScript, slot: &str) -> bool {
s.resolve();
let quads = s.extract();
@@ -609,12 +504,10 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
s.set_container(0, Some(backpack_with_fitting_helm()));
s.run("PickupContainerItem(0, 1)").unwrap();
s.tick(0.0); // dispatches the queued CURSOR_UPDATE to every registered doll slot
s.tick(0.0); // delivers the queued CURSOR_UPDATE
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The engine's own predicate agrees with the fixture's `equip_slots: vec![1]` — the input the
// reference's arm branches on, pinned beside the effect so a wrong answer here can't read as a
// wiring failure there.
// The predicate the arm branches on, pinned beside the effect.
assert!(s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
assert!(!s.eval::<bool>("return CursorCanGoInSlot(2)").unwrap());
@@ -628,9 +521,7 @@ fn cursor_update_highlights_fitting_doll_slots_while_holding_a_bag_item() {
);
}
/// The model pane's click-with-payload path (ref `CharacterModelFrame_OnMouseUp`): a left-click
/// release on the model pane while holding a fitting BAG item auto-equips it — the queued
/// `(bag, slot)` source, cursor cleared.
/// A left-button release on the model pane auto-equips the held item (`PaperDollFrame.lua:31-35`).
#[test]
fn model_pane_click_auto_equips_a_held_bag_item() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -664,25 +555,9 @@ fn model_pane_click_auto_equips_a_held_bag_item() {
assert_eq!(s.take_container_autoequips(), vec![(0, 1)]);
}
/// A BROKEN equipped item (durability 0) paints its doll slot's **ring** red — the reference's own
/// 0.9,0,0 on that slot's `NormalTexture` (`Interface\Buttons\UI-Quickslot2`,
/// `ItemButtonTemplate.xml:31`). Repairing it restores the ring to white, and no neighbouring slot
/// is ever tinted. Director-directed with the armor guy.
///
/// **Retired divergence (decision 1751's character swap): the ICON does not go red, and in the
/// reference it never did.** Stock `PaperDollItemSlotButton_Update` really does call BOTH setters
/// on a broken item — `SetItemButtonTextureVertexColor(this, 0.9, 0, 0)` and
/// `SetItemButtonNormalTextureVertexColor(this, 0.9, 0, 0)` (`PaperDollFrame.lua:670-672`) — and
/// `ItemButtonTemplate.lua` paints `$parentIconTexture` from the first and `$parentNormalTexture`
/// from the second (`:53-59` / `:84-90`). But the same `Update` ends with
/// `PaperDollItemSlotButton_UpdateLock()` (`:714`), whose unlocked arm is
/// `SetItemButtonDesaturated(this, nil)` (`:735`), and that function's `if ( not desaturated )`
/// branch overwrites `r,g,b` with 1,1,1 and re-`SetVertexColor`s **the icon**
/// (`ItemButtonTemplate.lua:71-81`). Forty-four lines later in the same call, the icon's red is
/// gone; only the ring survives. Our deleted `assets/ui/CharacterFrame.xml` painted both and made
/// them stick, which is why this test asserted the icon — that half is the transcription's, not the
/// reference's, and it goes with the file. Verified on a real run: the head slot's `INV_Helmet_01`
/// quad extracts white while its `UI-Quickslot2` quad extracts [0.9, 0, 0, 1].
/// A broken item tints its slot's ring (`UI-Quickslot2`) 0.9,0,0 (`PaperDollFrame.lua:670-672`).
/// The icon is tinted too, but the same update ends in `UpdateLock` (`:714`), whose unlocked
/// `SetItemButtonDesaturated(this, nil)` resets it to white (`ItemButtonTemplate.lua:71-81`).
#[test]
fn broken_equipped_item_tints_its_doll_slot_red() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -704,8 +579,7 @@ fn broken_equipped_item_tints_its_doll_slot_red() {
);
assert!(s.errors().is_empty(), "update errors: {:?}", s.errors());
s.resolve();
/// The colour of `slot`'s own quad whose texture path contains `needle` — asked per OWNER, so
/// the nineteen slots that share the ring art can be told apart.
/// The colour of `slot`'s own quad with `needle` in its path (every slot shares the ring art).
fn slot_color(s: &mut UiScript, slot: &str, needle: &str) -> Option<[f32; 4]> {
s.resolve();
let quads = s.extract();
@@ -732,14 +606,12 @@ fn broken_equipped_item_tints_its_doll_slot_red() {
Some(white),
"…and only that slot's — its neighbours share the art and rest white"
);
// The icon stays white: see the retired divergence in this test's doc comment.
assert_eq!(
slot_color(&mut s, "CharacterHeadSlot", "INV_Helmet_01"),
Some(white),
"the icon's red is overwritten by UpdateLock's SetItemButtonDesaturated(this, nil)"
);
// Repaired: the ring restores to white.
let mut inv = inventory_with_head_item();
inv[1].as_mut().unwrap().durability = Some((40, 40));
s.set_inventory_slots(inv);
@@ -756,12 +628,8 @@ fn broken_equipped_item_tints_its_doll_slot_red() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The director's own gesture, by screen point (2026-07-17 report: "tab switching flaky, can't
/// switch back to Character from Skills"): open the window, click the Skills tab, click a skill
/// row (arming the detail pane), click the Character tab, and round-trip once more — every click
/// routed through the pointer pipeline (`mouse_move`/`mouse_button`) exactly as the app does, so
/// a frame silently eating the tab row's clicks (or a handler error breaking the chain) fails
/// HERE, not on the director's screen. Zero collected handler errors allowed anywhere.
/// Skills and back to Character twice, every click by screen point through the pointer path, with
/// a skill row selected on the way: the Character tab must switch back while Skills is up.
#[test]
fn tab_round_trip_with_a_selected_skill_by_point() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -772,13 +640,6 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
// `ScrollTemplates.xml` and `SkillFrame.xml` used to be loaded again here, because the old
// per-file preamble did not carry them and a MISSING template is a loader *warning*, not an
// error (`SkillDetailScrollFrame` inherits `UIPanelScrollFrameTemplate`, so it would have
// loaded clean with no trough and failed later on geometry that silently never got built).
// `CHARACTER_UI` carries both now — `CharacterFrame_ShowSubFrame` hides all five pages by name
// on the first `ToggleCharacter` — and `load_ui_strict` fails on the warning, so a second load
// would only re-declare frames the list already built.
s.set_unit("player", Some(player_unit()));
s.set_skills(SkillsState {
entries: vec![
@@ -811,15 +672,14 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
category_name: "Professions".into(),
category_order: 2,
description: "Working with metals.".into(),
// The real 5875 split: primary professions carry SkillRaceClassInfo 0x20.
// A primary profession: SkillRaceClassInfo flag 0x20, `SKILL_FLAG_UNLEARNABLE`.
abandonable: true,
mono: false,
},
],
});
// The center of the unique visible Text quad `text` — resolves layout first, so it's always
// the CURRENT rect. Panics with the visible-text inventory if absent (a paint regression).
// The centre of the visible text quad `text`, after a resolve.
fn text_center(s: &mut UiScript, text: &str) -> (f32, f32) {
s.resolve();
let quads = s.extract();
@@ -857,13 +717,12 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(shown(&mut s, "PaperDollFrame"), "opens on the doll page");
// Tab to Skills — by point, the director's gesture.
let skills_tab = text_center(&mut s, "Skills");
click(&mut s, skills_tab);
assert!(shown(&mut s, "SkillFrame"), "Skills tab shows the page");
assert!(!shown(&mut s, "PaperDollFrame"), "doll page yields");
// Select a skill row — arms the detail pane (the report's precondition).
// Selecting a row arms the detail pane.
let row = text_center(&mut s, "Blacksmithing");
click(&mut s, row);
assert!(
@@ -871,8 +730,7 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
"the row click selects"
);
assert!(shown(&mut s, "SkillDetailStatusBar"), "detail bar arms");
// The description body renders through the SKILL_DESCRIPTION format (skillType is "" in every
// reachable 1.12 branch) — the nil-global half of the report, pinned.
// SKILL_DESCRIPTION, with a skillType that is always "" (`SkillFrame.lua:245`).
assert_eq!(
s.eval::<String>("return SkillDetailDescriptionText:GetText()")
.unwrap(),
@@ -880,9 +738,7 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
"the detail description renders via SKILL_DESCRIPTION"
);
// The abandon slice: a primary profession offers the unlearn button; its confirm formats the
// name; accepting queues the CMSG_UNLEARN_SKILL intent BY SKILL ID and removes nothing
// locally (the server's SetSkill(id,0,0) round trip owns the removal).
// Accepting the unlearn confirm queues CMSG_UNLEARN_SKILL by skill id; the server removes it.
assert!(
shown(&mut s, "SkillDetailStatusBarUnlearnButton"),
"the unlearn button shows for a profession"
@@ -910,18 +766,15 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
"nothing is removed locally"
);
// A weapon line never offers it.
let defense = text_center(&mut s, "Defense");
click(&mut s, defense);
assert!(
!shown(&mut s, "SkillDetailStatusBarUnlearnButton"),
"no unlearn button for Defense"
);
// Restore the profession selection so the round trip below leaves familiar state.
let row = text_center(&mut s, "Blacksmithing");
click(&mut s, row);
// Tab BACK to Character — the reported failure.
let character_tab = text_center(&mut s, "Character");
assert_eq!(
s.hit_test_name(character_tab.0, character_tab.1).as_deref(),
@@ -935,7 +788,6 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
);
assert!(!shown(&mut s, "SkillFrame"), "Skills page yields");
// Once more around — "flaky" only shows up on repetition.
let skills_tab = text_center(&mut s, "Skills");
click(&mut s, skills_tab);
assert!(shown(&mut s, "SkillFrame"), "second trip to Skills");
@@ -950,18 +802,9 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
);
}
/// The rotate arrows, driven by the pointer exactly as a hand does (the director's report:
/// "the 2 arrows for turning barely move it at all"). The ref's feel is TWO mechanisms, and the
/// pane used to ship only the smaller one:
///
/// - a **tap** fires `OnClick` on BOTH edges (`RegisterForClicks("LeftButtonDown","LeftButtonUp")`,
/// ref PaperDollFrame.xml l.241-243) — 2 × 0.03 rad, ~3.4°, which is all a tap is meant to be;
/// - **holding** spins at `ROTATIONS_PER_SECOND` = half a turn per second through the pane's
/// `OnUpdate` (ref `Model_OnUpdate`, UIParent.lua:1444-1462) — 100× the tap per second, and the
/// only reason the arrows feel like anything.
///
/// The ref's held branches also run OPPOSITE to its click helpers (held-LEFT adds where
/// click-LEFT subtracts); that quirk is quoted, so it is asserted here rather than "corrected".
/// A tap clicks on both edges (`PaperDollFrame.xml:243`), 0.03 each; holding spins half a turn a
/// second (`Model_OnUpdate`, `UIParent.lua:1444-1462`). The held branches turn opposite to the
/// click helpers, as in the reference.
#[test]
fn rotate_arrows_tap_twice_and_spin_while_held() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -975,7 +818,6 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.resolve();
// The left arrow's centre, in screen points.
let btn = "CharacterModelFrameRotateLeftButton";
let (bx, by) = {
let l: f32 = s.eval(&format!("return {btn}:GetLeft()")).unwrap();
@@ -985,7 +827,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
((l + r) * 0.5, (t + b) * 0.5)
};
// Press and HOLD. The press edge is itself a click (−0.03 off the 0.61 default).
// The press edge is a click: 0.61 - 0.03.
s.mouse_move(bx, by);
s.mouse_button(bx, by, "LeftButton", true);
let pressed = s.model_pane_facing("CharacterModelFrame");
@@ -994,7 +836,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
"the press edge nudges once: {pressed}"
);
// Held for half a second: half of half a turn = +π/2 (the held-LEFT branch ADDS).
// Half a second held is a quarter turn, which the held-left branch adds.
s.tick(0.5);
assert!(s.errors().is_empty(), "OnUpdate errors: {:?}", s.errors());
let spun = s.model_pane_facing("CharacterModelFrame");
@@ -1003,7 +845,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
"half a second held spins half of half a turn: {pressed} → {spun}"
);
// Release: the second click edge (another −0.03), and the spin stops dead.
// The release edge is the second click, and the spin stops.
s.mouse_button(bx, by, "LeftButton", false);
let released = s.model_pane_facing("CharacterModelFrame");
assert!(
@@ -1016,7 +858,7 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
"a released button does not keep spinning"
);
// The right arrow spins the other way (held-RIGHT subtracts).
// Held right subtracts.
let rbtn = "CharacterModelFrameRotateRightButton";
let (rx, ry) = {
let l: f32 = s.eval(&format!("return {rbtn}:GetLeft()")).unwrap();
@@ -1038,20 +880,8 @@ fn rotate_arrows_tap_twice_and_spin_while_held() {
assert!(s.errors().is_empty(), "no handler errors: {:?}", s.errors());
}
/// **The keybind and the tab row must agree** (the director's 2026-08-06 report: *"go on skills tab
/// and press c, it goes back to char tab but without switching the actual tab ui below properly,
/// then if I click on char tab, it closes the whole window"*).
///
/// The cause was a dropped line: the reference selects the tab inside `ToggleCharacter` itself
/// (`PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())`, ref `CharacterFrame.lua:11`), on
/// EVERY path. Ours only did it in the tab button's own `OnClick`, so the two entry points into the
/// window diverged: a keybind switched the page and left the row selecting the old tab, and the
/// next click on the *now-showing* page's tab hit `ToggleCharacter`'s "this page is already
/// visible" branch and shut the window instead of switching back.
///
/// Driven through the same entry point the `C` / `SHIFT-P` bindings use — a bare
/// `ToggleCharacter(page)` call, never the button — because that is precisely the path the row used
/// to miss.
/// `ToggleCharacter` selects the tab itself (`CharacterFrame.lua:10`), so a keybind's page switch
/// moves the tab row; driven by a bare `ToggleCharacter(page)`, as the `C` binding calls it.
#[test]
fn a_keybind_page_switch_moves_the_tab_row_with_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1062,20 +892,9 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
}
s.set_unit("player", Some(player_unit()));
// THE INVARIANT the fix rests on: a page's `id=` is the slot in this window's tab row that the
// tab row's own dispatch sends to that page. `ToggleCharacter` selects the row with
// `PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())` (stock `CharacterFrame.lua:10`), so
// if a page's id and its tab's id ever disagree, a keybind lights the wrong tab.
//
// **Retired divergence (decision 1751's character swap).** This used to loop over
// `BENILLA_CHARACTERFRAME_SUBFRAMES` asserting `page i must carry id=i`, because our deleted
// `assets/ui/CharacterFrame.xml` listed the pages in tab order. The reference does not:
// `CHARACTERFRAME_SUBFRAMES` is `{PaperDoll, PetPaperDoll, Skill, Reputation, Honor}`
// (`CharacterFrame.lua:1`) while the tab row is Character/Pet/**Reputation**/**Skills**/Honor
// (`CharacterFrame.xml:79-168`) — index 3 is SkillFrame with id 4, index 4 is ReputationFrame
// with id 3. That list is a set to iterate for show/hide, not an ordering, so the ordering
// assertion moves to where the reference really states it: `CharacterFrameTab_OnClick`'s five
// name branches (`CharacterFrame.lua:35-48`).
// `ToggleCharacter` selects the tab by the page's id, so each page's id is the tab that shows
// it (`CharacterFrame.lua:35-48`). `CHARACTERFRAME_SUBFRAMES` is a set, not the tab order:
// Skills is its third entry and tab 4 (`CharacterFrame.lua:1`, `CharacterFrame.xml:79-168`).
let row: [(i64, &str); 5] = [
(1, "PaperDollFrame"),
(2, "PetPaperDollFrame"),
@@ -1094,8 +913,7 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
.unwrap(),
id,
);
// …and it is one of the five the show/hide sweep knows about, so a page cannot arrive with
// a tab and stay invisible to `CharacterFrame_ShowSubFrame` (`CharacterFrame.lua:25-33`).
// In the list `CharacterFrame_ShowSubFrame` sweeps (`CharacterFrame.lua:25-33`).
assert!(
s.eval::<bool>(&format!(
"for _, v in CHARACTERFRAME_SUBFRAMES do if v == \"{page}\" then return true end \
@@ -1116,8 +934,7 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
s.eval::<i64>("return PanelTemplates_GetSelectedTab(CharacterFrame)")
.unwrap()
};
// A tab wears its "Active" (=Disabled) art exactly when it is the selected one — the visible
// half of the same fact, so a SetTab that updated the number without repainting fails here too.
// A selected tab shows its Disabled art, the active look (`UIPanelTemplates.lua:119-128`).
let wearing_active_art = |s: &mut UiScript, tab: u32| {
s.eval::<bool>(&format!(
"return CharacterFrameTab{tab}MiddleDisabled:IsVisible()"
@@ -1129,7 +946,6 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
.unwrap()
};
// Open on Character (the `C` binding), then move to Skills the same way.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert_eq!(selected(&mut s), 1);
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
@@ -1138,7 +954,7 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
assert!(wearing_active_art(&mut s, 4));
assert!(!wearing_active_art(&mut s, 1));
// THE REPORT: `C` from the Skills page. The page goes back to Character — and so must the row.
// `C` from the Skills page: the page and the row both go back to Character.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(shown(&mut s, "PaperDollFrame"));
assert!(!shown(&mut s, "SkillFrame"));
@@ -1150,16 +966,9 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() {
assert!(wearing_active_art(&mut s, 1));
assert!(!wearing_active_art(&mut s, 4));
// …and the second half of the report — *"then if I click on char tab, it closes the whole
// window"* — falls out of the same fix rather than needing its own. `PanelTemplates_SelectTab`
// DISABLES the tab it selects (a selected tab is not re-clickable), so with the row in step the
// Character tab is inert while the Character page is up. The window only closed because the row
// still said Skills: tab 1 was left enabled, the click reached `ToggleCharacter`, and the page
// it named was already visible — the HideUIPanel arm.
//
// The MECHANISM is asserted first and the click second, deliberately: a tab whose OnClick never
// ran would sail through the click assertions below for the wrong reason, and this is the one
// fact that cannot.
// `PanelTemplates_SelectTab` disables the selected tab (`UIPanelTemplates.lua:125`), so a click
// on the showing page's tab cannot close the window. The disable is asserted before the click:
// a click whose OnClick never ran would pass the checks after it.
assert_eq!(
s.eval::<i64>("return CharacterFrameTab1:IsEnabled()")
.unwrap(),
@@ -1181,26 +990,17 @@ 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.
/// The template's `<OnShow>` fits a tab once with `PanelTemplates_TabResize(0)`: its text width
/// plus the two end slices (`CharacterFrameTemplates.xml:78`, `UIPanelTemplates.lua:31-88`), from
/// an authored 115.
#[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.
/// `2 * CharacterFrameTab1Left:GetWidth()`: the 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).
// The app always installs a measurer (`AtlasMeasurer`); a bare VM has none.
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);
@@ -1220,10 +1020,8 @@ fn the_five_tabs_fit_their_labels_on_the_first_show() {
})
.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.
// `PetTab_Update` hides the pet tab without a pet (`PetPaperDollFrame.lua:189-197`), and a
// hidden tab gets no OnShow, so it keeps its authored width.
assert!(
!s.eval::<bool>("return CharacterFrameTab2:IsVisible()")
.unwrap(),
@@ -1253,7 +1051,7 @@ fn the_five_tabs_fit_their_labels_on_the_first_show() {
"…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.
// Shown as a pet shows it, it fits on its own first show.
s.run("CharacterFrameTab2:Show()").unwrap();
s.resolve();
let (pet_label, pet_width) = widths(&mut s)[1];
@@ -1263,7 +1061,6 @@ fn the_five_tabs_fit_their_labels_on_the_first_show() {
"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);
}
@@ -1277,24 +1074,9 @@ fn the_five_tabs_fit_their_labels_on_the_first_show() {
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
/// `PanelTemplates_Tab_OnClick(<frame>)` straight into a tab's `<OnClick>` and let the kit do the
/// rest; this client had every other member of the kit and not that one, because our own windows
/// wire their tabs to their own handlers and never reached for the generic entry point.
///
/// **It is built on a row of this test's own, and that is deliberate.** When it was written our
/// character row carried no `id=` at all (it was id-based through a `BenillaCharacterFrameTab_OnClick(id)`
/// closure), so driving it through the generic entry point would have selected tab 0. Decision
/// 1751's swap fixed that from the other end — the row is the reference's own now and every tab
/// carries its id (`CharacterFrame.xml:79-168`) — but the standalone row stays: this test is about
/// the KIT, and an addon's window is exactly what it builds, so it must not need a shipped window
/// loaded to run.
///
/// The `this`/frame split is the whole reason the function exists: `this` is the clicked TAB and
/// the owning FRAME is a separate argument, which is the shape `SimpleActionSets.xml:342` relies on
/// when it passes `this:GetParent()`.
/// `PanelTemplates_Tab_OnClick(frame)` selects `this:GetID()` on `frame`
/// (`UIPanelTemplates.lua:2-4`): `this` is the clicked tab and the owner an argument, as an addon
/// calls it from a tab's `<OnClick>` on a row of its own.
#[test]
fn an_addons_tab_click_selects_through_the_generic_entry_point() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1304,21 +1086,18 @@ fn an_addons_tab_click_selects_through_the_generic_entry_point() {
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, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
// `PanelTemplates_SelectTab` asks `GameTooltip:IsOwned(tab)` (`UIPanelTemplates.lua:130`).
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
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\CharacterFrameTemplates.xml"); // the window tab (1993)
load_xml(&s, r"Interface\FrameXML\CharacterFrameTemplates.xml"); // the window tab
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.
// Tabs named `<frame>Tab1..N`, as `PanelTemplates_UpdateTabs` looks them up, each with its id.
s.run(
r#"
TabKitFrame = CreateFrame("Frame", "TabKitFrame", UIParent)
@@ -1338,7 +1117,6 @@ fn an_addons_tab_click_selects_through_the_generic_entry_point() {
};
assert_eq!(selected(&mut s), 1);
// The addon idiom, verbatim: `this` is the tab, the frame is the argument.
s.run("this = TabKitFrameTab2; PanelTemplates_Tab_OnClick(TabKitFrame); this = nil")
.unwrap();
assert_eq!(
@@ -1346,7 +1124,7 @@ fn an_addons_tab_click_selects_through_the_generic_entry_point() {
2,
"the kit takes the tab's OWN id from `this`, not from the frame it was handed"
);
// The row repainted, not just the number — the same visible half the keybind test asserts.
// The row repaints, not just the number.
assert!(
s.eval::<bool>("return TabKitFrameTab2MiddleDisabled:IsVisible()")
.unwrap(),
@@ -1,26 +1,10 @@
//! The chat tab's **options menu**, end to end (decision 1589, fixing **B246**: *"no chat options
//! at all — background transparency has no home, and chat can be hard to read"*).
//!
//! These drive the shipped `ChatFrame.xml` through the shipped dropdown kit and the shipped colour
//! picker, from the mouse event inward — a right-click on the real tab, a click on the real
//! **Background** row, a drag of the real `OpacitySliderFrame` — because the report is about a
//! path, not about a function. Every hop it names (is the menu reachable? does the row open the
//! picker? does the slider move the chat box? does the value survive?) is a hop where the feature
//! could be absent while every unit underneath it passed.
//!
//! The manifest is `color_picker_tests`' (fonts → panel manager → widget kit → picker) plus
//! `ChatFrame.xml` last, which is `benilla.toc`'s own order.
//! The chat tab's options menu, driven from the mouse through the stock frames and colour picker.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// The GlobalStrings the menu labels itself with. The app runs the real
/// `Interface\FrameXML\GlobalStrings.lua` off the player's own patch chain at boot
/// (`load_global_strings`); these stand in for it here, at their real 1.12.1 values —
/// `BACKGROUND` l.131 (whose own trailing comment in that file reads *"Title in the chat
/// preferences menu"*, i.e. this exact row), `DISPLAY` l.937, `FONT_SIZE` l.1983,
/// `FONT_SIZE_TEMPLATE` l.1984, `CHAT_OPTIONS_LABEL` l.673, `NEWBIE_TOOLTIP_CHATOPTIONS` l.2724.
/// The menu's labels at their stock `GlobalStrings.lua` values.
fn bake_strings(s: &UiScript) {
s.run(
r#"
@@ -35,7 +19,6 @@ fn bake_strings(s: &UiScript) {
.unwrap();
}
/// The chat window with everything its tab menu reaches under it.
fn chat_with_menu() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
@@ -70,9 +53,8 @@ fn chat_with_menu() -> UiScript {
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.
/// The tabs ship hidden until a stationary hover reveals them (`FCF_OnUpdate`), so a tab click
/// needs the reveal first.
fn reveal_then(s: &mut UiScript) {
reveal_dock(s);
}
@@ -86,7 +68,6 @@ fn right_click(s: &mut UiScript, frame: &str) {
s.resolve();
}
/// Move the mouse onto a frame's centre through the real hit path (fires its `OnEnter`).
fn hover(s: &mut UiScript, frame: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
@@ -95,8 +76,7 @@ fn hover(s: &mut UiScript, frame: &str) {
s.resolve();
}
/// Settle the dock's hover fade all the way open: park the cursor in the middle of the window and
/// run past the 0.2 s stationary arm plus the 0.15 s ramp (`chat_tests`' own idiom).
/// Parks the cursor mid-window past the dock's 0.2 s show delay and 0.15 s fade.
fn reveal_dock(s: &mut UiScript) {
let (x, y): (f32, f32) = s
.eval(
@@ -111,7 +91,6 @@ fn reveal_dock(s: &mut UiScript) {
}
}
/// Left-click a frame's centre through the real hit path.
fn left_click(s: &mut UiScript, frame: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
@@ -121,11 +100,6 @@ fn left_click(s: &mut UiScript, frame: &str) {
s.resolve();
}
/// **B246's first half: the menu exists and the right button reaches it.**
///
/// Before 1589 the tab registered only `LeftButtonUp`, so the right-click was swallowed by the
/// engine's default click set and no handler ever ran — which is why the report reads "no chat
/// options at all" rather than "the menu is missing a row".
#[test]
fn right_clicking_a_chat_tab_opens_its_options_menu() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -141,9 +115,8 @@ fn right_clicking_a_chat_tab_opens_its_options_menu() {
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the tab's right-click opens the options menu"
);
// 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.
// The default window's level-1 rows (`FCFOptionsDropDown_Initialize`); any other window
// adds Close before Display.
for (n, key) in [
(1, "UNLOCK_WINDOW"),
(2, "RENAME_CHAT_WINDOW"),
@@ -161,8 +134,6 @@ fn right_clicking_a_chat_tab_opens_its_options_menu() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A LEFT click still selects the tab and does not open the menu — the control that must not
/// change. (The reference's own fork: the right-button arm returns before the select.)
#[test]
fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -190,13 +161,8 @@ fn a_left_click_still_selects_the_tab_and_opens_no_menu() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B246's actual ask: the background slider.** Background → the colour picker with its opacity
/// slider → dragging the thumb moves the chat window's stored alpha *and* the pixels.
///
/// The two numbers this pins are the ones a wrong implementation gets wrong in opposite
/// directions: the slider is **reversed** (0 at the top is fully opaque), so the seed is `1 - a`
/// and the read-back is `1 - value`; and the store is a **byte**, so 0.8 comes back as
/// `204/255`, not as 0.8.
/// The opacity slider is reversed (0 is fully opaque), so it opens at `1 - a` and writes
/// `1 - value`; the store is a byte, so 0.8 reads back as 204/255.
#[test]
fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -220,8 +186,7 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
.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.
// The cursor leaves the chat frame for the picker, so the hover fade-out runs first.
s.mouse_move(1500.0, 850.0);
for _ in 0..45 {
s.tick(0.016);
@@ -242,12 +207,8 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
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.
// `valueStep="0.01"` rebuilds `1 - 0.8` as `n * step`, a hair below the literal, so
// `1 - value` is a hair above 0.8 and the store's floor quantizer keeps 204, not 203.
assert!(
(stored - 204.0 / 255.0).abs() < 1e-9,
"the drag reached the engine store, quantized to its byte: {stored}"
@@ -265,9 +226,6 @@ fn the_background_row_opens_the_picker_and_its_opacity_slider_drives_the_window(
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The default look is **unchanged** by all of the above — the alpha rule generalised, it did not
/// move. A window at the shipped base of 0 still rides the full 0 → `DEFAULT_CHATFRAME_ALPHA`
/// hover ramp, which is the look the director signed off in 0288.
#[test]
fn the_shipped_window_still_fades_zero_to_a_quarter() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -292,9 +250,6 @@ fn the_shipped_window_still_fades_zero_to_a_quarter() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Font Size** submenu — B246's other half, *"chat can be hard to read"*. The four heights
/// are the reference's `CHAT_FONT_HEIGHTS`, the tick follows the font the frame is wearing, and a
/// pick moves both the live font and the stored `SIZE`.
#[test]
fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -307,9 +262,8 @@ fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
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.
// `for index, value in CHAT_FONT_HEIGHTS` walks a `[n] =` keyed table in hash order, so a
// row is found by its value.
let row_with = |s: &UiScript, pt: i64| -> String {
s.eval::<String>(&format!(
"for i = 1, DropDownList2.numButtons do \
@@ -346,9 +300,7 @@ fn the_font_size_submenu_resizes_the_window_and_stores_the_pick() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The menu belongs to the tab that opened it: window 2's menu writes window 2. This is what the
/// `id` on the tab and the per-tab capsule buy — `FCF_GetCurrentChatFrameID` reads the open menu's
/// parent, so a single shared capsule would have written window 1 whichever tab was clicked.
/// `FCF_GetCurrentChatFrameID` reads the id of the open dropdown's parent, the clicked tab.
#[test]
fn each_tabs_menu_writes_its_own_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -359,7 +311,7 @@ fn each_tabs_menu_writes_its_own_window() {
s.eval::<i64>("return FCF_GetCurrentChatFrameID()").unwrap(),
2
);
// A docked non-default window's menu carries Close before Display, so Background is row 7.
// A non-default window's menu adds Close before Display, so Background is row 7.
assert!(s
.eval::<bool>("return DropDownList1Button7:GetText() == BACKGROUND")
.unwrap());
@@ -380,8 +332,6 @@ fn each_tabs_menu_writes_its_own_window() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Cancel restores the colour and the alpha the row was opened with — the picker's own
/// `previousValues` contract, which the reference reaches through the same `cancelFunc` field.
#[test]
fn cancelling_the_picker_puts_the_window_back() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -412,10 +362,7 @@ fn cancelling_the_picker_puts_the_window_back() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The `UPDATE_CHAT_WINDOWS` seam: the host restores a player's saved file into the engine table
/// and fires the reference's own event; the window re-reads it and repaints. Without this the
/// restore would land in the store and show up only after something else happened to invalidate
/// the fade latch.
/// The host restores saved windows into the engine table, then fires `UPDATE_CHAT_WINDOWS`.
#[test]
fn the_restore_event_repaints_the_window_from_the_store() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -433,10 +380,8 @@ fn the_restore_event_repaints_the_window_from_the_store() {
..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.
// Colour, alpha and lock paint only on a frame's first `UPDATE_CHAT_WINDOWS`
// (`FloatingChatFrame.lua:52`); the helper already fired one, so the latch is reset.
s.run("ChatFrame1.isInitialized = nil").unwrap();
s.fire_event("UPDATE_CHAT_WINDOWS", vec![]);
s.mouse_move(1500.0, 850.0);
@@ -454,9 +399,8 @@ 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");
// 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.
// The restore paints without saving, but the dock pass after it (`FCF_DockFrame`,
// `FCF_SaveDock`, `SetChatWindowDocked`) writes windows 1 and 2 itself.
assert_eq!(s.take_chat_window_changes(), vec![0, 1]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1,19 +1,9 @@
//! The chat window's **move, resize and lock**, end to end (decision 1594).
//!
//! Like `chat_options_tests` next door, these drive the shipped `ChatFrame.xml` from the mouse
//! event inward — a press on the real grip button, a drag of the real window body, a click on the
//! real menu row — because what is being built is a *path*, not a function. Every hop it names is
//! a hop where the feature could be absent while every unit underneath it passed: is the grip a
//! hit target at all? does the lock stop it? does the clamp hold? does the geometry reach the file
//! and come back?
//!
//! The manifest is `chat_options_tests`' — `benilla.toc`'s own order, up to `ChatFrame.xml`.
//! The chat window's move, resize and lock, driven from the mouse through the stock frames.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// The chat dock with everything its tab menu and its grips reach under it.
fn chat_ui() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
@@ -34,8 +24,7 @@ fn chat_ui() -> UiScript {
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"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.
// A tab-drag stop's `FCF_ValidateChatFramePosition` reads `MainMenuBar:GetHeight()`.
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
@@ -54,7 +43,6 @@ fn chat_ui() -> UiScript {
s
}
/// A frame's centre, through the real layout.
fn centre(s: &mut UiScript, frame: &str) -> (f32, f32) {
s.resolve();
let (x, y): (f64, f64) = s
@@ -78,7 +66,6 @@ fn left(s: &mut UiScript) -> f32 {
s.eval::<f64>("return ChatFrame1:GetLeft()").unwrap() as f32
}
/// Right-click a frame's centre through the real hit path.
fn right_click(s: &mut UiScript, frame: &str) {
let (x, y) = centre(s, frame);
s.mouse_button(x, y, "RightButton", true);
@@ -86,7 +73,6 @@ fn right_click(s: &mut UiScript, frame: &str) {
s.resolve();
}
/// Left-click a frame's centre through the real hit path.
fn left_click(s: &mut UiScript, frame: &str) {
let (x, y) = centre(s, frame);
s.mouse_button(x, y, "LeftButton", true);
@@ -94,10 +80,8 @@ fn left_click(s: &mut UiScript, frame: &str) {
s.resolve();
}
/// 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.
/// The tabs ship hidden until a stationary hover reveals them (`FCF_OnUpdate`), and a window
/// moves by its tab (`OnDragStart` calls `StartMoving`), so every drag starts here.
fn reveal(s: &mut UiScript) {
let (cx, cy) = centre(s, "ChatFrame1");
s.mouse_move(cx, cy);
@@ -107,7 +91,6 @@ fn reveal(s: &mut UiScript) {
}
}
/// 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");
@@ -125,8 +108,7 @@ fn grab_grip(s: &mut UiScript, grip: &str) -> (f32, f32) {
(x, y)
}
/// The reference's stock state: **both dock windows ship locked**, and that is what
/// `GetChatWindowInfo` answers, because it is the chat-cache's own `LOCKED 1`.
/// The stock chat cache carries `LOCKED 1` for both dock windows.
#[test]
fn both_dock_windows_ship_locked() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -152,10 +134,7 @@ fn both_dock_windows_ship_locked() {
.is_none());
}
/// **The tab menu's Lock row: it is there, it toggles, and its label flips.**
///
/// The reference's FIRST row (FloatingChatFrame.lua l.236-246): `UNLOCK_WINDOW` when the frame is
/// locked, `LOCK_WINDOW` when it is not, `notCheckable`.
/// The lock verb is the menu's first row (`FloatingChatFrame.lua:239`).
#[test]
fn the_tab_menus_lock_row_toggles_the_window_and_flips_its_label() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -197,17 +176,13 @@ fn the_tab_menus_lock_row_toggles_the_window_and_flips_its_label() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A locked window refuses the grip.** Two things have to hold and only one of them is the
/// early return: the grip must not take the click at all (or it would shadow the chat line under
/// it — see the template's note), and `FCF_Resize` must refuse even if something did reach it.
#[test]
fn a_locked_window_refuses_a_grip_drag() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
let before = width(&mut s);
assert_eq!(before, 430.0, "the authored width");
// The reference's grips take the mouse whatever the lock says — the refusal is
// `FCF_Resize`'s `isLocked` gate, not a disabled button.
// The grips take the mouse whatever the lock; `FCF_Resize`'s `isLocked` gate refuses.
assert!(s
.eval::<bool>("return ChatFrame1ResizeBottomRight:IsMouseEnabled()")
.unwrap());
@@ -224,11 +199,7 @@ fn a_locked_window_refuses_a_grip_drag() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **An unlocked window resizes from its BOTTOMRIGHT grip, and stops at the reference's bounds.**
///
/// 430×120 authored, `<ResizeBounds>` 296×75 … 608×400. The clamp is the half a naive pump gets
/// wrong twice over: without the rebate the size pins at the bound while the anchor keeps sliding,
/// so a window held past its minimum stops shrinking and starts *walking*.
/// Authored 430x120; `<ResizeBounds>` 296x75 to 608x400 (`FloatingChatFrame.xml`).
#[test]
fn an_unlocked_bottom_right_grip_resizes_and_clamps_at_the_bounds() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -248,7 +219,7 @@ fn an_unlocked_bottom_right_grip_resizes_and_clamps_at_the_bounds() {
let bottom_before: f64 = s.eval("return ChatFrame1:GetBottom()").unwrap();
grab_grip(&mut s, "ChatFrame1ResizeBottomRight");
// Right and DOWN: the BOTTOMRIGHT corner follows the cursor, so both grow.
// Right and down: the `BOTTOMRIGHT` corner follows the cursor, so both grow.
s.mouse_move(x + 50.0, y - 30.0);
assert_eq!(width(&mut s), 480.0, "the right edge followed the cursor");
assert_eq!(height(&mut s), 150.0, "and the bottom edge with it");
@@ -258,11 +229,9 @@ fn an_unlocked_bottom_right_grip_resizes_and_clamps_at_the_bounds() {
"the planted TOP edge stayed put: {bottom_before} -> {bottom_after}"
);
// Past the maximum: the edge lands ON the bound and stays there.
s.mouse_move(x + 900.0, y - 900.0);
assert_eq!(width(&mut s), 608.0, "maxResize x");
assert_eq!(height(&mut s), 400.0, "maxResize y");
// Past the minimum, from there.
s.mouse_move(x - 900.0, y + 900.0);
assert_eq!(width(&mut s), 296.0, "minResize x");
assert_eq!(height(&mut s), 75.0, "minResize y");
@@ -271,13 +240,7 @@ fn an_unlocked_bottom_right_grip_resizes_and_clamps_at_the_bounds() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The release ends the resize even when the cursor is nowhere near the grip.**
///
/// The failure this pins is not hypothetical: drag past `maxResize` and the grip *stops following*
/// (the clamp plants the edge while the cursor runs on), so the release lands on empty screen. If
/// `OnMouseUp` went to whatever is under the cursor rather than to the button that took the press,
/// `FCF_StopResize` would never run and the window would stay glued to the mouse for the rest of
/// the session.
/// `OnMouseUp` goes to the button that took the press, not to the frame under the cursor.
#[test]
fn the_release_ends_the_resize_from_anywhere_on_screen() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -287,7 +250,6 @@ fn the_release_ends_the_resize_from_anywhere_on_screen() {
s.mouse_move(x + 900.0, y);
assert_eq!(width(&mut s), 608.0, "held against the maximum");
// The cursor is 900px past the grip; release there.
s.mouse_button(x + 900.0, y, "LeftButton", false);
s.resolve();
assert!(
@@ -297,7 +259,7 @@ fn the_release_ends_the_resize_from_anywhere_on_screen() {
"FCF_StopResize ran"
);
// The falsifier: with the drag still live, walking back would shrink the window.
// With the drag still live, walking back would shrink the window.
s.mouse_move(x - 200.0, y);
assert_eq!(
width(&mut s),
@@ -307,9 +269,6 @@ fn the_release_ends_the_resize_from_anywhere_on_screen() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The window moves by a body drag, and only while unlocked.** A plain click is untouched — the
/// 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_tab_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -342,9 +301,8 @@ fn the_tab_drag_moves_an_unlocked_window_and_a_locked_one_stays_put() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The dragged window is **user-placed**, and `UIParent_ManageFramePositions` therefore leaves it
/// alone. Without that the next stance-bar show/hide would `ClearAllPoints` the window and drop it
/// back on the bottom-stack seat.
/// `UIParent_ManageFramePositions` skips a user-placed frame, the chat frames only outside simple
/// chat (`UIParent.lua:1690-1692`).
#[test]
fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -376,16 +334,12 @@ fn a_moved_window_is_user_placed_and_the_managed_pass_skips_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The geometry survives a relog** — the whole round trip, in one test: drag and resize the
/// shipped window, snapshot it through the engine seam, write the file text, read it back, and
/// seat it into a *fresh* VM whose windows are all on their authored anchors.
#[test]
fn the_geometry_round_trips_through_the_save_file() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = chat_ui();
s.run("FCF_SetLocked(ChatFrame1, nil)").unwrap();
// Resize from the bottom-right, then move the whole window.
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);
@@ -400,7 +354,6 @@ fn the_geometry_round_trips_through_the_save_file() {
assert_ne!(want.0, 430.0, "the resize happened");
assert_ne!(want.2, 32.0, "the move happened");
// The engine seam, then the file.
let saved = s.user_placed_layouts();
assert_eq!(saved.len(), 1, "one window was placed: {saved:?}");
assert_eq!(saved[0].name, "ChatFrame1");
@@ -411,7 +364,7 @@ fn the_geometry_round_trips_through_the_save_file() {
"the file expresses what the seam produced"
);
// A fresh VM — the relog. Everything starts on its authored anchors.
// A fresh VM is the relog: every window starts on its authored anchors.
let mut fresh = chat_ui();
assert_eq!(width(&mut fresh), 430.0);
fresh.restore_user_placed_layouts(read_back);
@@ -439,9 +392,7 @@ fn the_geometry_round_trips_through_the_save_file() {
);
}
/// The **grip art rides the window's own reveal**, which is what the reference's
/// `CHAT_FRAME_TEXTURES` is for: the eight pieces and the background are tinted and faded as one,
/// so the handles appear with the box and are invisible without it.
/// `CHAT_FRAME_TEXTURES` fades and tints the background and the eight grip pieces as one.
#[test]
fn the_grip_art_follows_the_windows_reveal_and_tint() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -477,9 +428,7 @@ fn the_grip_art_follows_the_windows_reveal_and_tint() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A drag in flight pins the chrome open.** The cursor moves during a resize, which keeps
/// resetting the stationary-hover delay — without the reference's own `or chatFrame.resizing`
/// clause the box would fade out from under the hand holding it.
/// `FCF_OnUpdate`'s `or chatFrame.resizing` clause keeps the box open with the cursor off it.
#[test]
fn a_drag_in_flight_holds_the_chrome_visible() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -520,8 +469,7 @@ fn a_drag_in_flight_holds_the_chrome_visible() {
);
}
/// The **blanket** `CHAT_LOCKED` switch overrides the per-window lock, both ways — the reference's
/// `FCF_Get_ChatLocked()` gate, which is the first line of both verbs.
/// `FCF_Get_ChatLocked()` is `FCF_Resize`'s first gate, ahead of the window's own lock.
#[test]
fn the_chat_locked_global_overrides_the_per_window_lock() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -546,9 +494,8 @@ fn the_chat_locked_global_overrides_the_per_window_lock() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **ChatFrame2 is not independently movable or resizable**, whatever its own lock says: it takes
/// its rect from ChatFrame1's corners, so a drag of its own would stretch it off the dock. The
/// reference's third gate — `isDocked and chatFrame ~= DEFAULT_CHAT_FRAME`.
/// A docked non-default window takes its rect from `ChatFrame1`, so `FCF_Resize`'s third gate
/// (`isDocked and chatFrame ~= DEFAULT_CHAT_FRAME`) refuses it.
#[test]
fn the_docked_combat_log_cannot_be_moved_or_resized_on_its_own() {
let _data = benilla_formats::wow_data_or_skip!();
+64 -230
View File
@@ -1,24 +1,15 @@
//! Shipped end-to-end tests for the chat window + input (decision 0084, the chat arc): the real
//! `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).
//! End-to-end tests of the chat windows and the input box on the stock `ChatFrame.xml` and
//! `FloatingChatFrame.xml`, driven as the app drives them.
use benilla_ui::script::{ExtractedQuad, QuadContent, UiScript};
use super::test_ui::load_ui as load_xml;
/// The full ChatFrame stack (fonts first, so the FontString's `inherits="ChatFontNormal"` resolves).
/// The chat stack, fonts first so `inherits="ChatFontNormal"` resolves.
fn chat_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
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`.)
// `FCF_Tab_OnClick` uses the dropdown kit, whose backdrop reads GameTooltip.lua's colour.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
@@ -26,14 +17,11 @@ fn chat_frame() -> UiScript {
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
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.
// UIParent's OnUpdate runs `FCF_OnUpdate`, the dock's driver, 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.
// `FCF_ValidateChatFramePosition` reads `MainMenuBar:GetHeight()`, so the bar loads first.
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
@@ -49,7 +37,6 @@ fn chat_frame() -> UiScript {
s
}
/// The color+alpha of the first Text quad whose text equals `t`.
fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
quads.iter().find_map(|q| match &q.content {
QuadContent::Text {
@@ -69,8 +56,7 @@ fn close(a: f32, b: f32) -> bool {
fn injected_lines_render_in_the_pinned_colors() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
// The app's feed passes 0..1 floats from the pinned type→color table; the seam quantizes them.
// SAY white (FFFFFF), SYSTEM yellow (FFFF00 — the GM-feedback color), LOOT green (00AA00).
// The app's color table as 0..1 floats: SAY FFFFFF, SYSTEM FFFF00, LOOT 00AA00.
s.add_chat_message("ChatFrame1", "[Tri] says: hi", 1.0, 1.0, 1.0);
s.add_chat_message("ChatFrame1", "You give 500 copper.", 1.0, 1.0, 0.0);
s.add_chat_message(
@@ -124,11 +110,8 @@ fn newest_line_sits_at_the_bottom() {
assert!(y("newer") < y("older"), "newest renders at the bottom");
}
/// The whole fade round trip as it reaches a real chat window: a line ramps down, a scroll brings
/// it back to full, the scrolled-up view then holds it there, and returning to the bottom lets it
/// ramp again. The re-arm half: every scroll entry reaches
/// `0x788b80` or the relayout's `0x788af0`; before it, a faded-out chat could not be recovered by
/// any input the client offers (director-reported, 2026-08-29).
/// A scroll re-arms a fading line to full and holds it there until the view is back at the
/// bottom: every scroll entry reaches the re-arm at `0x788b80` or the relayout's `0x788af0`.
#[test]
fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
benilla_formats::wow_data_or_skip!();
@@ -143,8 +126,7 @@ fn wheel_scroll_re_arms_the_fade_then_freezes_it() {
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}");
// 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).
// The 1.12 chat frame takes no wheel (`enableMouse="false"`), so this is the buttons' verb.
s.run("ChatFrame1:ScrollUp()").unwrap();
s.resolve();
let a2 = text_color(&s.extract(), "L1").expect("L1 still visible")[3];
@@ -169,8 +151,6 @@ fn input_editbox_enter_drains_the_typed_line() {
assert!(s.has_keyboard_focus(), "focus gates the world's keys");
s.char_input("/yell hi");
assert!(s.key_input("ENTER"), "the box consumes ENTER");
// The reference's own path: `ChatEdit_OnEnterPressed` → `ChatEdit_SendText` →
// `ChatEdit_ParseText` → `SendChatMessage("hi", "YELL", …)`.
let sends = s.take_chat_sends();
assert_eq!(
sends
@@ -197,10 +177,7 @@ fn input_escape_closes_without_submitting() {
assert!(!s.has_keyboard_focus(), "escape closes the box");
}
/// The shipped chat box takes plain arrows and history recall — end-to-end over the real XML.
/// Guards the exact in-game gap the director hit: the ref template's `ignoreArrows="true"` (which
/// benilla deliberately drops — plain arrows edit; the divergence note in ChatFrame.xml) would
/// leave LEFT/RIGHT consumed-but-dead here, and an unpushed history would leave Up/Down empty.
/// The stock chat box edits, and Up/Down walk its history and back to the draft.
#[test]
fn chat_box_arrows_edit_and_history_recalls() {
benilla_formats::wow_data_or_skip!();
@@ -208,9 +185,8 @@ fn chat_box_arrows_edit_and_history_recalls() {
let mut s = chat_frame();
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("ab");
// 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.
// The stock template's `ignoreArrows="true"` is AltArrowKeyMode: the app hands a plain arrow
// to the bindings (the character turns while you type), and the box still edits.
assert!(
s.editbox_alt_arrow_mode(),
"the stock template's ignoreArrows landed as AltArrowKeyMode on the focused box"
@@ -227,8 +203,6 @@ fn chat_box_arrows_edit_and_history_recalls() {
s.run("ChatFrameEditBox:SetText('')").unwrap();
s.char_input("/yell hi");
assert!(s.key_input("ENTER"));
// `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
@@ -240,10 +214,8 @@ fn chat_box_arrows_edit_and_history_recalls() {
assert!(s.focus_editbox("ChatFrameEditBox"));
s.char_input("dra");
s.editbox_action(EditAction::HistoryPrev);
// `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.
// `ChatEdit_AddHistory` filed "/y hi" (`ChatFrame.lua:1916-1937`); the recall's `OnTextSet`
// parse (l.2077-2079) turns it back into YELL with "hi".
assert!(
s.eval::<bool>(
"return ChatFrameEditBox.chatType == 'YELL' and ChatFrameEditBox:GetText() == 'hi'"
@@ -264,19 +236,15 @@ fn chat_box_arrows_edit_and_history_recalls() {
);
}
/// Decision 0843 — the chat body as a dismiss surface, the director's stuck-spell gesture end to
/// end through the shipped XML: a spell dragged out of the spellbook and RELEASED over the chat
/// keeps carrying (a drag release is never a click — 0218's byte-verified trigger), the follow-up
/// completed LEFT CLICK on the chat body dismisses it, and the same click leaves an ITEM payload
/// untouched (a silent item dismissal would be a destroy — only the world-drop popup offers that).
/// A drag released over the chat keeps carrying; the completed left click after it drops a spell,
/// never an item, which it would destroy. Deviation: the reference drops a spell on a left click
/// only over empty sky, which leaves no left-click way to drop one over ground.
#[test]
fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
benilla_formats::wow_data_or_skip!();
use benilla_ui::script::{
ContainerSlot, ContainerState, SpellBookState, SpellSlotView, SpellTabView,
};
// 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 [
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
@@ -322,7 +290,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
"OnDragStart picked the spell up"
);
// Release the DRAG over the chat body: keeps carrying (OnClick never fires on a drag).
// Release the drag over the chat body: OnClick never fires on a drag, so it keeps carrying.
let (cx, cy) = (200.0, 150.0); // inside ChatFrame1 (BOTTOMLEFT 32,85 + 430×120)
s.mouse_move(cx, cy);
s.mouse_button(cx, cy, "LeftButton", false);
@@ -331,7 +299,6 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
"a drag release over the chat keeps carrying"
);
// The completed click on the chat body dismisses the stuck spell.
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
assert!(s.errors().is_empty(), "{:?}", s.errors());
@@ -340,7 +307,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
"a chat click dismisses a spell payload"
);
// An ITEM payload survives the same click untouched.
// An item survives the same click.
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
@@ -385,17 +352,14 @@ 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
/// `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 reference byte
/// table (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` and [`crate::ui_chat::default_color`] both carry the reference's registry
/// (`.rdata 0x804710`): every kind agrees to the byte, and `id` is its registry slot.
#[test]
fn chat_type_info_matches_the_host_color_table() {
benilla_formats::wow_data_or_skip!();
use crate::ui_chat::{default_color, ChatEventKind as K};
/// Each modeled kind and its `ChatTypeInfo` key — the reference's own spellings.
/// Each modeled kind, its `ChatTypeInfo` key and its registry id.
const PAIRS: &[(&str, K, i64)] = &[
("SAY", K::Say, 1),
("PARTY", K::Party, 2),
@@ -448,17 +412,11 @@ fn chat_type_info_matches_the_host_color_table() {
(b * 255.0).round() as u8,
];
assert_eq!(got, default_color(*kind), r#"ChatTypeInfo["{name}"] color"#);
// `id` is GetChatTypeIndex's 1-based registry slot, asserted EXACTLY rather than as a
// range: four of these ship the same FFDBB7 and a range check would let a transposition
// inside that family through with both colors still matching.
// Asserted exactly: four of these share FFDBB7, so only the id catches a transposition.
assert_eq!(id, *want_id, r#"ChatTypeInfo["{name}"].id"#);
}
}
/// The table's *shape*, transcribed rather than derived: the reference's 105 keys, its five sticky
/// types (the set `ui_chat::edit`'s `SendType::sticky` already quotes), the two keys FrameXML
/// declares that the engine's color registry does not contain, and the ten boot-seeded channel
/// extras. An addon reads `.sticky` and `.id` as often as it reads the color.
#[test]
fn chat_type_info_has_the_references_shape() {
benilla_formats::wow_data_or_skip!();
@@ -477,11 +435,9 @@ fn chat_type_info_has_the_references_shape() {
.unwrap();
assert_eq!(sticky, "BATTLEGROUND GUILD PARTY RAID SAY");
// REPLY and COMBAT_ERROR are declared by FrameXML and absent from the engine's 94-entry
// registry, so GetChatTypeIndex answers 0 for both — but their COLORS differ, which is the
// trap this asserts. Nothing ever writes COMBAT_ERROR. REPLY is written by hand inside the
// UPDATE_CHAT_COLOR handler, which mirrors WHISPER into it (ChatFrame.lua l.1357-1365), so
// its end state is WHISPER's FF80FF. Seeding both white reads as symmetric and is wrong.
// REPLY and COMBAT_ERROR are FrameXML's own, absent from the engine's 94-entry registry, so
// both have id 0, but their colors differ: the `UPDATE_CHAT_COLOR` arm copies WHISPER's
// FF80FF into REPLY (`ChatFrame.lua:1357-1365`), and nothing overwrites COMBAT_ERROR's white.
for (name, want) in [
("REPLY", [255u8, 128, 255]),
("COMBAT_ERROR", [255, 255, 255]),
@@ -517,14 +473,8 @@ fn chat_type_info_has_the_references_shape() {
}
}
/// **Every event name we fire is a key the reference's own `ChatTypeInfo` carries.**
/// `ChatFrame_OnEvent` recovers the type with `strsub(event, 10)` and indexes
/// `ChatTypeInfo[type]` with it — a name that misses that table is a name whose colour, id and
/// sticky flag an addon cannot look up, so this is the check that our `CHAT_MSG_*` spellings are
/// the reference's and not ours.
///
/// Swept over `ChatEventKind::ALL` rather than a second hand-written list, so a kind added without
/// a matching table key fails here instead of shipping a name nothing can resolve.
/// `ChatFrame_OnEvent` looks a line's type up as `ChatTypeInfo[strsub(event, 10)]`
/// (`ChatFrame.lua:1370`), so every `CHAT_MSG_*` we fire must name one of its keys.
#[test]
fn fired_event_names_are_all_chat_type_info_keys() {
benilla_formats::wow_data_or_skip!();
@@ -544,14 +494,8 @@ fn fired_event_names_are_all_chat_type_info_keys() {
}
// ── The seven chat windows (NUM_CHAT_WINDOWS) ────────────────────────────────────────────────
//
// benilla shipped two windows against a constant the reference sets to seven. Everything below is
// a claim about the five that were missing, checked against the shipped `ChatFrame.xml` itself
// rather than against the loader's idea of it.
/// `NUM_CHAT_WINDOWS = 7` (ref ChatFrame.lua l.5) and every index it promises resolves to a real
/// `ScrollingMessageFrame`. _LazyPig walks exactly this loop and indexes the result with no nil
/// guard (`LazyPig.lua:1992`), so the constant without the frames is worse than neither.
/// `NUM_CHAT_WINDOWS` is 7 (`ChatFrame.lua:5`), and addons index all seven windows unguarded.
#[test]
fn every_window_num_chat_windows_promises_is_a_real_frame() {
benilla_formats::wow_data_or_skip!();
@@ -568,8 +512,7 @@ fn every_window_num_chat_windows_promises_is_a_real_frame() {
}
}
/// The corpus walk itself, verbatim from `_LazyPig/LazyPig.lua:1992` down to the unguarded
/// `getglobal(...):IsVisible()` that used to die at i=3.
/// The `_LazyPig` window walk, verbatim from `LazyPig.lua:1992`.
#[test]
fn the_lazypig_window_walk_survives_all_seven_indices() {
benilla_formats::wow_data_or_skip!();
@@ -587,16 +530,8 @@ fn the_lazypig_window_walk_survives_all_seven_indices() {
assert_eq!(visible, 1, "only the selected dock window is visible");
}
/// ChatFrame3..7 ship hidden and with no `isDocked`, the reference's own chat-cache state
/// (`DOCKED 0 / SHOWN 0`). This is not cosmetic: `Outfitter.lua:3099` reaches an UNGUARDED
/// `getglobal('ChatFrame'..i..'Tab'):GetText()` for any window that is visible *or* docked, and we
/// build no tabs past ChatFrame2Tab.
///
/// **ChatFrame1 and ChatFrame2 DO carry it**, and that is the half this test gained with the
/// move/resize arc: the reference's own gates read `chatFrame.isDocked` — `isDocked and chatFrame
/// ~= DEFAULT_CHAT_FRAME` is what stops the Combat Log being dragged out of the dock it is
/// anchored into — so the flag has a consumer now rather than being an advertisement. Both have
/// tabs (1575), so the Outfitter walk below is still safe.
/// ChatFrame3..7 ship hidden and undocked, the reference's default chat cache (`DOCKED 0`,
/// `SHOWN 0`); ChatFrame1 and 2 carry `isDocked`, which the stock dock code reads.
#[test]
fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
benilla_formats::wow_data_or_skip!();
@@ -617,7 +552,7 @@ fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
.unwrap();
assert!(!docked, "ChatFrame{i} carries no isDocked");
}
// And therefore the Outfitter walk never reaches a tab that does not exist.
// `Outfitter.lua:3099` reads the tab of every visible or docked window unguarded.
let ok: bool = s
.eval(
"for i = 1, NUM_CHAT_WINDOWS do\n\
@@ -634,9 +569,6 @@ fn the_undocked_windows_are_hidden_and_carry_no_is_docked() {
assert!(ok, "the Outfitter tab walk never touches a missing tab");
}
/// `GetChatWindowInfo`'s `shown` is not an independent opinion — it must agree with the frame the
/// shipped XML actually built, or an addon that trusts the getter and an addon that trusts the
/// frame will disagree about the same window. The drift guard between the Rust table and the XML.
#[test]
fn get_chat_window_info_shown_matches_the_shipped_frames() {
benilla_formats::wow_data_or_skip!();
@@ -644,10 +576,7 @@ fn get_chat_window_info_shown_matches_the_shipped_frames() {
for i in 1..=7 {
let agrees: bool = s
.eval(&format!(
// Both sides are normalised to a boolean before comparing: `IsShown` answers the
// NUMBER 1 or nil (1830), so comparing it straight against `shown ~= nil` compares
// a number with a boolean and is false for every window. This test is about the two
// AGREEING, not about either one's shape.
// `IsShown` answers 1 or nil, so both sides are compared as booleans.
"local _, _, _, _, _, _, shown = GetChatWindowInfo({i})\n\
return (shown ~= nil) == (ChatFrame{i}:IsShown() ~= nil)"
))
@@ -659,9 +588,6 @@ fn get_chat_window_info_shown_matches_the_shipped_frames() {
}
}
/// A named debug sink is a real ring of its own: `ChatFrame3:AddMessage` (IgniteStatus does this
/// seven times, TipBuddy once, Radar guarded, and AceDebug's `debugFrame` stores the frame) lands
/// in ChatFrame3 and nowhere near the window the player is reading.
#[test]
fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
benilla_formats::wow_data_or_skip!();
@@ -677,7 +603,6 @@ fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
s.eval::<i64>("return ChatFrame1:GetNumMessages()").unwrap(),
1
);
// Hidden, so nothing of it reaches the screen.
s.resolve();
assert!(
text_color(&s.extract(), "Radar: debug").is_none(),
@@ -685,17 +610,8 @@ fn a_line_added_to_chat_frame3_lands_in_chat_frame3_only() {
);
}
/// `FCF_SelectDockFrame(frame)` — the corpus idiom (CustomNameplates.lua:69, Roid-Macros
/// Utility.lua:40) is "un-hide the default chat frame before printing into it". It takes a FRAME,
/// where `BenillaFCF_SelectDock` takes a dock id.
///
/// **It used to RAISE on an undocked window and no longer does** (1714). The raise was right for a
/// client whose dock was the literal `for i = 1, 2`: "select ChatFrame5" had no meaning, and
/// answering with silence would have left the caller's next `AddMessage` in a still-hidden window.
/// With a real `DOCKED_CHAT_FRAMES` the reference's own answer applies — the assignment, then
/// `FCF_DockUpdate` hiding every docked window (none of them is the selection) and leaving the
/// undocked one exactly as it was. Not a nice answer, but the client's; and neither corpus caller
/// reaches it, since both pass `DEFAULT_CHAT_FRAME`.
/// `FCF_SelectDockFrame` takes a frame; for an undocked one the reference assigns the selection and
/// `FCF_DockUpdate` hides every docked window, leaving the undocked one as it was.
#[test]
fn fcf_select_dock_frame_selects_by_frame_and_leaves_an_undocked_one_alone() {
benilla_formats::wow_data_or_skip!();
@@ -714,9 +630,6 @@ fn fcf_select_dock_frame_selects_by_frame_and_leaves_an_undocked_one_alone() {
"the corpus guard brings the default frame back"
);
// An undocked window: no raise, and the reference's consequence — the dock's members all
// hide, because none of them is the selection, and ChatFrame5 is untouched (still hidden,
// since nothing showed it).
let shown5_before: bool = s
.eval("return ChatFrame5:IsShown() and true or false")
.unwrap();
@@ -739,21 +652,14 @@ fn fcf_select_dock_frame_selects_by_frame_and_leaves_an_undocked_one_alone() {
);
}
/// **The idle dock writes nothing** (decision 1396). `FCF_OnUpdate`'s apply block used to run
/// unconditionally — ~24 `getglobal`s, two table builds and ~20 `SetAlpha` calls every frame of
/// every session, writing the values that were already there, at 36 µs/frame (1395 measured it as
/// the largest single handler in the client).
///
/// The probe is a sentinel the applier can never write: it only ever writes `{0, 0.5, 1.0} × reveal`,
/// and `reveal` is pinned at 0 with the cursor away from the dock. If the gate is removed, the very
/// next tick overwrites 0.42 with 0 and this goes red.
/// With the cursor away, `FCF_OnUpdate` writes no tab alpha: a sentinel alpha survives ten ticks.
#[test]
fn an_idle_dock_stops_rewriting_the_tab_alpha_every_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_frame();
s.mouse_move(1500.0, 850.0); // far from the dock: no hover, so `reveal` stays 0
s.mouse_move(1500.0, 850.0); // far from the dock, so no hover
for _ in 0..8 {
s.tick(0.016); // let the label measurements land and the applier reach its resting write
s.tick(0.016); // let the dock settle
s.resolve();
}
@@ -771,8 +677,7 @@ fn an_idle_dock_stops_rewriting_the_tab_alpha_every_frame() {
);
}
/// The control for the guard above: the gate must not cost the reveal it is gating. A stationary
/// cursor over the dock for longer than `CHAT_TAB_SHOW_DELAY` fades the selected tab to full.
/// The control: a stationary hover past `CHAT_TAB_SHOW_DELAY` still fades the selected tab in.
#[test]
fn hovering_the_dock_still_reveals_the_tabs() {
benilla_formats::wow_data_or_skip!();
@@ -805,15 +710,8 @@ fn hovering_the_dock_still_reveals_the_tabs() {
);
}
/// **A view at the bottom writes nothing** — `ChatFrame_OnUpdate`'s at-bottom branch used to call
/// `flash:Hide()` and zero `flashTimer` every frame; the 1.12 reference gates both behind
/// `flash:IsVisible()` (1396's residual item 3, now restored — our `flashTimer = 0` rides inside
/// the reference's gate, so a return-to-bottom during a hidden blink phase keeps the residual
/// phase, a divergence that costs nothing at rest).
///
/// The probe is a sentinel the gated branch can only zero: `flashTimer = 0.42` with the flash
/// hidden survives ten ticks. The controls: scrolled up, the bottom-button blink still runs its
/// 0.5s toggle; and scrolling back while the flash is LIT still hides it and zeroes the timer.
/// At the bottom, `ChatFrame_OnUpdate` only hides a lit flash and returns
/// (`ChatFrame.lua:1508-1513`), so a sentinel `flashTimer` survives; scrolled up, it blinks.
#[test]
fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
benilla_formats::wow_data_or_skip!();
@@ -841,8 +739,6 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
.unwrap(),
"the flash stays hidden at rest"
);
// 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();
@@ -859,8 +755,7 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
.unwrap(),
"returning to the bottom hides a lit flash"
);
// …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`).
// The residual phase stands: the at-bottom arm never zeroes the timer.
let timer: f64 = s.eval("return ChatFrame1.flashTimer").unwrap();
assert!(
(timer - 0.22).abs() < 1e-6,
@@ -869,20 +764,9 @@ fn a_chat_view_at_the_bottom_stops_rewriting_the_flash() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Selecting the Combat Log must not stop the dock.** The falsification for decision 1588.
///
/// `FCF_OnUpdate` is the dock's whole driver — the hover reveal, both tabs' alphas, the label
/// settle, the whisper blink — and it used to ride `ChatFrame1`'s own `OnUpdate`.
/// `BenillaFCF_SelectDock(2)` hides ChatFrame1, and a hidden frame gets no `OnUpdate`
/// ([`UiScript::tick`] runs only effectively-visible frames, which is the reference's rule), so the
/// driver died at the exact moment the feature it drives was first used: the tabs froze mid-write,
/// still showing General as the selected one. That is what the director saw and reported, and every
/// test we had passed straight through it — including this file's two dock tests, because both only
/// ever exercise window 1.
///
/// The trap this walks around: calling `FCF_OnUpdate()` by hand — which the first live probe did,
/// and which every earlier test does — bypasses the visibility gate and reports healthy alphas on a
/// dead driver. So this drives the clock and nothing else.
/// Selecting the Combat Log hides ChatFrame1, and a hidden frame gets no OnUpdate, but
/// `FCF_OnUpdate` runs from UIParent's (`UIParent.xml:19`), so the dock keeps its driver. Only the
/// clock is driven: calling `FCF_OnUpdate()` by hand would skip that visibility gate.
#[test]
fn selecting_the_combat_log_keeps_the_dock_driver_running() {
benilla_formats::wow_data_or_skip!();
@@ -928,8 +812,6 @@ fn selecting_the_combat_log_keeps_the_dock_driver_running() {
);
}
/// The other half of the same freeze, and the one a screenshot cannot show: with the driver dead,
/// `reveal` never decayed either, so the chat box and both tabs stayed lit after the cursor left.
#[test]
fn the_dock_still_fades_out_with_the_combat_log_selected() {
benilla_formats::wow_data_or_skip!();
@@ -970,10 +852,6 @@ fn the_dock_still_fades_out_with_the_combat_log_selected() {
);
}
/// The Combat Log window has the scroll column too — the third part of window 2 that was simply
/// never authored (after the tab, 1575, and the border art, 1579). It is a behavioural check, not
/// a name check: scroll up and the bottom button's flash must start blinking on window 2's own
/// button, which only happens if window 2 both HAS the button and runs `ChatFrame_OnUpdate`.
#[test]
fn the_combat_log_window_runs_its_own_bottom_button_blink() {
benilla_formats::wow_data_or_skip!();
@@ -1001,12 +879,6 @@ fn the_combat_log_window_runs_its_own_bottom_button_blink() {
);
}
/// **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!();
@@ -1039,8 +911,6 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() {
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!(
@@ -1051,12 +921,10 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() {
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.
// A row's label is the Button's own text (`button:SetText`), with no `…Button1Text`.
"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(),
@@ -1068,8 +936,7 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() {
.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).
// `ChatMenu_Say` sets the box's type directly, typing no slash (`ChatFrame.lua:2245-2255`).
assert!(
s.eval::<bool>(
"return ChatFrameEditBox:IsVisible() and ChatFrameEditBox.chatType == 'SAY'"
@@ -1079,11 +946,8 @@ fn the_chat_menu_builds_its_rows_on_the_references_kit() {
);
}
/// **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.
/// A glass window (chat cache `COLOR 0 0 0 0`) rests its plate at alpha 0, and `FCF_OnUpdate`
/// fades it to `DEFAULT_CHATFRAME_ALPHA` and back on every hover (`FloatingChatFrame.lua:873-916`).
#[test]
fn a_glass_windows_plate_fades_in_on_every_hover() {
benilla_formats::wow_data_or_skip!();
@@ -1129,7 +993,6 @@ fn a_glass_windows_plate_fades_in_on_every_hover() {
}
}
/// 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() {
benilla_formats::wow_data_or_skip!();
@@ -1162,8 +1025,7 @@ fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() {
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.
// The plate as the renderer gets it: a black Background quad over ChatFrame1, faded.
let plates: Vec<(Option<benilla_ui::layout::Rect>, f32, Option<[f32; 4]>)> = s
.extract()
.iter()
@@ -1216,19 +1078,9 @@ fn a_glass_windows_plate_fades_in_on_every_hover_under_the_full_manifest() {
}
}
/// **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.
/// Leaving and re-entering within `CHAT_FRAME_FADE_TIME` traps the stock Lua (the tab fade's
/// finished callback nils `oldAlpha` under a live hover): the bare chat stack must trap, and the
/// shipped manifest, with `install_chat_plate_guard`, must not.
#[test]
fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
benilla_formats::wow_data_or_skip!();
@@ -1246,14 +1098,13 @@ fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
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.
// 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);
@@ -1317,24 +1168,9 @@ fn a_quick_exit_and_reentry_keeps_the_plates_hover_fade() {
);
}
/// **`/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.
/// The stock `/afk` and `/dnd` call `SendChatMessage(msg, "AFK")` or `"DND"`
/// (`ChatFrame.lua:1005-1011`), and the token must resolve to `CMSG_MESSAGECHAT` type `0x14` or
/// `0x15`: the server toggles the flag. Either half alone passes with the seam between them broken.
#[test]
fn the_stock_afk_and_dnd_commands_reach_the_wire() {
benilla_formats::wow_data_or_skip!();
@@ -1351,13 +1187,13 @@ fn the_stock_afk_and_dnd_commands_reach_the_wire() {
(sends[0].text.clone(), sends[0].chat_type.clone())
};
// Half one — the stock `SlashCmdList` body, verbatim: `SendChatMessage(msg, "AFK")`.
// Half one: the stock `SlashCmdList` body sends the token.
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.
// Half two: the token resolves to a wire kind.
assert_eq!(
SendType::from_token(&token).map(SendType::wire),
Some(ChatKind::Afk),
@@ -1371,9 +1207,8 @@ fn the_stock_afk_and_dnd_commands_reach_the_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".
// A bare `/afk` sends an empty body, which vmangos reads as a toggle; a non-empty one sets
// the flag and the auto-reply (`ChatHandler.cpp:611-630`).
let (text, token) = typed("/afk");
assert_eq!((text.as_str(), token.as_str()), ("", "AFK"));
assert_eq!(
@@ -1382,8 +1217,7 @@ fn the_stock_afk_and_dnd_commands_reach_the_wire() {
"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.
// The control: a token with no send still answers None.
assert!(SendType::from_token("NOT_A_CHAT_TYPE").is_none());
assert!(
SendType::from_token("TEXT_EMOTE").is_none(),
@@ -1,30 +1,18 @@
//! **The cinematic frame's key-down consumption** — what makes a fly-by something you *watch*.
//!
//! `CinematicFrame` is fullscreen, keyboard-enabled, and carries an `OnKeyDown` that answers
//! ESCAPE. In the reference that is enough to swallow **every** key while it is up, because the
//! key-down walk's gate is EXISTENCE, not handling (`0x76b7d0`): a shown keyboard frame with the
//! slot set consumes the key whatever its script does with it, and a 1.12 handler has no way to
//! signal "not handled" (`0x76ba25`).
//!
//! The reference's own Lua is the proof, and it is why these tests exist: that same `OnKeyDown` 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.
//!
//! benilla had the walk (decision 1319) but fed it only ten key names, so ESCAPE was consumed and
//! `W` was not — and the player could walk around underneath their own intro cinematic.
//! The cinematic frame swallows every key while it is up: the key-down walk gates on a shown
//! keyboard frame with the slot set, not on handling (`0x76b7d0`), and a 1.12 handler cannot
//! decline a key (`0x76ba25`), so the stock `OnKeyDown` re-runs `SCREENSHOT` by hand.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// The frame tree a cinematic actually runs against: `UIParent` (whose `ShowUIPanel` the frame's
/// `CINEMATIC_START` arm calls) and the cinematic frame itself.
/// The cinematic frame over `UIParent`, whose `ShowUIPanel` its `CINEMATIC_START` arm calls.
fn ui_with_the_cinematic_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
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.lua"); // StaticPopup's money row
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
@@ -37,24 +25,20 @@ fn ui_with_the_cinematic_frame() -> UiScript {
s
}
/// Show the cinematic frame the way the engine does — the `CINEMATIC_START` edge `feed_ui` fires.
/// The engine's `CINEMATIC_START` edge, as `feed_ui` fires it.
fn start_cinematic(s: &mut UiScript) {
s.set_in_cinematic(true);
s.fire_event("CINEMATIC_START", vec![]);
s.resolve();
}
/// With a fly-by on screen, the movement keys are the cinematic frame's, not the world's.
///
/// `frame_key_input` returning `true` is exactly the consumption that suppresses the key's binding
/// and the gameplay readers ([`super::UiKeyboardCapture`]) — so this *is* the assertion that you
/// cannot walk during a cinematic.
/// `frame_key_input` returning true keeps the key from its binding and the gameplay readers
/// ([`super::UiKeyboardCapture`]).
#[test]
fn a_playing_cinematic_swallows_the_movement_keys() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_with_the_cinematic_frame();
// Before it starts, the frame is hidden and declines: the world keeps its keys.
for key in ["W", "A", "S", "D", "SPACE", "1"] {
assert!(
!s.frame_key_input(key),
@@ -77,12 +61,6 @@ fn a_playing_cinematic_swallows_the_movement_keys() {
}
}
/// The two keys the reference deliberately keeps working, and the third it does not.
///
/// ESCAPE ends the shot; the screenshot binding is re-run by hand from inside the handler
/// (`RunBinding("SCREENSHOT")`) precisely *because* consumption is unconditional. Both are
/// consumed at the walk either way — "keeps working" means the frame's own script acts on them,
/// never that they fall through.
#[test]
fn escape_is_consumed_and_acted_on_rather_than_falling_through() {
benilla_formats::wow_data_or_skip!();
@@ -101,9 +79,7 @@ fn escape_is_consumed_and_acted_on_rather_than_falling_through() {
);
}
/// The host feed itself: every key the walk is supposed to carry must actually *have* a name, or
/// the delivery in [`super::input`] is a silent no-op for it. This is the half that was missing —
/// the walk was faithful, the feed was ten keys wide.
/// A key with no name is silently never delivered to a keyboard frame ([`super::input`]).
#[test]
fn the_host_has_a_reference_name_for_the_keys_it_now_delivers() {
use crate::bindings::chord::key_token;
@@ -126,18 +102,11 @@ fn the_host_has_a_reference_name_for_the_keys_it_now_delivers() {
}
}
/// **The one key a cinematic gives back.** The module doc above says the reference's `OnKeyDown`
/// calls `RunBinding("SCREENSHOT")` by hand *because* the walk's gate is existence — and benilla
/// shipped that transcribed line against a `RunBinding` that did not exist in the whole codebase.
/// Every press of the screenshot key during a fly-by raised `attempt to call global 'RunBinding'`
/// and took no picture, and the three tests above stayed green through all of it because they only
/// ever asked about keys the frame *swallows*.
#[test]
fn the_screenshot_key_is_handed_back_to_its_binding() {
benilla_formats::wow_data_or_skip!();
let mut s = ui_with_the_cinematic_frame();
// The binding table the passthrough reads: `GetBindingKey("SCREENSHOT")` has to answer, or the
// arm is skipped and the test proves nothing.
// `GetBindingKey("SCREENSHOT")` must answer, or the handler's screenshot arm is skipped.
s.register_bindings(&crate::bindings::registry_commands());
s.seed_binding_set(1, None);
s.load_binding_set(1);
@@ -151,13 +120,10 @@ fn the_screenshot_key_is_handed_back_to_its_binding() {
start_cinematic(&mut s);
let _ = s.take_keybind_requests();
// Consumed like every other key — the frame is up, so the binding layer must not also see it.
assert!(
s.frame_key_input(&key),
"the frame consumes {key} like any other key"
);
// …and handed straight back to its command by hand. This is the whole point of the arm: the
// request is what the host runs, and an error here means no screenshot.
assert_eq!(
s.take_keybind_requests(),
vec![benilla_ui::script::keybind::KeybindRequest::Run(
@@ -166,24 +132,13 @@ fn the_screenshot_key_is_handed_back_to_its_binding() {
"the screenshot key must reach its binding through RunBinding"
);
// ESCAPE is still the skip, and still queues nothing on the binding channel.
// ESCAPE still skips, and queues nothing on the binding channel.
assert!(s.frame_key_input("ESCAPE"));
assert!(s.take_keybind_requests().is_empty());
}
/// **The HUD hide, end to end — and the frame that must survive it.**
///
/// This is the chain decision 1734 restored, and every link was broken until it did:
/// `CINEMATIC_START` → `CinematicFrame`'s arm calls `ShowUIPanel` → its `area = "full"` row routes
/// to `SetFullScreenFrame` → which hides `UIParent` → which cascades to every frame declaring
/// `parent="UIParent"`. Before, `SetFullScreenFrame` had that line dropped, and there was almost
/// nothing parented to cascade to; the engine hid the HUD with `UiHidden` instead, and paid for it
/// with a mouse that could not find the cinematic frame.
///
/// It replaces a log line. 1699 verified the takeover by watching `cinematic: HUD hidden for
/// playback` go past, because `UiHidden` hides at the *draw* and leaves every widget answering
/// `IsVisible() == true` — the predicate was useless. Hiding through the real cascade makes
/// `IsVisible` the honest question again, so the check is an assertion instead of a log.
/// `ShowUIPanel`'s `area = "full"` row (`UIParent.lua:31`) routes to `SetFullScreenFrame`, which
/// hides `UIParent` and so every frame parented to it; `CinematicFrame` declares no parent.
#[test]
fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
benilla_formats::wow_data_or_skip!();
@@ -194,8 +149,7 @@ fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
== 1
};
// A shown child of UIParent stands in for the HUD: `UiPanels.xml` gives us one without
// dragging the whole action bar in, and what is under test is the cascade, not which frame.
// `StaticPopup1`, a shown child of `UIParent`, stands in for the HUD.
s.eval::<i64>("StaticPopup1:Show() return 0").unwrap();
s.resolve();
assert!(visible(&s, "UIParent"), "the HUD's parent starts visible");
@@ -227,25 +181,14 @@ fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
assert!(visible(&s, "StaticPopup1"), "with the child that was up");
}
/// **The screenshot confirmation survives the HUD hide — because the reference seats it outside
/// the HUD.** `WorldFrame.xml`'s own header states the law in one line: *"Children of the world
/// frame are visible even when the UI is turned off."* `ScreenshotStatus` is declared inside that
/// frame, and the case that needs it is this file's: `CinematicFrame`'s `OnKeyDown` hands the
/// SCREENSHOT key back to its binding by hand (1701) precisely so a fly-by can be captured, so the
/// one moment the confirmation is most certainly wanted is the one where `UIParent` is hidden.
///
/// Ours sat on `UIParent` until decision 1757, on a note that read the two seats as equivalent
/// because both frames are full-screen and their CENTERs coincide — true of the geometry, and
/// silent about the hide.
/// `ScreenshotStatus` is a child of `WorldFrame`, whose children stay visible with the UI hidden
/// (`WorldFrame.xml`).
#[test]
fn the_screenshot_confirmation_shows_during_a_cinematic() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
// The UI loads on world entry, so a player always exists by then.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -269,7 +212,7 @@ fn the_screenshot_confirmation_shows_during_a_cinematic() {
start_cinematic(&mut s);
assert_eq!(visible(&s, "UIParent"), 0, "the fly-by hid the HUD");
// The engine's own report of a finished capture (`crate::screenshot` → SCREENSHOT_SUCCEEDED).
// The engine's report of a finished capture (`crate::screenshot`).
s.fire_event("SCREENSHOT_SUCCEEDED", vec![]);
s.resolve();
assert_eq!(
@@ -1,19 +1,11 @@
//! 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
//! library, so its block below is not one addon's idiom but the path every colour option in every
//! Ace2 config menu takes. Every test here therefore enters from Lua exactly as they do; the
//! Dewdrop and AceConsole sequences are transcribed from corpus copies, cited per test.
//! The stock `ColorPickerFrame.xml` and the dropdown's colour-swatch row, driven from Lua the way
//! addons drive them; the Dewdrop-2.0 and AceConsole-2.0 sequences are shaped on corpus copies.
use benilla_ui::script::{QuadContent, UiScript};
use super::test_ui::load_ui as load_xml;
/// The manifest prefix the picker needs: fonts, the panel manager (`ShowUIPanel`/`HideUIPanel`,
/// `UISpecialFrames`, the ESC chain), the shared widget kit (`GameMenuButtonTemplate`), then the
/// window. `UIDropDownMenu.xml` rides along because `CloseMenus` and the swatch row live there.
/// The files the picker needs, in manifest order, ending with the window.
fn picker() -> UiScript {
let mut s = UiScript::new().unwrap();
for file in [
@@ -39,29 +31,19 @@ fn picker() -> UiScript {
s
}
/// What the widget hands back after being *set* to `(r, g, b)` — the client's whole colour law, run
/// through the engine's own transcription of it rather than restated here.
///
/// It is emphatically **not** the identity, and not a simple quantize either: `SetColorRGB` rounds
/// half-up into bytes and stores HSV floats, while every read path floors, so 9.75 % of colours come
/// back a step low on one channel (`SetColorRGB 0x78eae0`, the read-back floor `0x7bbec0`;
/// measured exhaustively over all 256³). `benilla_ui::script::colorselect`'s own tests pin that law
/// against the reference's measured witnesses; these tests use it as a given so they stay about the
/// Dewdrop plumbing.
/// What the widget reads back after being set to `(r, g, b)`: `SetColorRGB` (`0x78eae0`) rounds
/// into bytes and stores HSV, the read-back (`0x7bbec0`) floors, so 9.75% of colours come back a
/// step low on one channel.
fn after_round_trip(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
let mut cs = benilla_ui::widget::ColorSelectState::default();
cs.set_rgb(r, g, b);
cs.rgb_f64()
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The window's own API surface
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The window's own API surface ─────────────────────────────────────────────────────────────────
/// The four names addons address by string all exist, are the right kinds, and the two buttons'
/// `OnClick` is fetchable — which is not decoration: `AceConsole-2.0.lua` l.1402 does
/// `ColorPickerOkayButton:GetScript("OnClick")` and refuses to install its colour option at all if
/// `ColorPickerOkayButton` is nil.
/// The four names addons address exist, and both buttons' `OnClick` is fetchable:
/// `AceConsole-2.0.lua:1402` wraps the Okay button's handler, and skips its option without it.
#[test]
fn the_named_pieces_exist_and_their_scripts_are_fetchable() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -85,21 +67,15 @@ fn the_named_pieces_exist_and_their_scripts_are_fetchable() {
"{button}'s OnClick is not fetchable"
);
}
// Born hidden, like the reference's `hidden="true"`.
assert!(!s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `SetColorRGB` paints the preview swatch through `OnColorSelect`, and `GetColorRGB` answers the
/// client's quantized colour. The swatch is the ONLY thing in the window that shows the colour, and
/// nothing but this handler ever writes it.
///
/// The assertion goes through `extract()` on purpose. `ColorSwatch:SetTexture(r, g, b)` is the
/// *solid-colour* form of SetTexture — it replaces the texture, it does not tint one — so
/// `GetVertexColor` still reads the `<Color>` the XML declared (1,1,1,1) and would have "passed"
/// this test while the swatch drew nothing. What the renderer receives is the only honest witness.
/// `SetColorRGB` paints the swatch through `OnColorSelect` (`ColorPickerFrame.xml:171-176`), and
/// `GetColorRGB` reads back the quantized colour. Checked on the draw list: `SetTexture(r, g, b)`
/// replaces the texture with a solid colour, so `GetVertexColor` still reads the XML's white.
#[test]
fn set_color_rgb_paints_the_swatch_and_reads_back_the_widgets_colour() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -128,16 +104,13 @@ fn set_color_rgb_paints_the_swatch_and_reads_back_the_widgets_colour() {
);
}
/// `hasOpacity` is what decides whether the window has an opacity slider at all — and the window's
/// WIDTH with it. Both arms of the reference's `OnShow` (l.161-170).
/// `hasOpacity` shows the slider and widens the window to 365 (`ColorPickerFrame.xml:161-170`).
#[test]
fn has_opacity_shows_the_slider_and_widens_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = picker();
// Without it: no slider, the narrow window. (`GetWidth` reports the *resolved* rect, so the
// OnShow's SetWidth needs a resolve pass before it is readable — the same round-trip every
// geometry assertion in this file set makes.)
// Without it: no slider, the narrow window. `GetWidth` reads the resolved rect.
s.run("ColorPickerFrame.hasOpacity = nil ShowUIPanel(ColorPickerFrame)")
.unwrap();
s.resolve();
@@ -169,8 +142,7 @@ fn has_opacity_shows_the_slider_and_widens_the_window() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Moving the slider runs `opacityFunc` on every change — the live-preview half of the contract,
/// and the reference's own `<OnValueChanged>` (l.146-152).
/// Every slider change runs `opacityFunc` (`ColorPickerFrame.xml:147-151`).
#[test]
fn the_opacity_slider_drives_opacity_func_on_every_change() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -192,19 +164,10 @@ fn the_opacity_slider_drives_opacity_func_on_every_change() {
assert_eq!(s.eval::<f64>("return seen[2]").unwrap(), 0.75);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Dewdrop-2.0 — the sequence 64 corpus files share
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── Dewdrop-2.0's colour sequence ────────────────────────────────────────────────────────────────
/// The `Dewdrop-2.0.lua` block verbatim in shape (a corpus copy, l.425-465): set `func` to a closure
/// that reads `GetColorRGB()` + `1 - OpacitySliderFrame:GetValue()` and calls the addon's setter
/// with the closed-over arg prefix, mirror it into `opacityFunc`, set `opacity = 1 - this.opacity`,
/// `SetColorRGB`, capture the old values into `cancelFunc`, `ShowUIPanel`.
///
/// Two things this proves beyond "it runs": the setter fires **at `SetColorRGB` time**, before the
/// window is even shown (the reference's own behaviour — `func` is a live preview, and it is why
/// `hasOpacity`/`opacity` are assigned before the colour), and clicking **Okay** commits with the
/// slider's final value.
/// `Dewdrop-2.0.lua`'s colour-swatch `OnClick` in shape: a `func` closure over the addon's setter,
/// mirrored into `opacityFunc`, then `SetColorRGB`, a `cancelFunc` over the old values, the show.
fn dewdrop_open(s: &UiScript, r: f64, g: f64, b: f64, opacity: f64) {
s.run(&format!(
r#"
@@ -238,8 +201,7 @@ fn dewdrop_open(s: &UiScript, r: f64, g: f64, b: f64, opacity: f64) {
.unwrap();
}
/// **Okay commits.** The Dewdrop sequence, then the OK button: the addon's setter receives the
/// quantized colour and the slider-derived alpha, and the window closes.
/// Okay: the setter gets the quantized colour and the slider's alpha, and the window closes.
#[test]
fn the_dewdrop_sequence_commits_on_okay() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -247,23 +209,19 @@ fn the_dewdrop_sequence_commits_on_okay() {
dewdrop_open(&s, 0.1, 0.5, 0.9, 0.25);
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The live preview has already run TWICE before the window is even on screen, and both are the
// reference's own: `SetColorRGB` fires `OnColorSelect` → `func()`, and then `OnShow`'s
// `OpacitySliderFrame:SetValue(this.opacity)` moves the slider off its `defaultValue="1"` and
// fires `OnValueChanged` → `opacityFunc()`, which Dewdrop has aliased to the same closure.
// Pinned rather than glossed: an addon whose `func` writes through to the server sees both.
// The preview ran twice before the window shows: `SetColorRGB` fires `OnColorSelect` into
// `func`, then `OnShow`'s `SetValue` fires `OnValueChanged` into `opacityFunc`, the same one.
assert_eq!(s.eval::<usize>("return table.getn(applied)").unwrap(), 2);
assert!(s
.eval::<bool>("return ColorPickerFrame:IsVisible()")
.unwrap());
// The window came up with the slider at `1 - opacity`, which is what OnShow seeded.
// OnShow seeded the slider at `1 - opacity`.
assert_eq!(
s.eval::<f64>("return OpacitySliderFrame:GetValue()")
.unwrap(),
0.75
);
// The player drags the opacity down, then accepts.
s.run("OpacitySliderFrame:SetValue(0.4)").unwrap();
s.run("ColorPickerOkayButton:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
@@ -280,16 +238,14 @@ fn the_dewdrop_sequence_commits_on_okay() {
"the closed-over arg prefix is preserved"
);
assert_eq!((r, g, b), after_round_trip(0.1, 0.5, 0.9));
// 1e-6, not exact: a Slider holds `f32` (the client's `CSimpleSlider` does too), so
// `1 - GetValue()` is `1 - f32(0.4)` = 0.59999999…, not the literal 0.6.
// A Slider holds `f32`, as the client's `CSimpleSlider` does, so this is not exactly 0.6.
assert!(
(a - 0.6).abs() < 1e-6,
"alpha is 1 - the slider's 0.4, got {a}"
);
}
/// **Cancel restores.** The half people get wrong: Cancel must put back the colour the addon came
/// in with, and it does it by *calling `cancelFunc`* — the window has no memory of its own.
/// Cancel restores the addon's pre-open colour through `cancelFunc` (`ColorPickerFrame.xml:71-73`).
#[test]
fn cancel_restores_the_previous_colour_through_cancel_func() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -303,15 +259,12 @@ 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");
// 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.
// Not exact: the slider's `valueStep="0.01"` rebuilds the value as `n * step + min`.
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();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(!s
@@ -328,10 +281,10 @@ fn cancel_restores_the_previous_colour_through_cancel_func() {
);
}
/// ESC cancels too, and that is a deviation working as designed: the reference hooks `<OnKeyDown>`
/// on the frame, this engine routes the press through `ToggleGameMenu`'s chain (UiPanels.xml), and
/// the rung cancel-CLICKS rather than hiding — so the colour comes back and the game menu does not
/// open behind it.
/// The ESC ladder (`ToggleGameMenu`) hides the picker as a `UISpecialFrames` row
/// (`UIParent.lua:52-55`), a bare hide that leaves the previewed colour. `cancelFunc` runs from
/// the Cancel button and the frame's own ESC `OnKeyDown` (`ColorPickerFrame.xml:177-184`), never
/// from the ladder.
#[test]
fn escape_hides_the_picker_and_cancel_is_what_reverts() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -344,11 +297,6 @@ fn escape_hides_the_picker_and_cancel_is_what_reverts() {
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();
@@ -357,7 +305,6 @@ fn escape_hides_the_picker_and_cancel_is_what_reverts() {
(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
@@ -366,10 +313,8 @@ fn escape_hides_the_picker_and_cancel_is_what_reverts() {
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
/// it with one that calls the original and then does the addon's own commit. If `GetScript` returned
/// nil, or `SetScript` did not take, the addon's arm would never run — and 20+ corpus addons ship
/// this library.
/// `AceConsole-2.0.lua:1402-1406` in shape: the Okay button's handler, fetched with `GetScript`
/// and replaced through `SetScript` by one that calls it first.
#[test]
fn ace_console_can_chain_the_okay_buttons_onclick() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -403,10 +348,8 @@ fn ace_console_can_chain_the_okay_buttons_onclick() {
);
}
/// ESC's other path: `CloseWindows` walks `UISpecialFrames`, and `ColorPickerFrame` is in it now
/// (decision 1206 declined to seed it only because we shipped no such frame). This is the plain
/// `Hide` the reference does on that path — no cancel — reached when something else closes the
/// world's windows.
/// `CloseWindows` walks `UISpecialFrames`, which lists `ColorPickerFrame` (`UIParent.lua:52-55`),
/// and hides it plainly, with no cancel.
#[test]
fn the_picker_is_a_uispecialframe_and_close_windows_puts_it_away() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -432,16 +375,11 @@ fn the_picker_is_a_uispecialframe_and_close_windows_puts_it_away() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The dropdown's colour-swatch row — the other half of the same feature
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The dropdown's colour-swatch row ─────────────────────────────────────────────────────────────
/// A menu row with `hasColorSwatch` shows its square tinted to `r/g/b`, and clicking the square is
/// what opens the picker — seeding `func`/`hasOpacity`/`opacity`/`cancelFunc`, the colour, AND
/// `previousValues`, which is captured here and nowhere else. Then Cancel gets it back.
///
/// The info table is the reference's own documented shape (`UIDropDownMenu.lua` l.114-125) and the
/// exact one FloatingChatFrame's colour rows use.
/// A `hasColorSwatch` row (fields per `UIDropDownMenu.lua:114-125`) shows its square in `r/g/b`;
/// clicking it opens the picker through `UIDropDownMenuButton_OpenColorPicker`
/// (`UIDropDownMenu.lua:801-815`), which captures `previousValues` for Cancel to hand back.
#[test]
fn a_dropdown_row_with_has_color_swatch_opens_the_picker_and_cancel_restores() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -483,7 +421,6 @@ fn a_dropdown_row_with_has_color_swatch_opens_the_picker_and_cancel_restores() {
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
// The square is up and wears the row's colour.
assert!(s
.eval::<bool>("return DropDownList1Button1ColorSwatch:IsVisible()")
.unwrap());
@@ -495,7 +432,6 @@ fn a_dropdown_row_with_has_color_swatch_opens_the_picker_and_cancel_restores() {
"the swatch is tinted to info.r/g/b, got {r},{g},{b}"
);
// Clicking it opens the picker, seeded from the row.
s.run("DropDownList1Button1ColorSwatch:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s
@@ -514,10 +450,10 @@ fn a_dropdown_row_with_has_color_swatch_opens_the_picker_and_cancel_restores() {
.unwrap());
let (pr, pg, pb): (f64, f64, f64) = s.eval("return ColorPickerFrame:GetColorRGB()").unwrap();
assert_eq!((pr, pg, pb), after_round_trip(0.2, 0.4, 0.6));
// …and swatchFunc became the picker's live-preview `func`, which already ran once.
// `swatchFunc` became the picker's live-preview `func`, which has already run once.
assert_eq!(s.eval::<usize>("return table.getn(picked)").unwrap(), 1);
// Cancel hands `previousValues` — the table OpenColorPicker captured — to the addon.
// Cancel hands the addon `previousValues`, the table the opener captured.
s.run("ColorPickerCancelButton:Click()").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert_eq!(
@@ -527,8 +463,6 @@ fn a_dropdown_row_with_has_color_swatch_opens_the_picker_and_cancel_restores() {
);
}
/// A row WITHOUT `hasColorSwatch` keeps its square hidden — the branch that stops every ordinary
/// menu row from sprouting a colour button.
#[test]
fn a_row_without_the_flag_has_no_swatch() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -552,14 +486,11 @@ fn a_row_without_the_flag_has_no_swatch() {
.unwrap());
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The wheel (decision 1592) — the shipped window's generated art, end to end
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The wheel: the window's generated art ────────────────────────────────────────────────────────
/// The four elements the reference declares are installed on the SHIPPED window, at the
/// reference's own geometry, and `ColorPickerWheel` publishes under the reference's name — which is
/// load-bearing twice over: `<ColorValueTexture>` anchors to it by name, and an addon re-skinning
/// the picker addresses it by name.
/// The wheel, the value strip and both thumbs sit at the stock geometry
/// (`ColorPickerFrame.xml:186-221`), and `ColorPickerWheel` publishes its name, which the strip's
/// anchor and reskinning addons use.
#[test]
fn the_shipped_window_carries_the_wheel_at_the_references_geometry() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -594,7 +525,7 @@ fn the_shipped_window_carries_the_wheel_at_the_references_geometry() {
"the strip anchors LEFT to the wheel's RIGHT at +24 — BY NAME"
);
// The markers: the one BLP in this window, at the reference's two crops.
// The two thumbs: one BLP, two crops.
for (getter, want_w, want_h) in [
("GetColorWheelThumbTexture", 10.0, 10.0),
("GetColorValueThumbTexture", 48.0, 14.0),
@@ -612,17 +543,13 @@ fn the_shipped_window_carries_the_wheel_at_the_references_geometry() {
}
}
/// **The window can now be used for what it is for**: Dewdrop opens it, the user clicks a hue, and
/// the caller's `func` sees the colour under the cursor. This is the whole of B246's picker path
/// and the thing that was impossible before 1592 — every other test in this file could pass with no
/// wheel at all.
///
/// The click is at the wheel's right rim, which the pick law makes 180° — cyan.
/// A click on the wheel reaches the caller's `func` with the colour under the cursor; the right
/// rim is hue 180, cyan.
#[test]
fn a_click_on_the_wheel_reaches_the_callers_func() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = picker();
// Dewdrop-2.0.lua l.425-465's block, as transcribed in this file's other tests.
// The Dewdrop block above, reduced to its `func`.
s.run(
r#"
picked = nil
@@ -654,7 +581,7 @@ fn a_click_on_the_wheel_reaches_the_callers_func() {
r < 0.05 && g > 0.95 && bl > 0.95,
"the right rim is 180° — cyan, got ({r:.3}, {g:.3}, {bl:.3})"
);
// And the window's own preview swatch followed, through OnColorSelect.
// The window's own preview swatch followed, through OnColorSelect.
let swatch = s.extract().into_iter().any(|q| {
matches!(&q.content, QuadContent::Texture { color: Some(c), .. }
if c[0] < 0.05 && c[1] > 0.95)
@@ -662,11 +589,8 @@ fn a_click_on_the_wheel_reaches_the_callers_func() {
assert!(swatch, "ColorSwatch repainted to the picked colour");
}
/// The two file-less regions reach the renderer as their own content kinds, carrying exactly what
/// changes their pixels and nothing more: the disc carries **no colour at all** (it is drawn at a
/// literal `V = 1`), and the strip carries hue and saturation but **not value**. Anything more
/// would churn these quads on every step of a drag for no pixel — the extract gate is only honest
/// if the content is (decision 1592 §4).
/// The file-less regions carry only what moves a pixel: the disc no colour at all (it draws at
/// V = 1), the strip hue and saturation but not value.
#[test]
fn the_generated_art_reaches_the_renderer_carrying_only_what_moves_a_pixel() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -688,14 +612,13 @@ fn the_generated_art_reaches_the_renderer_carrying_only_what_moves_a_pixel() {
_ => None,
})
.expect("the strip draws");
// (0.15, 0.55, 0.75) is a cyan-blue: hue 200°, saturation 0.80 (through the client's own
// quantize on the way in — `set_rgb` stores what the 8-bit lattice can hold).
// (0.15, 0.55, 0.75) is hue 200, saturation 0.80 after the 8-bit quantize on the way in.
assert!(
(strip.0 - 200.0).abs() < 0.5 && (strip.1 - 0.801).abs() < 0.01,
"the strip carries the live hue/sat, got {strip:?}"
);
// Dropping the brightness moves NO strip pixel — the same quad content comes back.
// A lower brightness leaves the strip's content unchanged.
s.run("ColorPickerFrame:SetColorRGB(0.05, 0.18, 0.25)")
.unwrap();
s.resolve();
@@ -1,20 +1,11 @@
//! The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
//! transcription driven end-to-end through the real loader: the COMBAT_TEXT_UPDATE pipeline,
//! the scroll/fade/crit-pop envelope, the option gating, and the Lua-side low-health trigger.
//! Scrolling combat text: the stock `Blizzard_CombatText` addon driven through the real loader.
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use super::test_ui::load_ui as load_xml;
/// The window loaded **with the feature switched on** — which is not how it ships.
///
/// `SHOW_COMBAT_TEXT` boots at the reference's `"0"` since 1804 (it was `"1"` from 0578, when the
/// director's ask for scrolling combat text was read as the shipped experience). The master is
/// enforced at the source: `CombatText_UpdateDisplayedMessages` registers no events at all while
/// it is off, so a fresh VM answers every question below with "nothing happened". These tests are
/// about what the feature *does* once a player ticks the Combat page's box, so the harness ticks
/// it for them exactly the way that row does — assign the global, then re-run the family's
/// applyFunc. The gating itself is [`combat_text_master_toggle_unregisters`]'s subject, not theirs.
/// The addon with `SHOW_COMBAT_TEXT` on; it ships `"0"`, and while it is off
/// `CombatText_UpdateDisplayedMessages` registers no events.
fn load_combat_text() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -22,12 +13,8 @@ fn load_combat_text() -> UiScript {
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.
// The options window's defaults block (`UIOptionsFrame.lua:125`), seated by hand, master on.
s.run(
r#"SHOW_COMBAT_TEXT = "1"
COMBAT_TEXT_SHOW_LOW_HEALTH_MANA = "1"
@@ -52,15 +39,13 @@ fn load_combat_text() -> UiScript {
s
}
/// A damage message paints "-N" red at the base height, scrolls upward over its 1.9 s life,
/// fades past 1.3 s, and frees its string at expiry. A gated type (MANA, var default "0")
/// shows nothing.
/// The stock envelope: height 25, a 1.9 s scroll life, a fade from 1.3 s.
#[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.
// The pool is `NUM_COMBAT_TEXT_LINES`, 20 strings.
let hidden: bool = s
.eval(
r#"
@@ -92,7 +77,7 @@ fn combat_text_damage_scrolls_and_expires() {
.unwrap();
assert!(ok, "damage paints -17 at height 25 ({:?})", s.errors());
// Half a second in: the string has scrolled upward (mode 1 flows up, 384 → 609).
// Float mode 1 scrolls up, from y 384 to 609.
s.tick(0.5);
let ok: bool = s
.eval(
@@ -104,15 +89,13 @@ fn combat_text_damage_scrolls_and_expires() {
.unwrap();
assert!(ok, "scrolled up, still opaque ({:?})", s.errors());
// Past the fade-out start: alpha drops below 1.
s.tick(1.0); // 1.5 s total, fade began at 1.3
let fading: bool = s
.eval("return CombatText1:GetAlpha() < 1 and CombatText1:GetAlpha() > 0")
.unwrap();
assert!(fading, "fading past 1.3 s ({:?})", s.errors());
// Past the 1.9 s scroll life: removed and hidden (the ref tests scrollTime BEFORE advancing,
// so expiry lands on the tick after the threshold is crossed).
// Expiry tests `scrollTime` before advancing it, so it lands a tick after crossing 1.9 s.
s.tick(0.5);
s.tick(0.1);
let gone: bool = s
@@ -120,7 +103,6 @@ fn combat_text_damage_scrolls_and_expires() {
.unwrap();
assert!(gone, "expired at 1.9 s ({:?})", s.errors());
// A var-gated type at its ref default "0" shows nothing.
s.fire_event(
"COMBAT_TEXT_UPDATE",
vec![
@@ -133,9 +115,7 @@ fn combat_text_damage_scrolls_and_expires() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A crit pops through the scale envelope: seeded at 30, grown toward 60 inside the first
/// 0.05 s, shrinking back toward 30 by 0.2 s — and it parks (endY = startY: the crit never
/// scrolls away from the seat).
/// A crit seeds at 30, peaks at 60 at 0.05 s, is back by 0.2 s, and parks: `endY` is `startY`.
#[test]
fn combat_text_crit_pops_and_parks() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -170,9 +150,7 @@ fn combat_text_crit_pops_and_parks() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The drawn height via the engine extract (the SetTextHeight override on the matching Text
/// quad) — the two-regime split (decision 0582): the font object stays 25, the override is the
/// drawn size, uncapped.
/// The drawn height: the `SetTextHeight` override on the matching text quad, not the font's 25.
fn extracted_text_height(s: &mut UiScript, text: &str) -> Option<f32> {
s.resolve();
s.extract().into_iter().find_map(|q| match q.content {
@@ -185,8 +163,7 @@ fn extracted_text_height(s: &mut UiScript, text: &str) -> Option<f32> {
})?
}
/// The event-side triggers: PLAYER_REGEN_DISABLED paints "Entering Combat"; a sub-20% UNIT_HEALTH
/// paints "Health Low" once and re-arms only after recovering above the threshold.
/// Low health is `COMBAT_TEXT_LOW_HEALTH_THRESHOLD` (0.2), latched until health recovers above it.
#[test]
fn combat_text_state_and_low_health_triggers() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -227,7 +204,6 @@ fn combat_text_state_and_low_health_triggers() {
s.errors()
);
// Recover, then drop again: the latch re-arms.
player.health = 90;
s.set_unit("player", Some(player.clone()));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
@@ -252,10 +228,8 @@ fn combat_text_state_and_low_health_triggers() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The crit pop's PEAK reaches a true 60 (decision 0582: SetTextHeight sizes are uncapped —
/// the pre-split renderer clamped them to the 32-unit one-to-one cap, so crits never popped
/// past 32). The VM's screen is the 768-virtual space by construction (the app seam feeds it),
/// so the ref constants stand verbatim.
/// `SetTextHeight` sizes are uncapped, so the pop peaks at 60. The VM's screen is the 768-high
/// virtual space, so the stock 384/609 locations apply verbatim.
#[test]
fn combat_text_crit_peak_is_uncapped() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -277,18 +251,12 @@ fn combat_text_crit_peak_is_uncapped() {
);
s.tick(0.05); // the scale window's end: SetTextHeight(60), the pop peak
let h = extracted_text_height(&mut s, "-99").expect("crit drawn at the peak");
// floor() at the f32 tick boundary lands 59 or 60 — the claim under test is that the peak
// is UNCAPPED (the pre-split renderer clamped it to 32).
// `floor()` at the f32 tick boundary lands 59 or 60.
assert!((59.0..=60.0).contains(&h), "the pop peaks at ~60, got {h}");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The master toggle: SHOW_COMBAT_TEXT = "0" + CombatText_UpdateDisplayedMessages unregisters
/// everything — a subsequent damage event paints nothing (ref-identical gating). Since 1804 that
/// "0" is also what a fresh client ships (it was 0578's named divergence, `"1"`, until then), so
/// the test walks the switch **down from the harness's planted ON**: it fires a damage message
/// first and watches it paint, because "nothing painted" only means the toggle worked if
/// something would otherwise have.
/// Walks the switch down from on: a message paints first, so "nothing paints" after is the gate.
#[test]
fn combat_text_master_toggle_unregisters() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -305,14 +273,11 @@ fn combat_text_master_toggle_unregisters() {
.unwrap();
assert!(painted, "enabled: the message paints ({:?})", s.errors());
// The switch, then the file's own list clear — the message already in flight belongs to the
// planted ON state, and the claim under test is about what arrives AFTER the gate closes.
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.
// `CombatText_ClearAnimationList` hides the strings but leaves the list to the ticker, so
// "nothing paints" is the list not growing and no string shown.
let before: i64 = s.eval("return getn(COMBAT_TEXT_TO_ANIMATE)").unwrap();
s.fire_event(
"COMBAT_TEXT_UPDATE",
@@ -330,34 +295,24 @@ fn combat_text_master_toggle_unregisters() {
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.
///
/// Skips without client data.
/// The reference reads the `DAMAGE_TEXT_FONT` global (`0x6c847c`) after the addons load
/// (`0x6c8470`), so an addon's `ADDON_LOADED` assignment wins over stock Friz.
#[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.
// Unassigned, the reference's failure block (`0x704350`) leaves `0x703bf0`'s pre-seeded `""`,
// which the font factory 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!(
@@ -1,20 +1,15 @@
//! The combo-point dots (decisions 0869, 0875) — the reference's own `ComboFrame.xml`/`.lua`,
//! executed off the player's chain since 1751's tenth window, driven through the real loader: the show/hide edges, the per-point highlight/shine fade chain, the
//! "only newly-earned points flare" rule that `COMBO_FRAME_LAST_NUM_POINTS` exists to enforce, and
//! the two gates that live in `GetComboPoints` rather than in this Lua — rogue-or-druid only, and
//! the points must be banked on the CURRENT target.
//! The stock combo-point dots (`ComboFrame.xml`). `GetComboPoints` holds two gates the Lua does
//! not: rogue or druid only, and the points must be banked on the current target.
use benilla_ui::script::{PlayerReqState, UiScript, UnitState};
/// The unit the points get banked on in these tests, and a second one to re-target to.
const MOB_A: u64 = 0xF130_0000_0000_0001;
const MOB_B: u64 = 0xF130_0000_0000_0002;
use super::test_ui::load_ui as load_xml;
/// The manifest order this file actually ships in: `UiPanels.xml` for `UIFrameFade`, and the
/// reference's own `TargetFrame.xml` for the `TargetFrame` the dots anchor to — `relativeTo` is
/// resolved at LOAD, so that file has to precede this one (1751).
/// `UIFrameFade` comes with UIParent.xml, and TargetFrame.xml loads first because the dots
/// anchor `relativeTo="TargetFrame"`, resolved at load.
fn load_combo_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -48,8 +43,6 @@ fn load_combo_frame() -> UiScript {
s
}
/// Put the player in a class that can see combo points, with `guid` selected — the state
/// `GetComboPoints` demands before it will report anything at all.
fn play_as(s: &mut UiScript, class_id: u32, target: u64) {
s.set_player_req_state(PlayerReqState {
class_id,
@@ -65,7 +58,7 @@ fn play_as(s: &mut UiScript, class_id: u32, target: u64) {
);
}
/// Run every queued fade past its longest leg (0.4 s highlight → 0.3 s shine in → 0.4 s shine out).
/// Run 2 s of fades, past the 0.4 + 0.3 + 0.4 s highlight and shine chain (`ComboFrame.lua:3`).
fn settle(s: &mut UiScript) {
s.eval::<()>("for i = 1, 40 do UIFrameFadeUpdate(0.05) end")
.unwrap();
@@ -80,20 +73,16 @@ fn highlight_alpha(s: &mut UiScript, i: u32) -> f64 {
.unwrap()
}
/// Zero points hides the frame; the first point shows it and lights exactly one dot; the count
/// rising lights the rest; the drop back to zero hides it again. The alpha readings run the fade to
/// completion first, since the highlight arrives via `UIFrameFade`, not a straight `SetAlpha`.
/// The highlight arrives through `UIFrameFade`, so every alpha read settles the fades first.
#[test]
fn combo_frame_follows_the_point_count() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_combo_frame();
play_as(&mut s, 4, MOB_A); // rogue, mob A selected
play_as(&mut s, 4, MOB_A); // rogue
// Nothing banked: hidden, and the event on zero is a no-op that keeps it hidden.
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(!shown(&mut s), "no points ⇒ hidden");
// One point — a rogue's first builder.
s.set_combo_points(1, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(shown(&mut s), "one point ⇒ shown");
@@ -107,7 +96,6 @@ fn combo_frame_follows_the_point_count() {
"point 2 stays dark at one combo point"
);
// Up to five: every dot lit, none left behind.
s.set_combo_points(5, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
settle(&mut s);
@@ -125,17 +113,13 @@ fn combo_frame_follows_the_point_count() {
.unwrap();
assert!(all_lit, "all five dots lit at five points");
// The spend/expiry edge: back to zero hides the frame. This is the one the wire depends on —
// the server clears the byte on its own, so the falling edge is the only thing that ever takes
// the dots down.
// The server clears the count on spend or expiry; that falling edge alone takes the dots down.
s.set_combo_points(0, 0);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(!shown(&mut s), "zero points ⇒ hidden again");
}
/// A repaint at an UNCHANGED count must not re-flare the dots that were already lit — the whole
/// job of `COMBO_FRAME_LAST_NUM_POINTS`. `PLAYER_TARGET_CHANGED` is registered alongside the
/// combo event, so re-targeting mid-window fires exactly this repaint.
/// `PLAYER_TARGET_CHANGED` repaints too; `COMBO_FRAME_LAST_NUM_POINTS` stops lit dots re-flaring.
#[test]
fn a_repaint_at_the_same_count_does_not_re_flare() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -145,11 +129,9 @@ fn a_repaint_at_the_same_count_does_not_re_flare() {
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
settle(&mut s);
// Both settled: highlights up, shines burned out.
let shine: f64 = s.eval("return ComboPoint2Shine:GetAlpha()").unwrap();
assert_eq!(shine, 0.0, "the shine fades back out");
// A re-select of the SAME unit at the same count: no new fade is queued for either dot.
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let fading: bool = s
.eval("return UIFrameIsFading(ComboPoint1Highlight) ~= nil")
@@ -161,11 +143,8 @@ fn a_repaint_at_the_same_count_does_not_re_flare() {
);
}
/// **A warrior sees nothing** — `GetComboPoints 0x51a190`'s class gate (`cmp al,4 / cmp al,0xb`).
/// The server really does bank a point for them on a victim's dodge, and that byte really does
/// reach us: it is what greys Overpower through the usable walk's leg 5. It just never reaches the
/// UI. Every class outside {rogue, druid} takes the same path; the wire state here is identical to
/// the rogue ladder's first rung, so only the class differs.
/// `GetComboPoints` (`0x51a190`) answers 0 outside rogue and druid (classes 4 and 11), though
/// the server banks a point for a warrior's Overpower on a dodge.
#[test]
fn a_warriors_overpower_point_lights_no_dot() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -177,16 +156,13 @@ fn a_warriors_overpower_point_lights_no_dot() {
let points: i64 = s.eval("return GetComboPoints()").unwrap();
assert_eq!(points, 0, "and the binding itself reports zero");
// A druid is on the other side of the same gate (class 11 — cat form's builders).
play_as(&mut s, 11, MOB_A);
s.fire_event("PLAYER_COMBO_POINTS", vec![]);
assert!(shown(&mut s), "a druid sees the same banked point");
}
/// **Combo points are per target** — `0x51a190` compares `PLAYER_FIELD_COMBO_TARGET` against the
/// current-target GUID global and pushes 0 on a mismatch. So re-targeting empties the dots without
/// the server touching the count, and selecting the banked unit again refills them. Losing the
/// target entirely (guid 0) empties them too.
/// `0x51a190` answers 0 unless `PLAYER_FIELD_COMBO_TARGET` is the current target, so the dots
/// follow the selection while the banked count stays put.
#[test]
fn re_targeting_empties_the_dots_without_the_count_moving() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -197,21 +173,18 @@ fn re_targeting_empties_the_dots_without_the_count_moving() {
settle(&mut s);
assert!(shown(&mut s), "three points on the selected mob ⇒ shown");
// Select a different mob. The wire count is untouched — only the reader's answer changes.
play_as(&mut s, 4, MOB_B);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!shown(&mut s), "points banked elsewhere ⇒ no dots");
let points: i64 = s.eval("return GetComboPoints()").unwrap();
assert_eq!(points, 0, "the binding hides them, the wire still has them");
// Select the banked mob again: all three come straight back.
play_as(&mut s, 4, MOB_A);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
settle(&mut s);
assert!(shown(&mut s), "re-selecting the banked mob refills them");
assert!(highlight_alpha(&mut s, 3) > 0.99, "all three, not just one");
// And no target at all is a mismatch like any other.
s.set_unit("target", None);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!shown(&mut s), "no target ⇒ no dots");
+13 -84
View File
@@ -1,84 +1,27 @@
//! **The shipped UI's files, compiled into the binary** (decision 1175 §2).
//!
//! One question, one answer: *give me the text of `ui/<name>.xml`*. [`load_ui_files`] and the
//! `provider` closure it hands the loader (for `<Include>` / `<Script file=>` references) both ask
//! here instead of touching `std::fs`.
//!
//! Until 1175 they read `concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui")` — the *build*
//! machine's source tree. On a player's machine every one of those files resolves to nothing and
//! the client boots with no interface at all: not a crash, just an empty screen and one log line
//! per manifest entry that nobody sees.
//! `assets/ui` is our own content (MIT/Apache, not Blizzard's — `docs/METHOD.md`'s hard rule is
//! untouched), so it can simply be part of the program.
//!
//! ## Resolution order — the same in every build
//!
//! 1. **`#[cfg(feature = "dev")]`** — the crate's own `assets/ui/` on disk. Editing a FrameXML file
//! and re-running still costs no recompile, which is the whole reason the source tree is worth
//! probing at all. This is the only place a source path survives, and 1174's feature is what
//! keeps it out of a player binary.
//! 2. **The compiled-in copy** — `include_dir!` over the same directory.
//!
//! A dev build therefore prefers disk and silently falls back to the embedded copy, so a file
//! deleted from `assets/ui` does not *appear* to still work: [`tests`] pins that the two agree.
//!
//! ## Why not the asset server
//!
//! [`super::manifest::MANIFEST`] is a **dependency-ordered** manifest loaded synchronously
//! at a known point (a template must exist before the frame that inherits it), and the asset server
//! is async. Serving these through it would mean re-deriving a dependency graph that a hand-ordered
//! list already encodes correctly. This stays a byte lookup.
//!
//! ## The override layer this is not
//!
//! A `content/` folder beside the executable — addon and content-pack territory — is deliberately
//! *not* built here (1175 "Rejected"), but the shape leaves room for it: it would be one more arm
//! at the front of [`read`]. Building it now would be a mod system with no mods.
//! The shipped UI's files (`assets/ui`), compiled into the binary so the interface cannot be
//! missing; `load_ui_files` and its `<Include>` provider read them here, never through `std::fs`.
//! A dev build reads the source tree first, so an edit needs no recompile.
/// The shipped UI tree, compiled in: `benilla.toc` and every file it names, a few MB — a small
/// single-digit % of the release binary, and what it buys is "the interface cannot be missing".
/// (A hard file count lived here until it had silently drifted by four; the manifest is the
/// authority on how many there are, and `manifest::tests` is what keeps the two agreeing.)
/// The shipped UI tree: `benilla.toc` and every file it names.
static UI: include_dir::Dir<'_> = include_dir::include_dir!("$CARGO_MANIFEST_DIR/assets/ui");
/// 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
/// it is a property of FrameXML references rather than of the content store.
/// The text of one shipped UI file, by path relative to `assets/ui`; the basename fallback for a
/// FrameXML reference is the caller's.
pub(super) fn read(req: &str) -> Option<String> {
// 1 · the source tree, dev builds only — so editing a FrameXML file needs no recompile.
if let Some(text) = read_source_tree(req) {
return Some(text);
}
// 2 · the compiled-in copy.
UI.get_file(req)?.contents_utf8().map(str::to_owned)
}
/// `assets/ui/<req>` on disk, or `None` in a player build.
///
/// The source directory comes from [`crate::run_mode::dev_source_dir`] rather than a `cfg` here:
/// the seam has one door per kind (1179), and a path literal must be *absent* from a player
/// binary, not merely unreached (1175). With no source dir this is a cheap `None` and the caller
/// falls through to the compiled-in copy.
/// `assets/ui/<req>` on disk, or `None` in a player build, whose binary holds no source path.
fn read_source_tree(req: &str) -> Option<String> {
let dir = crate::run_mode::dev_source_dir()?.join("assets/ui");
std::fs::read_to_string(dir.join(req)).ok()
}
/// A digest of the FrameXML **this process will actually load** — the manifest plus every file it
/// names, hashed in load order, as eight hex digits.
///
/// It exists because a number taken from the corpus harness is meaningless without it. In a dev
/// build [`read`] prefers the SOURCE TREE, so editing an `assets/ui` file changes what a survey
/// loads **with no rebuild** — and a tree is routinely edited by several agents at
/// once. Three separate measurements this arc were taken across a moving tree and one of them
/// landed a wrong attribution in a decision record: 87/218 was credited to a single table when a
/// controlled A/B later put it at 75, with the other twelve belonging to a neighbour's uncommitted
/// files. The stamp does not prevent that; it makes it visible, which is all an instrument can do.
///
/// FNV-1a, no dependency, not cryptographic and not meant to be: the only question it answers is
/// *were these two runs looking at the same interface*.
/// FNV-1a (not cryptographic) over the manifest and each file it names, in load order, as eight
/// hex digits: a stamp that tells two runs whether they loaded the same interface.
pub(crate) fn digest() -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut eat = |bytes: &[u8]| {
@@ -92,10 +35,7 @@ pub(crate) fn digest() -> String {
eat(toc.as_bytes());
}
for name in super::addons::Addon::builtin().toc.files {
// A chain entry (1751) contributes its NAME only: its bytes are the player's install,
// which does not move between two runs on one machine — and this stamp's only question is
// *were these two runs looking at the same interface*. Migrating a window still changes the
// digest, because the manifest line changes.
// Only a chain entry's name counts: its bytes are the player's install.
eat(name.as_bytes());
if let Some(text) = read(&name) {
eat(text.as_bytes());
@@ -104,8 +44,7 @@ pub(crate) fn digest() -> String {
format!("{:08x}", h as u32)
}
/// Every shipped file's path, relative to `assets/ui`. The compiled-in set is the authority —
/// a dev build must not appear to ship a file that is only on this machine.
/// Every shipped file's path, relative to `assets/ui`, from the compiled-in set, not the disk.
#[cfg(test)]
pub(super) fn shipped_files() -> impl Iterator<Item = &'static str> {
UI.files().filter_map(|f| f.path().to_str())
@@ -113,11 +52,6 @@ pub(super) fn shipped_files() -> impl Iterator<Item = &'static str> {
#[cfg(test)]
mod tests {
/// Every entry of the manifest the client actually loads is a file we actually ship.
///
/// This is the check the old `std::fs` loader could not make: a typo'd manifest entry used to
/// be one `error!` line in a log at boot, and capture runs skip the loader entirely unless
/// `WOW_CAPTURE_UI=1`, so nothing failed. Now a name that is not in the binary fails here.
#[test]
fn every_manifest_entry_is_compiled_in() {
for name in crate::ui_script::manifest::shipped_manifest_files() {
@@ -128,15 +62,10 @@ mod tests {
}
}
/// The dev probe and the compiled-in copy describe the same tree.
///
/// A dev build prefers disk, so without this a file deleted from `assets/ui` would keep
/// working from the embedded copy (and a file added there would keep working from disk) until
/// someone built a release. Either way the two would silently disagree about what benilla
/// ships, which is the failure mode 1175 is about.
/// A dev build prefers disk, so without this the two copies could disagree unnoticed.
#[test]
fn the_source_tree_and_the_compiled_in_copy_agree() {
// A player build has no source dir to disagree with — that IS the property there.
// A player build has no source tree to disagree with.
let Some(dir) = crate::run_mode::dev_source_dir().map(|d| d.join("assets/ui")) else {
return;
};
+23 -58
View File
@@ -1,7 +1,5 @@
//! The death-arc UI (decisions 0308/1746, DeathFrame.xml): the DEATH release popup's
//! show/countdown/release flow, its self-resurrect button, the resurrect-offer popup pick, and
//! the spirit-healer XP_LOSS two-step — the Lua wiring between the engine's death events and the
//! StaticPopup engine, driven exactly as `death.rs`'s feed does it (set_death → fire_event).
//! The stock death popups (`StaticPopup.lua`): release, self-resurrect, resurrect offers, the
//! spirit healer's confirm and the corpse run, driven as the app's death feed drives them.
use benilla_ui::script::{DeathAction, DeathUiState, ScriptValue, UiScript};
@@ -23,9 +21,6 @@ fn setup() -> UiScript {
s
}
/// PLAYER_DEAD with a running release timer: the DEATH popup shows, its OnShow seeds the countdown
/// from GetReleaseTimeRemaining, the engine's per-tick DEATH text renders, Release Spirit queues
/// the Repop intent, and PLAYER_ALIVE (the release landing) hides it.
#[test]
fn death_popup_counts_down_and_release_queues_repop() {
benilla_formats::wow_data_or_skip!();
@@ -46,13 +41,13 @@ fn death_popup_counts_down_and_release_queues_repop() {
"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).
// DEATH has no `hideOnEscape` (`StaticPopup.lua:375`).
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the DEATH popup ignores ESC"
);
// Button2 (soulstone) is hidden — HasSoulstone() is nil.
// No self-res owed: `DisplayButton2` is `HasSoulstone()`, nil here.
assert!(
!s.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap(),
@@ -65,7 +60,6 @@ fn death_popup_counts_down_and_release_queues_repop() {
"Release Spirit hides the dialog"
);
// Re-shown (still dead), then the release lands: PLAYER_ALIVE hides it.
s.fire_event("PLAYER_DEAD", vec![]);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
s.fire_event("PLAYER_ALIVE", vec![]);
@@ -76,9 +70,8 @@ fn death_popup_counts_down_and_release_queues_repop() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The self-resurrect leg (decision 1746): with `PLAYER_SELF_RES_SPELL` naming a spell, the DEATH
/// popup grows its second button, the button wears the SPELL's name rather than the registry's
/// "Reincarnate" default, and clicking it queues `UseSoulstone` — while button1 still releases.
/// A `PLAYER_SELF_RES_SPELL` owed: `HasSoulstone()` returns its name, which OnShow puts on
/// button 2 (`StaticPopup.lua:383`).
#[test]
fn death_popup_offers_the_self_resurrect_and_spends_it() {
benilla_formats::wow_data_or_skip!();
@@ -100,11 +93,10 @@ fn death_popup_offers_the_self_resurrect_and_spends_it() {
"Use Soulstone",
"OnShow stamps the Spell.dbc name over the registry's vestigial \"Reincarnate\""
);
// Button1 is still the release, and it is NOT the self-res.
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::Repop]);
// Button2 spends it. The dialog is raised again first, because clicking either button hides it.
// Either click hides the dialog, so raise it again first.
s.fire_event("PLAYER_DEAD", vec![]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::UseSoulstone]);
@@ -115,10 +107,7 @@ fn death_popup_offers_the_self_resurrect_and_spends_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A shaman's Reincarnation is the same path with a different name — and the button is decided by
/// `HasSoulstone()` at SHOW time, so a self-res that arrives after the popup is up does not
/// retro-fit a button onto it (the reference's own StaticPopup engine re-evaluates neither
/// `DisplayButton2` nor `OnShow` per tick).
/// Neither `DisplayButton2` nor OnShow re-runs per tick: a late self-res shows on the next raise.
#[test]
fn death_popup_button2_is_decided_at_show_time() {
benilla_formats::wow_data_or_skip!();
@@ -132,7 +121,6 @@ fn death_popup_button2_is_decided_at_show_time() {
.eval::<bool>("return StaticPopup1Button2:IsShown()")
.unwrap());
// The field lands late: the standing dialog does not grow a button…
s.set_death(DeathUiState {
release_remaining: Some(300.0),
self_res_label: Some("Reincarnation".into()),
@@ -144,8 +132,7 @@ fn death_popup_button2_is_decided_at_show_time() {
.unwrap(),
"no per-tick re-evaluation — the button set is fixed at StaticPopup_Show"
);
// …but the OnCancel arm re-asks, so a click on a button that is not there cannot mis-route,
// and the next raise picks it up.
// OnCancel asks `HasSoulstone()` again at click time (`StaticPopup.lua:400`).
s.run("StaticPopup_Hide(\"DEATH\")").unwrap();
s.fire_event("PLAYER_DEAD", vec![]);
assert_eq!(
@@ -158,8 +145,7 @@ fn death_popup_button2_is_decided_at_show_time() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The no-timer variant: GetReleaseTimeRemaining() == −1 (an instanceable map) swaps in the
/// DEATH_RELEASE_NOTIMER text and never counts down.
/// A release time of -1 (an instance) shows `DEATH_RELEASE_NOTIMER` (`StaticPopup.lua:385`).
#[test]
fn death_popup_no_timer_shows_the_static_text() {
benilla_formats::wow_data_or_skip!();
@@ -180,13 +166,11 @@ fn death_popup_no_timer_shows_the_static_text() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// RESURRECT_REQUEST picks its popup by the offer bits (the ref's three-way), formats the offerer
/// name in, gates Accept behind the recovery delay, and Accept/Decline queue their intents.
/// `RESURRECT_REQUEST` picks one of three popups by sickness, then timer (`UIParent.lua:259`).
#[test]
fn resurrect_request_picks_variant_and_answers() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
// No sickness + timer, delay already elapsed: RESURRECT_NO_SICKNESS with an armed Accept.
s.set_death(DeathUiState {
resurrect_sickness: false,
resurrect_has_timer: true,
@@ -199,7 +183,7 @@ fn resurrect_request_picks_variant_and_answers() {
s.eval::<String>("return StaticPopup1.which").unwrap(),
"RESURRECT_NO_SICKNESS"
);
s.tick(0.05); // the zero StartDelay expires: real text + enabled button
s.tick(0.05); // the zero StartDelay runs out
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
@@ -212,7 +196,7 @@ fn resurrect_request_picks_variant_and_answers() {
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::AcceptResurrect]);
// Sickness variant declines: the intent queues and (still dead) DEATH re-shows.
// With sickness: declining while dead re-shows DEATH (`StaticPopup.lua:428`).
s.set_death(DeathUiState {
resurrect_sickness: true,
resurrect_has_timer: true,
@@ -236,9 +220,7 @@ fn resurrect_request_picks_variant_and_answers() {
);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::DeclineResurrect]);
// The re-shown DEATH lands on the free SECOND instance (Show runs inside the decline's
// OnCancel, before the click's own hide frees instance 1) — the exact case that sized
// STATICPOPUP_NUMDIALOGS at 2.
// DEATH takes StaticPopup2: OnCancel shows it before the click hides StaticPopup1.
assert_eq!(
s.eval::<String>("return StaticPopup_Visible(\"DEATH\") or \"none\"")
.unwrap(),
@@ -252,8 +234,7 @@ fn resurrect_request_picks_variant_and_answers() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The spirit healer's CONFIRM_XP_LOSS two-step: first Accept rewrites to the "Are you sure" text
/// and keeps the dialog, the second queues AcceptXPLoss; walking out of range auto-hides.
/// The first Accept swaps in the second-ask text and keeps the dialog (`StaticPopup.lua:1138`).
#[test]
fn xp_loss_two_step_confirm_then_range_hide() {
benilla_formats::wow_data_or_skip!();
@@ -275,9 +256,7 @@ fn xp_loss_two_step_confirm_then_range_hide() {
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
// OnAccept reads `this:GetParent()` (the ref's button-handler context), so the click is driven
// as the XML OnClick does — with `this` bound to the button.
// OnAccept reads `this:GetParent()`, so `this` is the button, as in the XML OnClick.
s.run("this = StaticPopup1Button1 StaticPopup_OnClick(StaticPopup1, 1) this = nil")
.unwrap();
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
@@ -289,13 +268,11 @@ fn xp_loss_two_step_confirm_then_range_hide() {
text.starts_with("Remember, if you find your corpse"),
"the second-ask text: {text}"
);
// Second Accept: the activate intent queues and the dialog closes.
s.run("this = StaticPopup1Button1 StaticPopup_OnClick(StaticPopup1, 1) this = nil")
.unwrap();
assert_eq!(s.take_death_actions(), vec![DeathAction::AcceptXpLoss]);
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
// Below the sickness level: the NO_SICKNESS variant. Out of range: the OnUpdate poll hides.
s.set_death(DeathUiState {
sickness_duration: None,
spirit_healer_in_range: true,
@@ -319,11 +296,8 @@ fn xp_loss_two_step_confirm_then_range_hide() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A cancelled confirm comes back on the next ask (decision 1068, B80): every CONFIRM_XP_LOSS
/// fire re-shows the dialog — the feed fires per SMSG, and this guards the Lua half of that
/// contract. And the XP_LOSS dialogs carry the ref's alert dress (StaticPopup.lua l.1579-1589):
/// width 420 with the AlertIcon shown, reset to 320/hidden for a non-alert entry on the same
/// instance.
/// The feed fires `CONFIRM_XP_LOSS` per server message, so a cancelled confirm returns on the next
/// ask. `showAlert` widens to 420 with the icon; every Show resets both (`StaticPopup.lua:1580`).
#[test]
fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
benilla_formats::wow_data_or_skip!();
@@ -345,19 +319,16 @@ fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
.unwrap(),
"showAlert shows the DialogAlertIcon"
);
// Cancel: nothing queues, nothing on the wire — the dialog just hides.
s.run("this = StaticPopup1Button2 StaticPopup_OnClick(StaticPopup1, 2) this = nil")
.unwrap();
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(s.take_death_actions().is_empty());
// Asking the healer again = a fresh SMSG = the feed fires again: the confirm is back.
s.fire_event("CONFIRM_XP_LOSS", vec![]);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a fresh confirm re-shows after a Cancel (B80's deadlock)"
);
s.run("StaticPopup_Hide(\"XP_LOSS\")").unwrap();
// A non-alert entry on the same instance resets the dress — Show's unconditional reset.
s.run(concat!(
"StaticPopupDialogs[\"TEST_PLAIN\"] = { text = \"x\", button1 = \"OK\", timeout = 0, ",
"whileDead = 1 } StaticPopup_Show(\"TEST_PLAIN\")"
@@ -376,8 +347,7 @@ fn xp_loss_cancel_then_reconfirm_reshows_with_the_alert_dress() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// UnitIsGhost/UnitIsDeadOrGhost — the trio's ghost legs (a ghost has health 1, so UnitIsDead is
/// false for it; decision 0308 §1).
/// A ghost has 1 health, so `UnitIsDead` is nil for it.
#[test]
fn the_ghost_predicates() {
benilla_formats::wow_data_or_skip!();
@@ -393,8 +363,7 @@ fn the_ghost_predicates() {
..Default::default()
}),
);
// 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.
// The three answer 1 or nil, never a boolean: `== nil` fails on a `false`.
assert!(s
.eval::<bool>("return UnitIsDead(\"player\") == nil")
.unwrap());
@@ -407,10 +376,7 @@ fn the_ghost_predicates() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The corpse-run range events (decision 0308 §5): CORPSE_IN_RANGE shows RECOVER_CORPSE with its
/// StartDelay countdown gating Accept, Accept queues the reclaim intent (and keeps the dialog —
/// the server's descriptor deltas close the loop), CORPSE_OUT_OF_RANGE hides it; the instance
/// variant is the buttonless notice.
/// `StartDelay` gates Accept, and Accept keeps the dialog up (`StaticPopup.lua:1182`).
#[test]
fn corpse_range_events_drive_recover_corpse() {
benilla_formats::wow_data_or_skip!();
@@ -456,7 +422,7 @@ fn corpse_range_events_drive_recover_corpse() {
"leaving range hides the recover dialog"
);
// The dungeon-corpse notice: buttonless (the ref entry has no button1), whileDead.
// RECOVER_CORPSE_INSTANCE has no buttons (`StaticPopup.lua:1196`).
s.fire_event("CORPSE_IN_INSTANCE", vec![]);
assert_eq!(
s.eval::<String>("return StaticPopup1.which").unwrap(),
@@ -470,8 +436,7 @@ fn corpse_range_events_drive_recover_corpse() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// GetCorpseMapPosition: the feed's corpse UV surfaces (0,0) = hidden when absent — the reference
/// WorldMapFrame.lua:443-452 law the map's update block branches on.
/// No corpse reads (0, 0), which hides the map's corpse marker (`WorldMapFrame.lua:445`).
#[test]
fn corpse_map_position_binding() {
benilla_formats::wow_data_or_skip!();
@@ -1,14 +1,6 @@
//! The delete-item confirm popup driver (decision 0216 §3, UiPanels.xml's
//! `BenillaDeleteItemConfirmDriver`) — Lua wiring the Rust-side cursor tests can't reach: the
//! world-drop's `DELETE_ITEM_CONFIRM` showing the ref's `DELETE_ITEM` StaticPopup entry (decision
//! 0308 §3's engine) with the real `DELETE_ITEM`/`YES`/`NO` GlobalStrings, its Yes/No/ESC routing
//! to `DeleteCursorItem`/`ClearCursor`, and the entry's own `OnUpdate` auto-hide poll.
//!
//! Plus the `arg2 >= 3` fork the driver grew in 1743 (ref `UIParent.lua:344-352`): a RARE-or-better
//! payload raises `DELETE_GOOD_ITEM` instead, whose OKAY stays disabled until the player types
//! `DELETE_ITEM_CONFIRM_STRING` into its edit box. The two fixtures are real 1.12 rows (vmangos
//! `item_template`): **Tough Jerky** 117, quality 1 — the plain arm; **Flurry Axe** 871, quality 4 —
//! the typed arm.
//! The delete confirm: the world drop's `DELETE_ITEM_CONFIRM` raises the stock `DELETE_ITEM`
//! popup, or `DELETE_GOOD_ITEM` for quality 3 and up (`UIParent.lua:344-352`). The fixtures are
//! real 1.12 items: Tough Jerky (117, quality 1) and Flurry Axe (871, quality 4).
use benilla_ui::script::{ContainerSlot, ContainerState, UiScript};
@@ -16,9 +8,7 @@ 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
/// the fixture's only real parameter.
/// A backpack holding a 5-stack of `item_id` at `quality`, the parameter the popup forks on.
fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
@@ -50,11 +40,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
}
}
/// The popup engine and its driver, and nothing else: `UiPanels.xml` owns both `StaticPopup*` and
/// `BenillaDeleteItemConfirmDriver`, and every test but the repaint one below only ever asks about
/// the dialog. It carried `MerchantFrame.xml` + `Cooldown.xml` + `BagFrame.xml` until 1751 for one
/// reason — the repaint test used to count `BenillaBagFrame_Update` — and that test now brings the
/// reference's own bag stack itself ([`bag_setup`]), so the whole tail went with it.
/// The stock popup stack and the world frame the drop is clicked on.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -67,28 +53,21 @@ fn setup() -> UiScript {
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).
// The stock `DELETE_GOOD_ITEM`'s `OnHide` hands focus back to `ChatFrameEditBox`.
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, "Interface\\FrameXML\\GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, for dropdowns
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.
// The drop is a click on the full-screen world frame, so it must be loaded.
load_world_frame(&s);
s.set_money(0);
s
}
/// [`setup`] plus the reference's bag stack — the harness for the one test that has to watch a bag
/// WINDOW repaint. The windows are `ContainerFrame1..12` off the player's chain now (1751), so
/// this needs client data and the caller opens with `wow_data_or_skip!`.
/// The stock bag windows (`BAG_UI`) and the world frame, for the test that watches a bag repaint.
fn bag_setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -96,43 +75,34 @@ fn bag_setup() -> UiScript {
load_xml(&s, file);
}
// `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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_world_frame(&s); // [`setup`]'s reason, same fixture
load_world_frame(&s); // the drop target
// …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.
// `BAG_UI` leaves out `CharacterFrame.xml`, so `PaperDollFrame` has no hidden parent and would
// cover the world; the real client never shows it here.
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 on the world frame; 0218's byte-verified trigger) fires the world-drop
/// `DELETE_ITEM_CONFIRM(name, quality)` the driver listens for.
/// Drops Tough Jerky on the world: a full left click, press and release both on the world frame,
/// fires `DELETE_ITEM_CONFIRM(name, quality)`.
fn pick_up_and_drop_in_world(s: &mut UiScript) {
drop_in_world(s, 117, "Tough Jerky", 1);
}
/// The same world drop for an arbitrary fixture item — 1743's fork reads `arg2` (the quality), so
/// every test below picks the arm it wants by choosing the item.
/// The world drop for any fixture item; its quality (`arg2`) picks the popup.
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");
world_click(s);
s.tick(0.01); // flush the queued DELETE_ITEM_CONFIRM into the driver's OnEvent
s.tick(0.01); // flush the queued DELETE_ITEM_CONFIRM into UIParent's OnEvent
}
/// The popup shows with the real 1.12 `DELETE_ITEM`/`YES`/`NO` GlobalStrings text, and Yes runs
/// `DeleteCursorItem()` — the item leaves the cursor and queues its wire destroy.
#[test]
fn delete_item_confirm_shows_the_real_strings_and_yes_deletes() {
benilla_formats::wow_data_or_skip!();
@@ -160,8 +130,7 @@ fn delete_item_confirm_shows_the_real_strings_and_yes_deletes() {
"No"
);
// Yes → StaticPopup_OnClick(dialog, 1) → the entry's OnAccept → DeleteCursorItem(): the
// payload clears and the destroy queues (count 0 = the fixture's whole 5-stack was picked up).
// Yes runs `OnAccept`, `DeleteCursorItem()`; a destroy count of 0 is the whole stack.
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
@@ -172,20 +141,8 @@ fn delete_item_confirm_shows_the_real_strings_and_yes_deletes() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// No runs the entry's OnCancel (`ClearCursor()`) instead — the item is dropped, not destroyed (no
/// wire send) — and the bag REPAINTS off the clear's `ITEM_LOCK_CHANGED`, un-darkening the source
/// slot. The repaint is the 0218 stuck-darkening regression: the popup's No is a clear the bag
/// never clicked through, so only the event wiring (the container window's own ITEM_LOCK_CHANGED
/// registration) can reach it.
///
/// **What 1751 changed, and what it did not.** The repaint counted here used to be
/// `BenillaBagFrame_Update`; the window is the reference's `ContainerFrame` now, so the body is
/// its own `ContainerFrame_Update(frame)` (`ContainerFrame.lua:234`) off the player's chain. Two
/// consequences, both faithful: the window has to be OPEN (`ContainerFrame_OnEvent` gates the
/// repaint on `this:IsShown()` at l.39-42, where ours repainted a hidden window too), and the
/// darkening the repaint undoes is the reference's own `SetItemButtonDesaturated(button, locked)`
/// at l.245 — so the slot's greyscale flag is asserted directly here rather than only through the
/// repaint that clears it.
/// No runs `OnCancel` (`ClearCursor()`) and destroys nothing; only the clear's `ITEM_LOCK_CHANGED`
/// can repaint the open bag, un-darkening the slot (`ContainerFrame.lua:39-42`, `:246`).
#[test]
fn delete_item_confirm_no_clears_without_destroying() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -193,8 +150,7 @@ fn delete_item_confirm_no_clears_without_destroying() {
s.set_container(0, Some(one_item_backpack(117, "Tough Jerky", 1)));
s.run("ToggleBackpack()").unwrap();
assert!(bag_open(&s, 0), "the backpack window is up");
// Asked of the buttons' own GetID: `ContainerFrame_GenerateFrame` numbers a window's buttons
// BACKWARDS, so slot 1 is the LAST `…Item<j>`, and index arithmetic would pin a coincidence.
// `ContainerFrame_GenerateFrame` numbers buttons backwards (`ContainerFrame.lua:426-428`).
let slot1 = bag_slot_button(&s, 0, 1);
pick_up_and_drop_in_world(&mut s);
@@ -203,9 +159,7 @@ fn delete_item_confirm_no_clears_without_destroying() {
"held on the cursor, the source slot draws dark (ref SetItemButtonDesaturated(_, locked))"
);
// 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).
// Count repaints. The 1.12 client's Lua 5.0 has no `...` as a value: forward `arg` by `unpack`.
s.run(
"repaints = 0\n\
local real = ContainerFrame_Update\n\
@@ -238,9 +192,7 @@ fn delete_item_confirm_no_clears_without_destroying() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Is `button`'s icon drawn greyscale? The renderer-facing answer, off the quad stream, because
/// the engine publishes `Texture:SetDesaturated` with no getter to ask — and a *drawn* grey is
/// what "the slot is stuck dark" means to the player anyway.
/// Whether `button`'s icon draws greyscale, off the quad stream: `SetDesaturated` has no getter.
fn desaturated(s: &mut UiScript, button: &str) -> bool {
s.resolve();
s.extract()
@@ -256,10 +208,8 @@ fn desaturated(s: &mut UiScript, button: &str) -> bool {
})
}
/// ESC routes through the ref's `StaticPopup_EscapePressed` — DELETE_ITEM is `hideOnEscape`, so
/// its OnCancel runs with reason "clicked" exactly as a real No-click would. (`ToggleGameMenu`'s
/// existing unconditional `ClearCursor()` already empties the cursor before the popup branch, so
/// this mainly proves the popup itself closes.)
/// ESC runs stock `StaticPopup_EscapePressed`: `DELETE_ITEM` is `hideOnEscape`, so its `OnCancel`
/// runs as No would (`StaticPopup.lua:1879-1893`).
#[test]
fn escape_closes_the_delete_confirm_popup() {
benilla_formats::wow_data_or_skip!();
@@ -275,11 +225,8 @@ fn escape_closes_the_delete_confirm_popup() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's DELETE_ITEM `OnUpdate` auto-hide poll (StaticPopup.lua:669-673), now VERBATIM
/// as the registry entry's own OnUpdate: if the cursor empties by any OTHER path (not a popup
/// button — say, a same-slot click cancelling a fresh pickup) while the confirm is still showing,
/// the popup auto-hides on the next tick. The poll lives inside the DELETE_ITEM entry, so a
/// DIFFERENT visible dialog is untouched by cursor traffic.
/// The stock `DELETE_ITEM`'s `OnUpdate` hides it once the cursor empties by any path
/// (`StaticPopup.lua:669-673`); the poll is the entry's own.
#[test]
fn the_delete_entry_polls_itself_hidden_and_other_dialogs_are_untouched() {
benilla_formats::wow_data_or_skip!();
@@ -287,8 +234,6 @@ fn the_delete_entry_polls_itself_hidden_and_other_dialogs_are_untouched() {
pick_up_and_drop_in_world(&mut s);
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
// The cursor empties via ClearCursor() directly (not a popup button) — the entry's own
// OnUpdate poll hides the still-showing confirm on the next tick.
s.run("ClearCursor()").unwrap();
s.tick(0.01);
assert!(
@@ -296,8 +241,6 @@ fn the_delete_entry_polls_itself_hidden_and_other_dialogs_are_untouched() {
"the DELETE_ITEM OnUpdate poll auto-hides once the cursor is empty"
);
// An unrelated registered dialog must NOT be auto-hidden by cursor traffic — only the
// DELETE_ITEM entry polls, and its OnUpdate only runs while DELETE_ITEM is the shown `which`.
s.run(
r#"StaticPopupDialogs["TEST_UNRELATED"] = { text = "unrelated?", button1 = "Yes",
button2 = "No", timeout = 0, whileDead = 1 }
@@ -314,9 +257,7 @@ fn the_delete_entry_polls_itself_hidden_and_other_dialogs_are_untouched() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The RARE-or-better fork (ref `UIParent.lua:346-350`, `arg2 >= 3`): a quality-4 payload raises
/// `DELETE_GOOD_ITEM`, not `DELETE_ITEM` — with the second GlobalString, the edit box up and
/// focused, and OKAY **disabled** until the confirm word is typed.
/// Quality 3 and up raises `DELETE_GOOD_ITEM` instead (`UIParent.lua:346-350`).
#[test]
fn a_rare_payload_raises_the_typed_confirm_with_okay_disabled() {
benilla_formats::wow_data_or_skip!();
@@ -353,19 +294,15 @@ fn a_rare_payload_raises_the_typed_confirm_with_okay_disabled() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The typed gate itself (ref `EditBoxOnTextChanged`, `StaticPopup.lua:718-723`): only the exact
/// `DELETE_ITEM_CONFIRM_STRING` enables OKAY, it is compared through `strupper` so lower case
/// passes, and backing away from the word disables it again. Then OKAY destroys.
/// `EditBoxOnTextChanged` compares the `strupper` of the text with `DELETE_ITEM_CONFIRM_STRING`
/// (`StaticPopup.lua:718-723`).
#[test]
fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
benilla_formats::wow_data_or_skip!();
let mut s = setup();
drop_in_world(&mut s, 871, "Flurry Axe", 4);
// OKAY is enabled by the box's `OnTextChanged`, and that fire is deferred to the drain
// (decision 1831) — so what this answers is the button state the LAST DRAINED text produced,
// which is the state a player ever sees. Ticking here rather than after each write keeps the
// test reading as the sequence of edits it is about.
// `OnTextChanged` fires at the drain, so this ticks before reading the button.
let enabled = |s: &mut UiScript| {
s.tick(0.0);
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
@@ -376,8 +313,7 @@ fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
s.run(r#"StaticPopup1EditBox:SetText("DELET")"#).unwrap();
assert!(!enabled(&mut s), "a prefix of the word is not the word");
// Typed character by character through the engine's own input path, lower case: the ref
// compares through strupper, so this arms OKAY exactly as shouting it would.
// Typed key by key in lower case, through the engine's input path.
s.run(r#"StaticPopup1EditBox:SetText("")"#).unwrap();
for c in ["d", "e", "l", "e", "t", "e"] {
assert!(s.char_input(c), "the focused box takes the keystroke");
@@ -398,7 +334,6 @@ fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
s.run(r#"StaticPopup1EditBox:SetText("DELETE")"#).unwrap();
assert!(enabled(&mut s));
// OKAY now does what the plain arm's does: DeleteCursorItem().
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
@@ -409,8 +344,7 @@ fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Enter in the box is the ref's `EditBoxOnEnterPressed` (`StaticPopup.lua:712-717`): it destroys
/// only while OKAY is enabled, so a reflexive Enter over a half-typed word does nothing at all.
/// Stock `EditBoxOnEnterPressed` destroys only while OKAY is enabled (`StaticPopup.lua:712-717`).
#[test]
fn enter_in_the_box_destroys_only_once_okay_is_enabled() {
benilla_formats::wow_data_or_skip!();
@@ -427,7 +361,7 @@ fn enter_in_the_box_destroys_only_once_okay_is_enabled() {
assert!(s.take_container_destroys().is_empty());
s.run(r#"StaticPopup1EditBox:SetText("DELETE")"#).unwrap();
s.tick(0.0); // the write only marks the box — the drain is what enables OKAY (1831)
s.tick(0.0); // the write only marks the box; the drain enables OKAY
assert!(s.key_input("ENTER"));
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
@@ -438,9 +372,7 @@ fn enter_in_the_box_destroys_only_once_okay_is_enabled() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// NO on the typed variant is the plain one's cancel — `ClearCursor()`, no wire send — and the
/// entry's `OnHide` empties the box so the next raise opens blank (the ref's own reason: a word
/// left over from last time would be armed before the player read the dialog).
/// The stock `OnHide` empties the box, so the next raise opens blank and disarmed.
#[test]
fn no_on_the_typed_confirm_clears_and_leaves_the_box_empty_for_next_time() {
benilla_formats::wow_data_or_skip!();
@@ -459,7 +391,6 @@ fn no_on_the_typed_confirm_clears_and_leaves_the_box_empty_for_next_time() {
"OnHide empties the box"
);
// Raise it again: blank box, OKAY disabled — the armed state did not survive.
drop_in_world(&mut s, 871, "Flurry Axe", 4);
assert_eq!(
s.eval::<String>("return StaticPopup1EditBox:GetText()")
@@ -475,12 +406,8 @@ 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 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.
/// Stock `DELETE_GOOD_ITEM` has no `EditBoxOnEscapePressed`, so ESC in its box does nothing
/// (`StaticPopup.lua:1817-1822`).
#[test]
fn escape_in_the_typed_confirms_box_is_swallowed_as_the_reference_leaves_it() {
benilla_formats::wow_data_or_skip!();
@@ -506,8 +433,6 @@ fn escape_in_the_typed_confirms_box_is_swallowed_as_the_reference_leaves_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The plain arm keeps its own edit box HIDDEN — the fork is not cosmetic, and a common-quality
/// destroy must not grow a field to type into. The control for every test above.
#[test]
fn the_plain_arm_shows_no_edit_box() {
benilla_formats::wow_data_or_skip!();
@@ -532,13 +457,8 @@ 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.
/// The world drop under the whole shipped manifest: a fixture is a subset, and can miss the file
/// that breaks the drop.
#[test]
fn the_world_drop_survives_the_whole_shipped_interface() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -564,7 +484,6 @@ fn the_world_drop_survives_the_whole_shipped_interface() {
.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 -110
View File
@@ -1,24 +1,6 @@
//! The dressing room + the chat-link insert, driven through the real click paths (decisions
//! 1059/1060).
//!
//! What these pin is the **fork**, at the sites a player actually clicks: CTRL opens the room
//! wearing the item, SHIFT posts its link into an open chat edit box — and, at a bag slot, SHIFT
//! with chat *closed* still opens the stack splitter it always did (the reference's own `else`,
//! and the regression these two features could most easily cause).
//!
//! **The bag-slot fork is the REFERENCE's own code since 1751.** Both forks used to run through
//! benilla's `BenillaBagSlot_OnClick`; the bag windows are the reference's recycled
//! `ContainerFrame1..12` now and the handler is its `ContainerFrameItemButton_OnClick(button,
//! ignoreModifiers)` — which takes the MOUSE button as its first argument and reads the frame from
//! `this`, so a test cannot call it directly any more. These drive the mouse instead
//! ([`super::test_ui::click`]), which is the stronger test regardless: it puts the template's own
//! `RegisterForClicks("LeftButtonUp", "RightButtonUp")` and script wiring under test too.
//!
//! **The doll slots went the same way.** `CharacterFrame.xml` and `PaperDollFrame.xml` are the
//! reference's own too now, so `BenillaPaperDollSlot_OnClick` is gone and the fork is stock
//! `PaperDollItemSlotButton_OnClick(button, ignoreModifiers)` (`PaperDollFrame.lua:647-662`) —
//! which reads `this` exactly as the bag handler does. Those tests drive the mouse as well, over a
//! window that is genuinely OPEN, which is the only state a player can click a doll slot from.
//! The dressing room and the chat-link insert through the real click paths: CTRL opens the room
//! wearing the item, SHIFT posts its link into an open chat box, and at a bag slot SHIFT with chat
//! closed still opens the stack splitter. The stock handlers read `this`, so these drive the mouse.
use benilla_ui::script::{
ContainerSlot, ContainerState, DressUpIntent, InvSlotView, InventorySlots, SoundRequest,
@@ -27,18 +9,17 @@ use benilla_ui::script::{
use super::test_ui::{bag_slot_button, click, load_ui as load_xml, BAG_UI, CHARACTER_UI};
/// The jerky stack every bag fixture here uses — a real 1.12 link, quality white.
/// A real 1.12 item link: Tough Jerky (117), quality white.
const JERKY_LINK: &str = "|cffffffff|Hitem:117|h[Tough Jerky]|h|r";
/// The room's own files, past whatever bag interface the caller wants — both loaders below end
/// with these, in manifest order.
/// The room's own files, in manifest order; both loaders end with these.
const ROOM_UI: &[&str] = &[
"Interface\\FrameXML\\UIDropDownMenu.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
"ScrollTemplates.xml", // our scroll kits
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\StackSplitFrame.xml",
@@ -48,18 +29,14 @@ const ROOM_UI: &[&str] = &[
"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.
// After the panel kit: the room's Close and Reset buttons inherit its templates at load.
"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
/// window**, for the sites that never touch a container (a chat link, the room's own controls).
/// Deliberately install-free: those tests run on a bare checkout, which [`load_room_with_bags`]
/// cannot.
/// The room with no bag window, for the click sites that never touch a container.
fn load_room(s: &UiScript) {
for file in [
"Interface\\FrameXML\\Fonts.xml",
@@ -82,13 +59,8 @@ fn load_room(s: &UiScript) {
}
}
/// [`load_room`] over the REFERENCE's bag windows (1751). `BAG_UI` is the ordered set a test needs
/// before it can open one and it already carries the fonts, panels, tooltip, item-button template
/// and bag bar the room wants, so only the room's own files follow it. `UIParent.xml` leads because
/// it inherits nothing and every window below parents to it — `BAG_UI` has no line for it.
///
/// **Needs client data**: `BAG_UI` names a chain entry, so its callers open with
/// `wow_data_or_skip!`.
/// The room over the stock bag windows; `UIParent.xml` leads because every window parents to it
/// and `BAG_UI` has no line for it.
fn load_room_with_bags(s: &UiScript) {
load_xml(s, r"Interface\FrameXML\UIParent.xml");
for file in BAG_UI {
@@ -99,17 +71,9 @@ fn load_room_with_bags(s: &UiScript) {
}
}
/// The character window **open on its paper doll**, with the room and a chat edit box beside it —
/// the two files [`CHARACTER_UI`] does not already carry. (`UIDropDownMenu.xml`,
/// `Interface\FrameXML\UIMenu.xml` and `UnitPopup.xml` are in both lists; loading either twice
/// would redeclare its frames.)
///
/// The player behind it carries **both** halves of the race and class pairs: `UnitRace`/`UnitClass`
/// answer `(localized, file)` or `nil, nil` — the binding `zip`s them — and stock
/// `PaperDollFrame_SetLevel` formats all three into `CharacterLevelText` unguarded
/// (`PaperDollFrame.lua:100-104`) on every show.
///
/// **Needs client data**, like [`load_room_with_bags`].
/// The character window on its paper doll, with the room and a chat box beside it. The player
/// has race and class, which stock `PaperDollFrame_SetLevel` formats unguarded on every show
/// (`PaperDollFrame.lua:100-104`).
fn shown_paper_doll() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -124,7 +88,7 @@ fn shown_paper_doll() -> UiScript {
"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.
// After the panel kit its buttons inherit from.
"Interface\\FrameXML\\DressUpFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
@@ -147,15 +111,8 @@ fn shown_paper_doll() -> UiScript {
s
}
/// A backpack holding a 5-stack of Tough Jerky in slot 1, opened; returns the NAME of the button
/// showing that slot.
///
/// **Asked, never derived** (1751). The window is one of the reference's recycled
/// `ContainerFrame1..12` — which one depends on what else is open — and its buttons are numbered
/// BACKWARDS (`ContainerFrame_GenerateFrame`: `index = size - j + 1`, so `…Item1` is the bag's last
/// slot), so [`bag_slot_button`] finds the button whose own `GetID()` is the slot. The old body
/// scanned `BenillaBagSlot<N>` for a `.slot` field and handed back a centre; neither those frames
/// nor that field exist, and the centre is [`click`]'s business now.
/// Opens a backpack holding 5 Tough Jerky in slot 1 and returns the name of that slot's button,
/// asked of the buttons: the stock windows are recycled and number their buttons backwards.
fn backpack_with_jerky(s: &mut UiScript) -> String {
s.set_money(0);
let mut slots = std::collections::HashMap::new();
@@ -185,13 +142,8 @@ fn backpack_with_jerky(s: &mut UiScript) -> String {
bag_slot_button(s, 0, 1)
}
/// CTRL + left-click on a bag item opens the dressing room and puts that item on — the ref's
/// `DressUpItemLink(GetContainerItemLink(...))` (ContainerFrame.lua:565-566). The two intents are
/// ordered: `Dress` (the window was closed, so it re-dresses in the player's own gear first) then
/// `TryOn` — reversed, the room would show the player's own gear and not the clicked item.
///
/// Since 1751 that arm is the reference's OWN `ContainerFrameItemButton_OnClick` rather than a
/// transcription of it, so the cited line numbers are now the live code's.
/// Stock `DressUpItemLink(GetContainerItemLink(...))` (`ContainerFrame.lua:565-566`). `Dress`
/// must precede `TryOn`, or the room shows the player's own gear instead of the item.
#[test]
fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -201,8 +153,7 @@ fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
let btn = backpack_with_jerky(&mut s);
assert!(!s.eval::<bool>("return DressUpFrame:IsVisible()").unwrap());
// The modifier is pushed BEFORE the mouse, exactly as the app's input pass does it: the click
// handler's fork reads the state as of the click.
// Modifiers go in before the click, as the app's input pass sends them.
s.set_modifiers(false, true, false);
click(&mut s, &btn, "LeftButton");
s.set_modifiers(false, false, false);
@@ -223,16 +174,8 @@ fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// SHIFT + left-click posts the item's link into the chat edit box **when it is open**, and still
/// opens the stack splitter when it is not — the reference's own if/else (ContainerFrame.lua:
/// 567-577). Both halves in one test because it is the *fork* that matters: the split was the
/// behaviour that already shipped, and the insert must not have eaten it.
///
/// Since 1751 the fork is the reference's own body and the splitter it opens is still ours
/// (`OpenStackSplitFrame`, StackSplit.xml): the reference reaches it through the `this.SplitStack`
/// closure `ContainerFrameItemButton_OnLoad` installs, so what this now also covers is that seam
/// holding — a stack split confirmed from a bag slot still calls `SplitContainerItem` with the
/// window's own bag id and the button's own slot id.
/// Stock shift arm (`ContainerFrame.lua:567-578`): the link with chat open, else the stack
/// splitter, which calls back through the `this.SplitStack` closure.
#[test]
fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -241,7 +184,6 @@ fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
load_room_with_bags(&s);
let btn = backpack_with_jerky(&mut s);
// Chat closed → the splitter, exactly as before.
s.set_modifiers(true, false, false);
click(&mut s, &btn, "LeftButton");
s.set_modifiers(false, false, false);
@@ -251,7 +193,6 @@ fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
);
s.run("StackSplitFrame:Hide()").unwrap();
// Chat open → the link, and no splitter.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
click(&mut s, &btn, "LeftButton");
@@ -273,9 +214,8 @@ fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A link already IN chat: ctrl-clicking it previews that item (ref ItemRef.lua:49-50), shift
/// -clicking it re-inserts it (the branch that already shipped). The router is `SetItemRef`, which
/// is what the message frame's `OnHyperlinkClick` calls with the full markup.
/// Stock `SetItemRef`, which a chat frame's `OnHyperlinkClick` calls: ctrl previews the item
/// (`ItemRef.lua:49-50`).
#[test]
fn ctrl_clicking_a_chat_link_previews_it() {
benilla_formats::wow_data_or_skip!();
@@ -299,17 +239,11 @@ fn ctrl_clicking_a_chat_link_previews_it() {
s.eval::<bool>("return DressUpFrame:IsVisible()").unwrap(),
"and the room opened"
);
// The plain click still shows the link tooltip rather than the room (no modifier).
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The paper doll's own slots (`PaperDollFrame.lua:647-662`): ctrl previews what you are wearing,
/// shift posts its link. Both read the unit-keyed `GetInventoryItemLink`, the binding this arc
/// added — so this also pins that the getter answers for `"player"`.
///
/// Since 1751 that handler is the reference's own `PaperDollItemSlotButton_OnClick(button,
/// ignoreModifiers)`, which reads the frame from `this` — so this drives the mouse over a window
/// that is really open, exactly as the bag tests above do.
/// Stock `PaperDollItemSlotButton_OnClick` (`PaperDollFrame.lua:647-662`) reads
/// `GetInventoryItemLink("player", slot)` for both the preview and the link.
#[test]
fn the_paper_doll_slots_preview_and_post_what_you_wear() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -335,7 +269,6 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
let _ = s.take_dressup_intents();
s.resolve();
// CTRL on the head slot → the room, wearing the helm.
s.set_modifiers(false, true, false);
click(&mut s, "CharacterHeadSlot", "LeftButton");
s.set_modifiers(false, false, false);
@@ -344,7 +277,6 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
vec![DressUpIntent::Dress, DressUpIntent::TryOn(1234)]
);
// SHIFT with chat open → the link; the item is never picked up either way.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
click(&mut s, "CharacterHeadSlot", "LeftButton");
@@ -361,19 +293,14 @@ fn the_paper_doll_slots_preview_and_post_what_you_wear() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A slot whose item template has not answered yet has **no link** — a state the real client never
/// has (its item cache is synchronous), so the reference carries no guard: stock
/// `PaperDollItemSlotButton_OnClick` shift-inserts `GetInventoryItemLink(...)` straight
/// (`PaperDollFrame.lua:653`). Shift-clicking such a slot must post nothing and raise nothing.
///
/// That is now the ENGINE's promise rather than a transcription's guard: `EditBox:Insert(nil)` is a
/// no-op because the reference reads the argument through `lua_tostring` (decision 1800), and this
/// test is what holds the stock line above harmless over an in-flight item.
/// An unresolved item has no link, which stock `PaperDollFrame.lua:653` inserts unguarded:
/// `EditBox:Insert(nil)` is a no-op, as the reference reads its argument with `lua_tostring`
/// (`0x7984b0`).
#[test]
fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_paper_doll();
// The slot is occupied but unresolved: an item id with no name/quality yet, so no link.
// Occupied but unresolved: an item id with no name or quality, so no link.
let mut slots = InventorySlots::default();
slots[1] = Some(InvSlotView {
item_id: 1234,
@@ -397,7 +324,7 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// And ctrl on the same unresolved slot is inert too (DressUpItemLink's own nil guard).
// Ctrl is inert too: `DressUpItemLink` returns on a nil link (`DressUpFrame.lua:11-13`).
s.set_modifiers(false, true, false);
click(&mut s, "CharacterHeadSlot", "LeftButton");
s.set_modifiers(false, false, false);
@@ -408,9 +335,8 @@ fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The window's own controls: Reset re-dresses (and clicks), closing it empties the booth, and the
/// rotate buttons move the pane's yaw by the reference's own ±0.03 per OnClick — which fires on
/// BOTH mouse edges, so one tap is 0.06 (decision 0638 §3).
/// The rotate buttons step 0.03 per `OnClick` (`UIParent.lua:1428`) and register both mouse edges
/// (`DressUpFrame.xml:220`), so one tap turns 0.06.
#[test]
fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
benilla_formats::wow_data_or_skip!();
@@ -422,7 +348,7 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
let _ = s.take_dressup_intents();
s.resolve();
// The pane's OnLoad (`Model_OnLoad`) seeded the ref's default facing on the widget itself.
// `Model_OnLoad` seeds the default facing.
assert!(
(s.model_pane_facing("DressUpModel") - 0.61).abs() < 1e-6,
"ref UIParent.lua:1422"
@@ -436,7 +362,6 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
"Reset plays the ref's own kit"
);
// One tap of rotate-left: OnClick on press AND release, −0.03 each.
let before = s.model_pane_facing("DressUpModel");
let (x, y) = s
.eval::<(f32, f32)>(
@@ -456,8 +381,7 @@ fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
);
s.run("HideUIPanel(DressUpFrame)").unwrap();
// 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).
// The app empties the booth off the frame's visibility; the stock `OnHide` queues nothing.
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());
@@ -1,23 +1,11 @@
//! The two shared dropdown lists' own scripts (`UIDropDownMenu.xml`'s `DropDownList1`/`2`).
//!
//! These exist because the instance `<Scripts>` block was absent entirely and nothing said so: the
//! lists were one-line self-closing elements, the loader has no opinion about a frame with no
//! scripts, and every dropdown still opened and closed. The reference hangs three separate jobs off
//! those two elements and we had none of them.
//!
//! Each test is named after the claim it defends (1212): the submenu catch-all, the open-menu
//! registry's clear, the fact that the default text height is DERIVED rather than a literal, and —
//! found by the third while it was failing — that a button's label answers for the font object its
//! button gave it, which it did not.
//! The stock dropdown lists' own scripts (`UIDropDownMenu.xml`): OnHide closes the next level
//! and clears its `OPEN_DROPDOWNMENUS` entry, and list 1's OnLoad sets the default text height.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// The production prefix the lists need (ui_script/mod.rs order): the fonts (this OnLoad reads a
/// real font height off Button1's NormalText), UIParent (the lists declare `parent="UIParent"`),
/// and GameTooltip — the list's own `$parentMenuBackdrop` OnLoad indexes `TOOLTIP_DEFAULT_COLOR`,
/// which GameTooltip.xml declares.
/// Fonts for Button1's height, and GameTooltip.xml's `TOOLTIP_DEFAULT_COLOR` for the backdrop.
fn load_dropdown_kit(s: &UiScript) {
for file in [
"Interface\\FrameXML\\Fonts.xml",
@@ -31,14 +19,7 @@ fn load_dropdown_kit(s: &UiScript) {
}
}
/// **Hiding level 1 closes level 2.** The bug this replaces was reachable by hand: open a menu with
/// a `hasArrow` row, hover the submenu open, then toggle level 1 shut. `ToggleDropDownMenu`'s close
/// arm is a bare `listFrame:Hide()`, so DropDownList2 stayed on screen with its parent gone.
///
/// The test hides level 1 *directly* rather than through the toggle, which is the stronger claim
/// and the reason the reference puts this on `OnHide` instead of in the toggle: it must catch every
/// path that hides level 1, including a parent Hide, the ESC ladder, and an addon calling
/// `DropDownList1:Hide()` on its own.
/// The close lives on OnHide (`UIDropDownMenu.xml:15`), so every path that hides level 1 counts.
#[test]
fn hiding_the_parent_list_closes_an_open_submenu() {
benilla_formats::wow_data_or_skip!();
@@ -60,15 +41,7 @@ fn hiding_the_parent_list_closes_an_open_submenu() {
);
}
/// **`OPEN_DROPDOWNMENUS` clears when its list hides.** `UnitPopup.xml` writes the level when a
/// unit menu opens and its every-frame driver walks the table; nothing ever removed the entry, so
/// it outlived its menu forever.
///
/// No player-visible symptom is reachable from the stale entry today — `UnitPopup_OnUpdate` returns
/// early unless `DropDownList1` is visible and a `UnitPopupFrame` owns the open menu, and
/// `UIDropDownMenu_AddButton` re-enables every button unconditionally. That is stated here rather
/// than left implied, because both of those guards belong to *other* code and either could move.
/// The divergence from the reference is in this file, so it is fixed and falsified in this file.
/// A unit menu writes its entry (`UnitPopup.lua:149`), and `UnitPopup_OnUpdate` walks the table.
#[test]
fn hiding_a_list_clears_its_open_menu_registry_entry() {
benilla_formats::wow_data_or_skip!();
@@ -100,16 +73,8 @@ fn hiding_a_list_clears_its_open_menu_registry_entry() {
);
}
/// **The default text height is read off Button1's font, never written as a literal.**
///
/// This is the trap the whole change turned on. `UIDropDownMenu.lua:16` forward-declares
/// `UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT = nil`, and reading only that line says "the reference
/// leaves it nil, so defining it would be a divergence". The assignment is in the *XML*, in this
/// OnLoad, and it derives the value from the button's own NormalText — so the honest answer is that
/// the name is a real gap whose only correct value is whatever our font object reports.
///
/// The assertion is therefore that it is non-nil and EQUALS Button1's reported height, not that it
/// equals some number: a literal would pass a number check and still be wrong.
/// `UIDropDownMenu.lua:16` declares the height nil; list 1's OnLoad sets it from Button1's font
/// (`UIDropDownMenu.xml:11`), so the test compares against that font, not a number.
#[test]
fn the_default_text_height_is_derived_from_button1_not_a_literal() {
benilla_formats::wow_data_or_skip!();
@@ -133,15 +98,7 @@ fn the_default_text_height_is_derived_from_button1_not_a_literal() {
);
}
/// **A button label answers for the font object its button gave it.** The gap the test above
/// uncovered: `extract` overlays the per-state font object onto a *clone* of the region's data
/// every frame, so a `<NormalFont inherits="GameFontHighlightSmall"/>` label painted correctly and
/// still answered `nil` to both `GetFont` and `GetFontObject`. Nothing errored — a FontString with
/// no font of its own is a legal state — so it took the reference's own OnLoad, which reads exactly
/// that, to surface it.
///
/// Asserted on the dropdown row because that is the site that found it, and against
/// `GameFontHighlightSmall` by name because that is what `UIDropDownMenuButtonTemplate` declares.
/// `UIDropDownMenuButtonTemplate` gives its label `GameFontHighlightSmall` through `<NormalFont>`.
#[test]
fn a_button_label_reports_the_font_object_its_button_set() {
benilla_formats::wow_data_or_skip!();
+5 -15
View File
@@ -1,7 +1,4 @@
//! The duel UI (decision 0633, DuelFrame.xml): the challenge popup's show/accept/decline, the
//! out-of-bounds warning's countdown text and its in-bounds dismissal, and the DUEL_FINISHED
//! sweep — the Lua wiring between the four engine events `ui_duel`'s feed fires and the shared
//! StaticPopup engine, driven exactly as that feed drives it.
//! The stock duel popups, driven by the four events `ui_duel`'s feed fires (`UIParent.lua:378`).
use benilla_ui::script::{DuelRequest, ScriptValue, UiScript};
@@ -23,8 +20,6 @@ fn setup() -> UiScript {
s
}
/// `DUEL_REQUESTED(name)` raises the challenge popup with the challenger's name filled in, and
/// Accept queues `AcceptDuel()`.
#[test]
fn the_challenge_popup_shows_the_name_and_accept_queues_the_accept() {
benilla_formats::wow_data_or_skip!();
@@ -51,8 +46,7 @@ fn the_challenge_popup_shows_the_name_and_accept_queues_the_accept() {
);
}
/// Decline queues `CancelDuel()` — and so does ESC, because the entry is `hideOnEscape` and the
/// engine routes an escape through OnCancel.
/// Escape runs a `hideOnEscape` popup's OnCancel (`StaticPopup.lua:1884`), so it declines too.
#[test]
fn decline_and_escape_both_cancel() {
benilla_formats::wow_data_or_skip!();
@@ -67,8 +61,7 @@ fn decline_and_escape_both_cancel() {
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
}
/// The out-of-bounds warning is a bare countdown: no buttons, its text rendered by the
/// StaticPopup engine's per-tick branch, and `DUEL_INBOUNDS` takes it away.
/// `DUEL_OUTOFBOUNDS` has no buttons and a 10 s timeout; `StaticPopup_OnUpdate` renders the count.
#[test]
fn out_of_bounds_counts_down_and_inbounds_dismisses_it() {
benilla_formats::wow_data_or_skip!();
@@ -98,9 +91,7 @@ fn out_of_bounds_counts_down_and_inbounds_dismisses_it() {
);
}
/// `DUEL_FINISHED` sweeps **both** dialogs and sends nothing — the case that matters is a duel
/// ending while the challenge popup is still up (the other side cancelled first), which must not
/// leave a stale popup or fire a second cancel.
/// `DUEL_FINISHED` hides both popups (`UIParent.lua:390`); a hide runs no OnCancel, so no decline.
#[test]
fn finishing_sweeps_both_dialogs_silently() {
benilla_formats::wow_data_or_skip!();
@@ -123,8 +114,7 @@ fn finishing_sweeps_both_dialogs_silently() {
assert!(s.take_duel_requests().is_empty());
}
/// The four Era globals queue their intents and nothing else — `StartDuel`/`StartDuelUnit` carry
/// their argument through so the app can resolve it.
/// The four 1.12 duel verbs; `StartDuel` and `StartDuelUnit` pass their argument to the app.
#[test]
fn the_era_globals_queue_their_intents() {
benilla_formats::wow_data_or_skip!();
@@ -1,10 +1,4 @@
//! The durability alert ("armor guy") against the reference's own `DurabilityFrame.xml`/`.lua`,
//! executed off the player's chain since 1751's fourth window:
//! the engine's `GetInventoryAlertStatus` statuses (recomputed on every inventory push, a change
//! firing `UPDATE_INVENTORY_ALERTS`) drive the ref's own SetAlerts law — body pieces show
//! together when any body region alerts, showSeparate pieces (Weapon/Shield/Ranged) each show
//! only themselves, the shield glyph swaps for the off-weapon glyph when the off hand holds a
//! WEAPON, and the whole frame hides at zero alerts.
//! The durability figure: stock `DurabilityFrame.lua` over the engine's `GetInventoryAlertStatus`.
use super::test_ui::load_ui as load_xml;
use benilla_ui::script::{
@@ -29,21 +23,17 @@ fn harness() -> UiScript {
"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.
"Interface\\FrameXML\\DurabilityFrame.xml",
] {
load_xml(&s, f);
}
// The app installs this at the end of every real load; a test VM has to say so itself. It is
// benilla's stated repair of a reference bug — the seat going stale when a side glyph appears
// while the frame is already shown — and its whole reasoning is at the constant.
// Deviation: the seat refreshes on every alert recompute, because the stock one goes stale
// when a side glyph appears while shown and hangs the shield off the screen edge. The app
// installs this after every load.
super::manifest::install_durability_reseat(&s).unwrap();
s
}
/// One equipped slot view with a live durability pair.
fn slot(item_id: u32, durability: Option<(u32, u32)>) -> Option<InvSlotView> {
Some(InvSlotView {
item_id,
@@ -54,9 +44,8 @@ fn slot(item_id: u32, durability: Option<(u32, u32)>) -> Option<InvSlotView> {
})
}
/// The atlas cell (`<TexCoords>`) of a shown region's quad → its painted vertex color; `None`
/// when no shown quad samples that cell. The weapon and off-weapon glyphs share one cell — in
/// any single state at most one of the pair is shown, so the cell stays unambiguous.
/// The painted colour of the shown quad that samples this `<TexCoords>` cell. The weapon and
/// off-weapon glyphs share a cell but never show together.
fn cell_color(s: &mut UiScript, cell: [f32; 4]) -> Option<[f32; 4]> {
s.resolve();
s.extract().iter().find_map(|q| match &q.content {
@@ -84,22 +73,18 @@ const SHIELD_CELL: [f32; 4] = [0.1875, 0.375, 0.3203125, 0.5546875];
const RED: [f32; 4] = [0.93, 0.07, 0.07, 1.0];
const YELLOW: [f32; 4] = [1.0, 0.82, 0.18, 1.0];
/// The show/hide + color law across the three states: clean (hidden), broken main hand (the
/// weapon glyph alone, red), damaged legs (ALL body pieces show, legs yellow, the rest faded
/// white), then repaired (hidden again).
/// `DurabilityFrame_SetAlerts`: one body alert shows every body piece, the rest faded white.
#[test]
fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// The ref's initial settle: the frame is authored shown; PLAYER_ENTERING_WORLD's SetAlerts
// hides it while everything is clean.
// The frame is authored shown; `PLAYER_ENTERING_WORLD` runs `SetAlerts`, which hides it.
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
assert!(
!s.eval::<bool>("return DurabilityFrame:IsShown()").unwrap(),
"clean gear → no armor guy"
);
// Broken main hand: the weapon glyph alone, painted the ref's red.
let mut inv: InventorySlots = Default::default();
inv[16] = slot(25, Some((0, 20)));
s.set_inventory_slots(inv);
@@ -115,9 +100,7 @@ fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
);
assert_eq!(cell_color(&mut s, WEAPON_CELL), Some(RED), "broken → red");
// Damaged legs (3 points left — the byte law's ABSOLUTE 1..=5, `cmp [D+0xa0],5` at
// `0x4c8012`): every body piece shows, legs yellow, the un-alerted head the faded
// white(0.5); the weapon glyph (repaired now) hides.
// Legs at 3 points: damaged is an absolute 1..=5 points left (`0x4c8012`).
let mut inv: InventorySlots = Default::default();
inv[7] = slot(39, Some((3, 25)));
s.set_inventory_slots(inv);
@@ -143,8 +126,7 @@ fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
"un-alerted body piece rides faded white"
);
// The threshold is ABSOLUTE, not a ratio: 5 points on a 100-max piece (5%) is damaged;
// 6 points on a 20-max piece (30%... and even 6/100) is not — `1..=5` exactly.
// Absolute, not a ratio: 5 of 100 is damaged, 6 of 20 is not.
let mut inv: InventorySlots = Default::default();
inv[7] = slot(39, Some((5, 100)));
s.set_inventory_slots(inv);
@@ -162,17 +144,14 @@ fn armor_guy_shows_red_broken_yellow_damaged_and_hides_clean() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The FLAGS bits (`0x4c7faa`, `0x4c7fc8`): `0x10` forces red regardless of durability;
/// `0x08` (wrapped gift) silences the region entirely. And the client's 12th region — low ammo
/// (carried count <= 20 → 3) — answers through `GetInventoryAlertStatus(12)` even though the
/// 1.12 FrameXML never reads it.
/// Item flag `0x10` forces red and `0x08` (wrapped) silences the region (`0x4c7faa`, `0x4c7fc8`);
/// region 12 is low ammo (20 or fewer carried reads 3), which the 1.12 FrameXML never reads.
#[test]
fn flag_bits_and_the_low_ammo_region() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
// Force-red: full durability, bit 0x10 → status 4, the glyph paints red.
let mut inv: InventorySlots = Default::default();
let mut v = slot(25, Some((20, 20))).unwrap();
v.flags = 0x10;
@@ -185,7 +164,6 @@ fn flag_bits_and_the_low_ammo_region() {
);
assert_eq!(cell_color(&mut s, WEAPON_CELL), Some(RED));
// Wrapped: broken durability but bit 0x08 → silent.
let mut inv: InventorySlots = Default::default();
let mut v = slot(25, Some((0, 20))).unwrap();
v.flags = 0x08;
@@ -196,7 +174,6 @@ fn flag_bits_and_the_low_ammo_region() {
"a wrapped item never alerts"
);
// Low ammo: 15 carried → region 12 reads 3; the armor guy stays hidden (FrameXML's 1..=11).
let mut inv: InventorySlots = Default::default();
inv[0] = slot(2512, None).map(|mut v| {
v.count = 15;
@@ -215,8 +192,7 @@ fn flag_bits_and_the_low_ammo_region() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The off-hand switch (ref SetAlerts' Shield arm): a broken SHIELD lights the shield glyph; a
/// broken off-hand WEAPON (template class 2) swaps it for the off-weapon glyph.
/// `SetAlerts`' Shield arm swaps to the off-weapon glyph when `OffhandHasWeapon()` (item class 2).
#[test]
fn off_hand_glyph_follows_what_the_hand_holds() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -238,7 +214,6 @@ fn off_hand_glyph_follows_what_the_hand_holds() {
},
);
// Broken shield → the shield glyph, red; the off-weapon glyph stays hidden.
let mut inv: InventorySlots = Default::default();
inv[17] = slot(2362, Some((0, 20)));
s.set_inventory_slots(inv);
@@ -249,7 +224,6 @@ fn off_hand_glyph_follows_what_the_hand_holds() {
);
assert_eq!(cell_color(&mut s, SHIELD_CELL), Some(RED));
// Broken off-hand weapon → the glyphs swap.
let mut inv: InventorySlots = Default::default();
inv[17] = slot(2488, Some((0, 16)));
s.set_inventory_slots(inv);
@@ -266,20 +240,16 @@ fn off_hand_glyph_follows_what_the_hand_holds() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The manage pass owns the seat (ref UIParent.lua:1758-1768): the authored XML anchor (+40,
/// off-screen — director-caught) dies on the first pass; the frame's TOPRIGHT re-seats at the
/// cluster's BOTTOMRIGHT minus CONTAINER_OFFSET_X (0 with no right multibars), minus 20 more
/// while a side glyph (weapon/shield/ranged) extends the art rightward. OnShow/OnHide re-fire
/// the pass on every alert transition.
/// The manage pass seats `TOPRIGHT` at the cluster's `BOTTOMRIGHT` less `CONTAINER_OFFSET_X`, and
/// 20 more while the shield, off-weapon or ranged glyph shows (`UIParent.lua:1759`).
#[test]
fn manage_pass_seats_the_frame_inside_the_cluster_edge() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
// Broken RANGED: the show transition's OnShow runs the pass; the RIGHT-side glyph pulls
// the frame 20 further left. (The ref's offset list deliberately excludes the main-hand
// weapon glyph — it hangs off the LEFT of the body and needs no extra room.)
// Broken ranged; `OnShow` runs the pass. The main-hand glyph hangs off the body's left, so
// the offset leaves it out.
let mut inv: InventorySlots = Default::default();
inv[18] = slot(2504, Some((0, 20)));
s.set_inventory_slots(inv);
@@ -292,7 +262,6 @@ fn manage_pass_seats_the_frame_inside_the_cluster_edge() {
"a right-side glyph seats the frame 20 further left"
);
// Repair (hide), then damaged legs (show; body only): flush with the cluster edge.
s.set_inventory_slots(Default::default());
let mut inv: InventorySlots = Default::default();
inv[7] = slot(39, Some((3, 25)));
@@ -308,14 +277,8 @@ fn manage_pass_seats_the_frame_inside_the_cluster_edge() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The seat stays fresh when a side glyph arrives AFTER the frame is already shown — the real
/// login order, equipment streaming in slot by slot with the weapon breaking before the
/// off-hand's slot lands (char "One": Worn Shortsword 0/20, then Large Round Shield 3/35). The
/// weapon glyph hangs off the body's LEFT and needs no room; once the shield glyph appears on the
/// RIGHT the frame must pull 20 further in so its ~17-unit overhang clears the screen edge.
/// Regression: the offset only refreshed on the frame's own show/hide transition, so a glyph that
/// appeared while the frame stayed shown left it stale and hung the shield off-screen
/// (director-caught). The OnEvent re-manage keeps it fresh on every alert recompute.
/// Gear streams in slot by slot at login, so a side glyph can appear while the frame is shown;
/// the re-seat deviation still pulls it 20 in so the shield's ~17-unit overhang clears the edge.
#[test]
fn a_late_side_glyph_refreshes_the_seat_while_the_frame_stays_shown() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -338,7 +301,6 @@ fn a_late_side_glyph_refreshes_the_seat_while_the_frame_stays_shown() {
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Str("".into())]);
// 1) The weapon breaks first: the frame shows with only the LEFT-side weapon glyph → flush.
let mut inv: InventorySlots = Default::default();
inv[16] = slot(25, Some((0, 20)));
s.set_inventory_slots(inv);
@@ -353,8 +315,7 @@ fn a_late_side_glyph_refreshes_the_seat_while_the_frame_stays_shown() {
.unwrap();
assert_eq!(delta, 0.0, "the left-side weapon glyph needs no extra room");
// 2) The off-hand's slot lands later — the frame is ALREADY shown, so no show transition
// fires. The shield glyph now extends the art right; the seat must still pull in by 20.
// The frame is already shown, so no show transition fires.
let mut inv: InventorySlots = Default::default();
inv[16] = slot(25, Some((0, 20)));
inv[17] = slot(30, Some((3, 35)));
@@ -371,7 +332,6 @@ fn a_late_side_glyph_refreshes_the_seat_while_the_frame_stays_shown() {
delta, 20.0,
"a side glyph arriving while shown must still pull the frame 20 in"
);
// The director's symptom, directly: the shield glyph's right edge clears the screen.
let overhang: f32 = s
.eval("return DurabilityShield:GetRight() - GetScreenWidth()")
.unwrap();
@@ -382,11 +342,8 @@ fn a_late_side_glyph_refreshes_the_seat_while_the_frame_stays_shown() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The quest tracker drops below the durability guy instead of overlapping his corner (ref
/// UIParent.lua:1770 — QuestWatchFrame is last in the right-side walk, seated at the running
/// anchorY after the durability height is subtracted). Both frames anchor the same
/// MinimapCluster BOTTOMRIGHT; benilla shipped only the durability arm and left the tracker
/// colliding there (director-caught, over "The Captain's Chest").
/// `QuestWatchFrame` is last in the right-side walk, seated at the running `anchorY` after the
/// durability frame's height (`UIParent.lua:1770`).
#[test]
fn the_quest_tracker_stacks_below_the_durability_guy() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -419,7 +376,6 @@ fn the_quest_tracker_stacks_below_the_durability_guy() {
}
super::manifest::install_durability_reseat(&s).unwrap();
// A right-side glyph shows the 65-tall durability frame under the minimap.
let mut inv: InventorySlots = Default::default();
inv[18] = slot(2504, Some((0, 20)));
s.set_inventory_slots(inv);
@@ -1,12 +1,4 @@
//! The two enchant-apply confirms (decision 0928, EnchantConfirm.xml): the Lua wiring between the
//! events `spell::targeting`'s item-bind gate fires and the shared StaticPopup engine, driven
//! exactly as that gate drives it.
//!
//! What these pin is the seam, not the gate: that `BIND_ENCHANT` and `REPLACE_ENCHANT` reach a
//! popup at all, that the replace text takes its two enchant names in the reference's order (old
//! then new), and that each Yes lands on the right one of the two Lua globals — because
//! `BindEnchant()` and `ReplaceEnchant()` mean opposite things on the app side (re-run the gate vs
//! bind outright), and swapping them would silently skip a question.
//! The stock enchant confirms, driven as the item gate in `spell::targeting` fires them.
use benilla_ui::script::{EnchantConfirm, ScriptValue, UiScript};
@@ -28,8 +20,7 @@ fn setup() -> UiScript {
s
}
/// `BIND_ENCHANT` (event 402, no args) raises the bind warning, and its Okay calls `BindEnchant()`
/// — which on the app side re-enters `0x495d60` with the confirmed flag, not a send.
/// `BIND_ENCHANT` (event 402) has no args; Yes re-enters the gate (`0x495d60`), sending nothing.
#[test]
fn the_bind_confirm_shows_and_its_okay_queues_bind_enchant() {
benilla_formats::wow_data_or_skip!();
@@ -49,8 +40,7 @@ fn the_bind_confirm_shows_and_its_okay_queues_bind_enchant() {
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
}
/// `REPLACE_ENCHANT(old, new)` fills the GlobalStrings template in the reference's argument order
/// — the fire site pushes the enchant already on the item first, then the one about to land.
/// `REPLACE_ENCHANT` pushes the item's current enchant, then the new one; Yes binds outright.
#[test]
fn the_replace_confirm_names_the_old_enchant_first() {
benilla_formats::wow_data_or_skip!();
@@ -71,8 +61,7 @@ fn the_replace_confirm_names_the_old_enchant_first() {
assert_eq!(s.take_enchant_confirms(), vec![EnchantConfirm::Replace]);
}
/// No is silent, and so is ESC — declining sends nothing and tears nothing down, because the gate
/// returned before `BindTarget` and the targeting word is still standing.
/// The gate returned before `BindTarget`, so declining sends nothing and the cursor stays up.
#[test]
fn declining_either_confirm_queues_nothing() {
benilla_formats::wow_data_or_skip!();
@@ -90,9 +79,7 @@ fn declining_either_confirm_queues_nothing() {
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
}
/// `CURRENT_SPELL_CAST_CHANGED` takes both away — the reference's only teardown for them, and the
/// one that matters: the pending cast the popup is asking about is what makes the question mean
/// anything, so a cancelled or replaced cast must not leave a live Yes button behind.
/// `CURRENT_SPELL_CAST_CHANGED` is the confirms' only teardown (`UIParent.lua:449`).
#[test]
fn a_changed_pending_cast_dismisses_both() {
benilla_formats::wow_data_or_skip!();
@@ -1,18 +1,11 @@
//! 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.
//!
//! Every assertion here reads the **drawn quads**, not Lua state. It used to read
//! `UIErrorsFrameLine1:GetText()` — the hand-rolled version's three stacked FontStrings — and those
//! are gone with it: the widget draws its own message bands now, so what a player sees is the only
//! thing left to test, which is also the thing worth testing.
//! The stock errors frame (`UIErrorsFrame.xml`), a `<MessageFrame>`, read from its drawn quads:
//! the widget draws its own message lines, so there is no Lua state to read.
use benilla_ui::script::{ExtractedQuad, QuadContent, ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
/// The toast lines as drawn, **top row first**, each with its colour+alpha and its band bottom.
/// The drawn lines, top row first, each with its colour and alpha and its band's bottom.
fn toast_lines(s: &mut UiScript) -> Vec<(String, [f32; 4], f32)> {
s.resolve();
let mut v: Vec<(String, [f32; 4], f32)> = s
@@ -42,11 +35,9 @@ fn info_and_error_messages_stack_hold_and_expire() {
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
// nothing, where the hand-rolled version had to `Hide()` itself.
assert!(toast_lines(&mut s).is_empty());
// A quest progress toast: yellow (ref UIErrorsFrame.lua:12), on the top line.
// A quest progress toast, yellow (`UIErrorsFrame.lua:12`).
s.fire_event(
"UI_INFO_MESSAGE",
vec![ScriptValue::Str("Tough Wolf Meat: 2/8".into())],
@@ -57,8 +48,7 @@ fn info_and_error_messages_stack_hold_and_expire() {
assert_eq!(lines[0].0, "Tough Wolf Meat: 2/8");
assert_eq!(lines[0].1, [1.0, 1.0, 0.0, 1.0], "UI_INFO_MESSAGE yellow");
// A second message lands ON TOP (insertMode="TOP"), red for UI_ERROR_MESSAGE (lua:14), pushing
// the toast to the row below — the whole point of the attribute.
// insertMode="TOP": the red error (`UIErrorsFrame.lua:14`) lands above the toast.
s.fire_event(
"UI_ERROR_MESSAGE",
vec![ScriptValue::Str("You are too far away!".into())],
@@ -68,21 +58,20 @@ fn info_and_error_messages_stack_hold_and_expire() {
lines.iter().map(|l| l.0.as_str()).collect::<Vec<_>>(),
["You are too far away!", "Tough Wolf Meat: 2/8"]
);
// 0.1 comes back as 26/255: `AddMessage` byte-quantizes every channel round-half-up
// (`ftol(v*255 + 0.5)`), so the colour a message draws with is never quite the float handed in.
// 0.1 draws as 26/255: `AddMessage` rounds each channel to a byte (`ftol(v*255 + 0.5)`).
assert_eq!(
lines[0].1,
[1.0, 26.0 / 255.0, 26.0 / 255.0, 1.0],
"UI_ERROR_MESSAGE red, byte-quantized"
);
// Hold 5 s (the ref's displayDuration="5"), then the class ctor's 3 s ramp, then gone. The
// phase check is per-tick, so the tick that spends the last of the hold still draws full.
// Hold 5 s (displayDuration="5"), then the default 3 s fade; the tick that ends the hold
// still draws full.
s.tick(4.0);
assert_eq!(toast_lines(&mut s).len(), 2);
s.tick(2.0); // the hold is spent on this tick; the ramp starts on the next
s.tick(2.0); // the hold ends on this tick; the fade starts on the next
assert_eq!(toast_lines(&mut s).len(), 2);
s.tick(1.5); // half the ramp — still drawing, now dimmer
s.tick(1.5); // half the fade: dimmer, still drawing
let mid = toast_lines(&mut s);
assert_eq!(mid.len(), 2);
assert!(
@@ -90,20 +79,13 @@ fn info_and_error_messages_stack_hold_and_expire() {
"mid-ramp alpha: {:?}",
mid[0].1
);
s.tick(2.0); // ramp done — retired, and this class frees the line rather than blanking it
s.tick(2.0); // fade done: the line is freed, not blanked
assert!(toast_lines(&mut s).is_empty());
assert!(s.errors().is_empty(), "expiry errors: {:?}", s.errors());
}
/// **The toast must outrank an open panel window.** `UIErrorsFrame` is 512x60 at TOP (0,-122)
/// and every left-slot panel is 384-wide at TOPLEFT (0,-104), so they overlap. The reference puts
/// this frame in HIGH (`UIErrorsFrame.xml` l.4) and benilla had dropped it, leaving it in the
/// default MEDIUM alongside the panels.
///
/// That is not a tie: a panel carries nested child frames (level 1+) while the toast's content
/// belongs to the errors frame itself (level 0), and level outranks insertion in the draw key. So
/// the toast lost to any open panel — invisible in exactly the state you most want to read it. Same
/// defect as the party frame's in decision 0597, one stratum up.
/// `UIErrorsFrame` overlaps the left-slot panels and outranks them by stratum, HIGH
/// (`UIErrorsFrame.xml:4`); within one stratum a panel's child frames would win on level.
#[test]
fn an_error_toast_draws_over_an_open_panel_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -122,18 +104,17 @@ fn an_error_toast_draws_over_an_open_panel_window() {
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "ScrollTemplates.xml"); // ours: the scroll kits
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, "Interface\\FrameXML\\MerchantFrame.xml");
// A missing template only warns at load, so an under-loaded list fails at its first update.
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.
// A left-slot panel opens, then the toast: stratum, not order, must decide.
s.eval::<()>("ShowUIPanel(QuestLogFrame)").unwrap();
s.fire_event(
"UI_ERROR_MESSAGE",
@@ -158,22 +139,13 @@ fn an_error_toast_draws_over_an_open_panel_window() {
panel_ceiling > 0,
"sanity: the panel must be drawing something at all"
);
// Everything the quest log draws is below the toast, and by STRATUM — not by luck within one.
//
// "Above" is measured against **other frames**: UIErrorsFrame's own declared `<FontString>` —
// the child that supplies the message font and the CENTER justification — is a real region of
// the frame and sorts above its message bands, exactly as a chat frame's does. It carries no
// text and draws nothing, so excluding the frame's own quads is what keeps this test about the
// thing it is named for.
// Only other frames count: the frame's own declared `<FontString>` sorts above its message
// lines but carries no text.
let above: Vec<&ExtractedQuad> = quads
.iter()
.filter(|q| q.z > toast)
// `RaidWarningFrame` is excluded on exactly the same grounds, and it is the reference's
// own arrangement rather than ours: it is HIGH + toplevel too (ref RaidWarning.xml:4), so
// it raises above the toast in z — but it sits BELOW UIErrorsFrame on screen (anchored to
// its BOTTOM at -10) and its declared `<FontString>` carries no text, so nothing of it is
// ever painted over the toast. Excluding it keeps this test about a PANEL WINDOW drawing
// over the toast, which is what it is named for.
// `RaidWarningFrame` is HIGH and toplevel too (`RaidWarning.xml:4`) but sits below this
// frame on screen, and its `<FontString>` carries no text.
.filter(|q| {
!matches!(
s.quad_owner_name(q.target).as_deref(),
+46 -124
View File
@@ -1,24 +1,12 @@
//! **The ESCAPE ladder** — `ToggleGameMenu`'s `elseif` chain (`UIParent.lua:1465-1497`) and the
//! host-glue precedence around it (a focused EditBox eats the key before the binding ever runs).
//!
//! The window these tests park in the ladder's way is a BAG, and since decision 1751 that is the
//! reference's own `ContainerFrame1..12`, executed off the player's installed patch chain — not
//! `BenillaBagFrame`, which no longer exists. Two consequences run through the file:
//!
//! * a bag test loads [`BAG_UI`] and opens with `wow_data_or_skip!`, because a chain entry needs
//! client data; and
//! * "is the bag open?" is asked of [`bag_open`] (the reference's own `IsBagOpen` scan), never of
//! a frame by name — the twelve windows are RECYCLED, so which one a bag lands in is a
//! coincidence and not a property.
//!
//! The ladder itself is unchanged by that swap: `CloseAllWindows` (ours, `UiPanels.xml`) sweeps
//! `ContainerFrame1..NUM_CONTAINER_FRAMES` where it used to sweep our five named windows.
//! The ESC ladder: stock `ToggleGameMenu`'s `elseif` chain (`UIParent.lua:1465-1497`), which a
//! focused edit box preempts. The bag windows are recycled, so a bag is asked of `IsBagOpen`,
//! never by frame name.
use benilla_ui::script::{ContainerSlot, ContainerState, LootRow, LootState, UiScript};
use super::test_ui::{bag_open, bag_slot_button, click, load_ui as load_xml, BAG_UI};
/// A one-item backpack (slot 1 holds a resolved item, so it is pickable onto the cursor).
/// A backpack with one resolved item in slot 1, so it can be picked up.
fn one_item_backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
@@ -50,14 +38,8 @@ fn one_item_backpack() -> ContainerState {
}
}
/// Load every window and drive the whole ESC-close path (UIParent.lua ToggleGameMenu → l.1491
/// CloseAllWindows): with the bag open, the loot window on
/// the left slot, and an item on the cursor, ESC closes the bag AND the panel slot, releases the
/// loot (OnHide → CloseLoot), and drops the held cursor. Also asserts the host-glue precedence: no
/// EditBox focused ⇒ `key_input("ESCAPE")` does not consume, so the app runs the binding.
///
/// The bag half is the reference's window now (1751), so what `CloseAllWindows` must sweep is a
/// `ContainerFrame` — the same assertion, asked through `IsBagOpen` instead of a frame name.
/// `CloseAllWindows` (`UIParent.lua:1491`) closes the bag and the loot panel, whose `OnHide`
/// releases the loot; with no edit box focused the key is left to the binding.
#[test]
fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -66,26 +48,22 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
for file in BAG_UI {
load_xml(&s, file);
}
// The loot window is the reference's own since 1751, and its dependency list grew with the
// swap — `LOOT_UI` carries the why for each entry. Loaded whole rather than cherry-picked:
// `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.
// `LOOT_UI` whole: a missing `ItemButtonTemplate` is only a load warning, so 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
continue; // a file loads once; `BAG_UI` carried it
}
load_xml(&s, file);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
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).
// Stock `ContainerFrame.lua` reads `MerchantFrame:IsShown()`.
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
// Open the bag and the loot window; drain the open kits (not under test here).
// Open the bag and the loot window; drain their open sounds.
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
@@ -105,7 +83,6 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
let _ = s.take_sounds();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Pick up slot 1 onto the cursor.
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "cursor holds the picked item");
assert!(bag_open(&s, 0), "bag is open before ESC");
@@ -115,13 +92,12 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
"loot holds the left panel slot before ESC"
);
// Precedence: no EditBox focused ⇒ ESCAPE is NOT consumed, so the host runs the binding.
assert!(
!s.key_input("ESCAPE"),
"no EditBox focused ⇒ ESC is not consumed by the box layer"
);
// The escape binding (what the host runs on the unconsumed ESC).
// The binding the host runs on an unconsumed ESC.
s.run("ToggleGameMenu()").unwrap();
assert!(!bag_open(&s, 0), "ESC closed the bag");
@@ -137,11 +113,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
s.take_loot_close(),
"closing the loot fired the release (OnHide → CloseLoot)"
);
// **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 the ESC→ClearCursor wiring is inferred to
// live in FrameXML rather than the engine — so in 1.12 the held item survives the key
// (1988).
// Stock `ToggleGameMenu` has no `ClearCursor` arm, so the held item survives ESC.
assert!(
s.cursor_item().is_some(),
"the held item survives ESC, as it does in the reference"
@@ -149,10 +121,8 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The other precedence side: a focused EditBox consumes ESCAPE (`key_input` returns true), so the
/// host's `!consumed` gate never runs the escape binding while typing — the bag stays open. Drives
/// the shipped chat box (the app's ENTER-to-open path is `focus_editbox`); its own OnEscapePressed
/// clears the focus (chat_tests covers the box's submit/close contract itself).
/// A focused edit box consumes ESC, so the binding never runs; the chat box's `OnEscapePressed`
/// clears its focus.
#[test]
fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -161,7 +131,7 @@ 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, "ScrollTemplates.xml"); // our scroll kits
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
@@ -184,9 +154,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
);
assert!(s.has_keyboard_focus());
// ESCAPE is consumed by the focused box — the host would NOT run the escape binding.
assert!(s.key_input("ESCAPE"), "a focused EditBox consumes ESCAPE");
// The box's OnEscapePressed cleared its own focus, but the bag window is untouched.
assert!(!s.has_keyboard_focus(), "the box cleared its focus");
assert!(
bag_open(&s, 0),
@@ -195,11 +163,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The options rung (decision 0950; the ref's own `elseif OptionsFrame:IsVisible()` at
/// UIParent.lua l.1483-1484, BETWEEN the popup rung and the menu rung): ESC with the options
/// window up closes it — hide, not cancel-click: benilla applies changes live — and eats the
/// press, so the menu does NOT open on the same stroke. Only the NEXT press, with nothing left
/// to eat, opens the menu (one eater per press, the 0449 law).
/// The options rung (`UIParent.lua:1483-1484`) eats the press, so the menu opens only on the next.
#[test]
fn escape_closes_the_options_window_before_opening_the_menu() {
benilla_formats::wow_data_or_skip!();
@@ -217,7 +181,7 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
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, "ScrollTemplates.xml"); // the Keybindings page's faux-scroll kit
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "GameMenuFrame.xml");
@@ -231,7 +195,6 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
"the menu is down — the options rung is what must eat this press"
);
// Press 1: the options rung eats it — the window closes and the menu stays down.
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
@@ -243,7 +206,6 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
"…and did NOT also open the menu — one eater per press"
);
// Press 2: nothing left to eat — the menu opens.
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
@@ -252,16 +214,8 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// ESC closes an open stack-split spinner (decision 0216 §6/slice 2, StackSplit.xml): this engine
/// has no plain-frame keyboard capture (the real client's own `StackSplitFrame` OnKeyDown ESCAPE
/// arm can't be driven), so the hook rides `ToggleGameMenu`'s shared chain instead — checked
/// right after the confirm popup and before the world map, both similarly transient overlays.
///
/// The spinner is opened by the REFERENCE's own shift fork now (1751,
/// `ContainerFrame.lua:567-577`), reached by a real SHIFT + left-click on a `ContainerFrame` item
/// button — which is why `ChatFrame.xml` is loaded: that fork's first test is
/// `ChatFrameEditBox:IsShown()` (a shift-click links the item into an open chat box instead of
/// splitting it), and the reference indexes that global unguarded.
/// `CloseAllWindows` hides the bag, and the item button's `OnHide` hides the split frame it opened
/// (`ContainerFrame.xml:35-39`).
#[test]
fn escape_closes_an_open_stack_split_frame() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -270,13 +224,13 @@ 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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // ContainerFrameItemButton_OnClick reads MerchantFrame
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // read by the stock bag-slot click
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\\ChatFrame.xml"); // ChatFrameEditBox, for the shift fork
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -319,9 +273,7 @@ fn escape_closes_an_open_stack_split_frame() {
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0), "the backpack window is up");
// Open the spinner with a real SHIFT + left-click on the button holding slot 1 — asked of the
// buttons' own `GetID()`, never by index (`ContainerFrame_GenerateFrame` numbers them
// backwards). The click goes through the engine because the reference's handler reads `this`.
// A real shift-click: the stock handler reads `this`, and the buttons number backwards.
let slot1 = bag_slot_button(&s, 0, 1);
s.set_modifiers(true, false, false);
click(&mut s, &slot1, "LeftButton");
@@ -333,20 +285,13 @@ fn escape_closes_an_open_stack_split_frame() {
!s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
"ESC closed the split frame"
);
// ESC's unconditional cursor-clear (ToggleGameMenu's own first line) ran too — nothing was
// held anyway (the split branch already released it), so this just confirms no error.
// The shift fork never picked the item up.
assert!(s.cursor_item().is_none());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The ESC ladder end-to-end (the ref's `ToggleGameMenu` order, `UIParent.lua:1482-1496`, one
/// eater per press — the director's two-press report, decision 0449): mid-cast with a menu
/// open, a bag open and a target, the press closes the MENU first (`CloseMenus`, `l.1488` —
/// the cast survives), the next only cancels the cast (`SpellStopCasting`, `l.1489`), the next
/// only closes the windows (`CloseAllWindows`, `l.1491`), and only then — nothing left to
/// eat — does one drop the target (`ClearTarget`, `l.1492`). The 1/nil returns are load-bearing
/// at every rung (an unconditional true anywhere would wedge the ladder; the artifact's
/// `elseif` chain is the proof each real binding is conditional).
/// One rung eats each press, in stock order (`UIParent.lua:1482-1496`): menus, the cast, the
/// windows, then the target. Each binding answers 1 only when it acted.
#[test]
fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
use benilla_ui::script::UnitState;
@@ -357,10 +302,9 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
for file in BAG_UI {
load_xml(&s, file);
}
// 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.
// The dropdown backdrop reads `TOOLTIP_DEFAULT_COLOR`, which `BAG_UI`'s tooltip file sets.
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -376,15 +320,10 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
}),
);
// Press 0 — mid-cast with a dropdown open (the ref's CloseMenus rung, l.1488): the menu
// closes and eats the press — the cast, the windows, and the target all survive.
// Press 0, mid-cast with a dropdown open: `CloseMenus` eats it.
s.set_casting(true);
// `maxWidth` before the Show: stock `DropDownList`'s own `<OnShow>` sizes every button from it
// (ref UIDropDownMenuTemplates.xml:237-245), and `UIDropDownMenu_Initialize` is what normally
// sets it — so showing the list bare raises `SetWidth(nil)` in the real client too. Our deleted
// transcription had no such OnShow, which is why a bare Show worked here before. The subject of
// this test is the ESC ladder, not the menu's construction, so it sets the one field the show
// path reads rather than standing up a whole anchored dropdown.
// Stock `DropDownList` `OnShow` sizes its buttons from `maxWidth`, which
// `UIDropDownMenu_Initialize` normally sets (`UIDropDownMenuTemplates.xml:237-245`).
s.run("DropDownList1.maxWidth = 100 DropDownList1:Show()")
.unwrap();
s.run("ToggleGameMenu()").unwrap();
@@ -403,7 +342,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
);
assert!(!s.take_target_clear());
// Press 1 — mid-cast (the app's per-frame IsCasting mirror): the cast dies, NOTHING else.
// Press 1, mid-cast: `SpellStopCasting` eats it.
s.set_casting(true);
s.run("ToggleGameMenu()").unwrap();
assert!(
@@ -419,7 +358,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
"the same press must NOT also drop the target (the raw-key double-fire 0449 retires)"
);
// Press 2 — the cancel resolved (next frame's mirror push): the windows close, target stays.
// Press 2, the cast over: `CloseAllWindows` eats it.
s.set_casting(false);
s.run("ToggleGameMenu()").unwrap();
assert!(
@@ -432,16 +371,14 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
"a press CloseAllWindows ate must not reach ClearTarget"
);
// Press 3 — nothing left to eat: the target drops.
// Press 3: `ClearTarget`.
s.run("ToggleGameMenu()").unwrap();
assert!(
s.take_target_clear(),
"the bare press reaches ClearTarget — the ladder's last rung"
);
// Press 4 — no target either: the chain runs out. In a full UI this press opens the game menu
// (decision 0674, `game_menu_tests`); this harness deliberately loads no GameMenuFrame.xml, so
// what it pins is the rung BELOW it — ClearTarget answering nil rather than eating the press.
// Press 4, no target: `ClearTarget` answers nil; the menu it falls through to is not loaded.
s.set_unit("target", None);
s.run("ToggleGameMenu()").unwrap();
assert!(
@@ -451,10 +388,8 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The SpellStopTargeting rung (`UIParent.lua:1490`, decision 0792): ESC with the ground-target
/// cursor up cancels the targeting and ONLY the targeting — after the cast rung (the artifact's
/// order), before the window close. The 1/nil returns are load-bearing exactly like the cast
/// rung's: an idle press must fall straight through both.
/// The `SpellStopTargeting` rung (`UIParent.lua:1490`) sits after the cast rung and before the
/// windows; idle, both answer nil.
#[test]
fn escape_ladder_targeting_rung_after_cast_before_windows() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -463,7 +398,7 @@ 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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -472,12 +407,12 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
let _ = s.take_sounds();
assert!(bag_open(&s, 0));
// SpellIsTargeting() mirrors the pushed state — the PetFrame-family readers' predicate.
// `SpellIsTargeting()` mirrors the pushed state.
assert!(!s.eval::<bool>("return SpellIsTargeting()").unwrap_or(false));
s.set_spell_targeting(true);
assert!(s.eval::<bool>("return SpellIsTargeting()").unwrap());
// Press 1 — targeting, no cast in flight: SpellStopTargeting eats the press; the bag stays.
// Press 1, targeting: `SpellStopTargeting` eats it.
s.run("ToggleGameMenu()").unwrap();
assert!(
s.take_stop_targeting(),
@@ -492,8 +427,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
"ESC while targeting must NOT also close windows"
);
// Press 2 — BOTH casting and targeting pushed (unreachable app state, but the chain's order
// is the artifact's law): the cast rung sits first (l.1489 before l.1490) and eats alone.
// Press 2, casting and targeting both (a state the app never pushes): the cast rung is first.
s.set_casting(true);
s.run("ToggleGameMenu()").unwrap();
assert!(s.take_spell_stop(), "the cast rung eats first");
@@ -502,8 +436,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
"the same press must not also cancel the targeting"
);
// Press 3 — idle (the app cleared the mode and the cast resolved): both rungs answer nil
// and the press falls through to CloseAllWindows.
// Press 3, idle: both rungs answer nil and `CloseAllWindows` eats it.
s.set_casting(false);
s.set_spell_targeting(false);
s.run("ToggleGameMenu()").unwrap();
@@ -515,15 +448,8 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`UISpecialFrames` — an addon's own ESC list** (decision 1206).
///
/// `tinsert(UISpecialFrames, "MyFrame")` is *the* 1.12 idiom for "ESC should close my window", and
/// 34 corpus call sites use it. With the table absent that line was
/// `bad argument #1 to 'tinsert' (table expected, got nil)` — 11 addons dead at load, the
/// second-largest wall in the survey.
///
/// The walk is the reference's own (`UIParent.lua` l.947-954, inside `CloseWindows`): a plain
/// `Hide`, never `HideUIPanel`, because these frames hold no panel slot.
/// `tinsert(UISpecialFrames, name)` is the 1.12 addon idiom for closing on ESC; stock
/// `CloseWindows` gives each a plain `Hide`, as they hold no panel slot (`UIParent.lua:947-954`).
#[test]
fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
benilla_formats::wow_data_or_skip!();
@@ -541,7 +467,6 @@ fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
load_xml(&s, "GameMenuFrame.xml");
// The addon's three lines, verbatim in shape.
s.run(
r#"
MyAddonWindow = CreateFrame("Frame", "MyAddonWindow", UIParent)
@@ -558,15 +483,12 @@ fn an_addon_frame_registered_in_uispecialframes_closes_on_escape() {
!s.eval::<bool>("return MyAddonWindow:IsVisible()").unwrap(),
"ESC closed the addon's window"
);
// ...and it ATE the press: the menu must not also open, or ESC would close the window and
// raise the menu in one keystroke.
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"CloseAllWindows returning truthy is what stops the chain (UIParent.lua l.1491)"
);
// A name in the table that resolves to nothing is skipped, not an error — the reference's own
// `if (frame and frame:IsVisible())`.
// A name with no frame is skipped: `if ( frame and frame:IsVisible() )`.
s.run(r#"tinsert(UISpecialFrames, "NoSuchFrameAnywhere") ToggleGameMenu()"#)
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1,50 +1,9 @@
//! **The event ARGUMENT-shape gate** — `reference/1.12-events.tsv` against what benilla actually
//! pushes at each fire site (decision 2140).
//! The event argument-shape gate: every fire site with a literal name against
//! `reference/1.12-events.tsv`, since an event name is a plain string at both ends.
//!
//! 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 two byte-level censuses of the reference, 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.
//! Only `exact` rows gate: an `advisory` row's site declares more varargs than its caller pushes,
//! and a `none` row has no producer in the census, which does not mean it has none. Only a
//! `ScriptValue::` element's kind is asserted; any other argument counts toward the arity alone.
use std::collections::BTreeMap;
/// One row of `reference/1.12-events.tsv`.
@@ -96,7 +55,7 @@ fn table() -> BTreeMap<String, Row> {
out
}
/// Split `body` at its top-level commas — `vec![…]`'s elements, with nested calls left alone.
/// Splits `body` at its top-level commas: `vec![…]`'s elements, nested calls left whole.
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);
@@ -177,9 +136,7 @@ fn fire_sites() -> Vec<(String, Vec<char>, String)> {
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.
// Test fires are not shipped: skip test files and every `#[cfg(test)]` tail.
if p.to_string_lossy().contains("test") {
continue;
}
@@ -187,7 +144,7 @@ fn fire_sites() -> Vec<(String, Vec<char>, String)> {
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).
// Split so this file cannot match its own scanner.
for call in [
concat!("fire_event", "("),
concat!("fire_event_into", "("),
@@ -228,7 +185,7 @@ fn fire_sites() -> Vec<(String, Vec<char>, String)> {
} else if args.starts_with("Vec::new()") {
Vec::new()
} else {
// An argument list built elsewhere — arity unknowable from here.
// An argument list built elsewhere: arity unknowable here.
continue;
};
out.push((name.to_string(), kinds, p.display().to_string()));
@@ -239,7 +196,6 @@ fn fire_sites() -> Vec<(String, Vec<char>, 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();
@@ -297,27 +253,12 @@ fn every_event_we_fire_carries_the_reference_s_arguments() {
);
}
/// **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.
/// `0x51bbb0` registers one watch per named unit-window field, so each of the 25 named event ids
/// below `0xb6` comes from that bridge with the unit token, and each needs a producer here.
#[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.
/// Bridge events nothing here produces, each with its reason; an entry that gains a producer
/// fails the gate.
const UNPRODUCED: &[(&str, &str)] = &[(
"UNIT_LOYALTY",
"field 138, the hunter pet's loyalty level. benilla's pet-stat feed (`ui_pet_stats`) \
@@ -341,9 +282,8 @@ fn every_unit_field_bridge_event_has_a_producer() {
.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.
// The ten power events are built with `format!("UNIT_{}", power_token(…))`, so there
// is no literal to scan; `reference_ui`'s `CONSTRUCTED` list declares the same family.
[
"MANA",
"RAGE",
@@ -1,19 +1,10 @@
//! The XP bar's own tests — the hover plate, the rested/exhaustion-tick system and the
//! on-bar numerals (decision 1082) — split from `action_bar_tests.rs`, which keeps the
//! action-button machinery. Same file, two concerns: the strip along the bar's top is the
//! player-progress readout; the buttons are the input surface.
//! The XP bar: its hover plate, the exhaustion tick and the on-bar numerals.
use benilla_ui::script::{QuadContent, ScriptValue, UiScript, UnitState};
/// The XP bar's load set: the manifest prefix it actually needs — fonts, UIParent, the
/// tooltip, `TextStatusBar.xml` (the numerals machinery `BenillaExpBar_OnLoad` wires in),
/// then Cooldown + the bar itself (manifest order).
fn exp_bar_harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Through the shared both-stores loader, not a disk read off `assets/ui`: `TextStatusBar.xml`
// 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 [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
@@ -28,10 +19,8 @@ fn exp_bar_harness() -> UiScript {
"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).
// `ExhaustionTick_Update` reads `ReputationWatchBar`, which `ReputationFrame.xml`
// declares; the two template files before it are what its check boxes inherit.
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
@@ -39,27 +28,18 @@ fn exp_bar_harness() -> UiScript {
] {
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.
// The stock default, detailed tips on (`UIOptionsFrame.lua:100`); no options file loads here.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s
}
/// The XP bar's hover (ref-MainMenuBar.xml l.136-147): the strip takes the mouse, and the plate is
/// the ref's two-line `GameTooltip_AddNewbieTip` — "XP Bar" over the wrapped explanation.
///
/// `enableMouse` is the load-bearing half and the easy thing to lose: without it the strip is
/// transparent and the hover silently never fires, which no tooltip assertion alone would catch.
#[test]
fn the_xp_bar_takes_the_mouse_and_explains_itself() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.resolve();
// Mid-height of the strip (the bar's top 13 px), a quarter of the way along — NOT its
// horizontal center, where the page arrows straddle the strip's lower edge and rightly take
// the mouse ahead of it.
// A quarter along, not the centre, where the page arrows overlap the strip and take the mouse.
let (x, y) = s
.eval::<(f64, f64)>(
"return MainMenuExpBar:GetLeft() + MainMenuExpBar:GetWidth() / 4, \
@@ -100,11 +80,8 @@ fn the_xp_bar_takes_the_mouse_and_explains_itself() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The exhaustion tick (decision 1082, ref `ExhaustionTick_Update`): with 1000/10000 XP and a
/// rested pool of 700 base-XP, the doubled span is 1400 bar-XP, so the tick parks at
/// (1000+1400)/10000 of the strip's width and the pale fill stretches exactly to it; the bar
/// paints rested blue. Draining the pool (normal state) hides both and returns the purple; a
/// pool whose span runs past the level's end hides the tick but keeps the rested blue.
/// A 700 rested pool doubles to a 1400 XP span, so at 1000/10000 the tick sits at 2400/10000 of
/// the strip (`ExhaustionTick_Update`).
#[test]
fn the_exhaustion_tick_marks_where_rested_runs_out() {
benilla_formats::wow_data_or_skip!();
@@ -115,8 +92,7 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
// The binding trio underneath (the app-feed shape the XML consumes) — the reference's
// contract (`0x48d350`, `0x48d3f0`, `0x516ea0`; decision 1087).
// The three bindings under the bar (`0x48d350`, `0x48d3f0`, `0x516ea0`).
let (id, name, mult) = s
.eval::<(i64, String, f64)>("return GetRestState()")
.unwrap();
@@ -150,7 +126,6 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
.unwrap();
assert!(ok, "tick at the rested boundary, fill up to it, bar blue");
// The pool drains dry: state normal, no threshold — tick and fill gone, purple back.
s.set_rest_state(2, 0, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
let ok: bool = s
@@ -166,10 +141,9 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
.unwrap();
assert!(ok, "a dry pool hides the tick and returns the purple bar");
// The nil law is the BYTE's, not the pool's (0x48d3f0's `dec/jne`, decision 1087): a normal-
// state player with a nonzero pool — vmangos's 0 < bonus ≤ 10 hysteresis window — still reads
// nil, and a rested player with a drained pool reads the NUMBER 0 (the tick then parks at the
// bar's current fill). An unmapped byte (0 here) is the binary's nil-triple fail path.
// `GetXPExhaustion`'s nil follows the rest-state byte, not the pool (`0x48d3f0`): normal with
// a remnant pool (the server's 0 < bonus <= 10 window) is nil, rested and drained is 0. An
// unmapped byte fails `GetRestState` to three nils.
s.set_rest_state(2, 5, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
assert_eq!(
@@ -193,8 +167,7 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
s.set_rest_state(2, 0, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
// A pool past the level's end (6000×2 span from 1000/10000): off the strip → hidden tick,
// still rested blue (the ref's exhaustionTickSet > width branch).
// 6000 doubled runs past the level's end: `exhaustionTickSet > width` hides the tick.
s.set_rest_state(1, 6000, false);
s.fire_event("UPDATE_EXHAUSTION", vec![]);
let ok: bool = s
@@ -214,12 +187,8 @@ fn the_exhaustion_tick_marks_where_rested_runs_out() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The max-level rail (decision 1094, ref `ReputationWatchBar_Update`'s no-watched-faction arm):
/// at MAX_PLAYER_LEVEL the XP strip and its tick give way to the flat brass rail; below it the
/// strip is back. Walked over a live ding — 59 → 60 via `PLAYER_LEVEL_UP`'s arg1, the value the
/// ref reads (not the level field, which may not have landed yet). The tick's own handler runs
/// first (it loaded first) and re-shows the tick off the rested pool; the rail's handler then
/// hides it — the ref's own load order, ending hidden.
/// `ReputationWatchBar_Update` swaps in the rail at `MAX_PLAYER_LEVEL`, read from
/// `PLAYER_LEVEL_UP`'s arg1; the tick's handler runs first by load order, so the rail's hides it.
#[test]
fn the_max_level_rail_replaces_the_xp_bar_at_60() {
benilla_formats::wow_data_or_skip!();
@@ -266,20 +235,15 @@ fn the_max_level_rail_replaces_the_xp_bar_at_60() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The gryphon end caps stay over the strip and the rail across ANY show/hide history —
/// including the faithful hidden→visible tail re-stamp (0x76ae10 / 0884, which a stale-rail
/// recovery exercises: decision 1106's whole bug walked through here). The protection is
/// 0884's own key order: the caps ride the art frame's OVERLAY and the strip's fill its
/// ARTWORK, and within a `(strata, level)` bucket the LAYER outranks every stamp — no
/// re-stamp can lift a lower layer over them.
/// A hidden-to-visible show re-stamps a frame to the tail (`0x76ae10`), but within a
/// `(strata, level)` bucket the layer outranks every stamp, so the `OVERLAY` caps stay on top.
#[test]
fn the_gryphons_outrank_the_bars_across_hide_show_cycles() {
benilla_formats::wow_data_or_skip!();
let mut s = exp_bar_harness();
s.set_player_xp(300, 400);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The relog shape: the strip hidden (a stale max-level rail), then re-shown; the rail's
// own arrival is ALWAYS a hidden→visible re-stamp (it is born hidden).
// The strip hidden then re-shown, and the rail, born hidden, shown: both re-stamp.
s.run("MainMenuExpBar:Hide() MainMenuExpBar:Show()")
.unwrap();
s.run("MainMenuBarMaxLevelBar:Show()").unwrap();
@@ -306,9 +270,7 @@ fn the_gryphons_outrank_the_bars_across_hide_show_cycles() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hover numerals (decision 1082, ref TextStatusBar.lua over `MainMenuBarExpText`): entering
/// the strip shows "curr / max" centered on the bar, leaving hides it (the lockShow refcount's
/// 0↔1 edge — the `statusBarText` cvar that pins it on permanently reads OFF here).
/// `TextStatusBar.lua`'s `lockShow` count shows the numerals on hover; `statusBarText` is off.
#[test]
fn the_xp_bar_numerals_show_on_hover() {
benilla_formats::wow_data_or_skip!();
@@ -327,10 +289,8 @@ fn the_xp_bar_numerals_show_on_hover() {
.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap());
// `this` bound, because the engine binds it: the stock `HideTextStatusBarText` reads
// `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.
// `HideTextStatusBarText` reads `this.isZero` where it means `bar` (`TextStatusBar.lua:95`);
// the engine always binds `this` during dispatch, so the test does too.
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnLeave\")() this = nil")
.unwrap();
assert!(
@@ -341,10 +301,6 @@ fn the_xp_bar_numerals_show_on_hover() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The rest-state tooltip line (decision 1082, ref `ExhaustionToolTipText`'s newbie-tips branch):
/// hovering the BAR arms a 1 s timer and the canAddRestStateLine handshake; when the timer runs
/// out on the bar's OnUpdate, the "Rested / 200% of normal experience" line is APPENDED to the
/// held-open newbie tip, once (the handshake is consumed).
#[test]
fn the_rest_state_line_joins_the_held_open_tooltip() {
benilla_formats::wow_data_or_skip!();
@@ -353,7 +309,6 @@ fn the_rest_state_line_joins_the_held_open_tooltip() {
s.set_rest_state(1, 700, true);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// 1.12's default posture (0661): detailed newbie tips ON.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnEnter\")()")
.unwrap();
@@ -363,7 +318,7 @@ fn the_rest_state_line_joins_the_held_open_tooltip() {
"the bar hover arms the tick's timer"
);
// Three OnUpdate ticks walk the timer 1 → 0.4 → −0.2 → fire (the ref's `< 0` edge).
// Three 0.6 s ticks take the timer from 1 to below 0, the `< 0` edge.
for _ in 0..3 {
s.run("ExhaustionTick_OnUpdate(0.6)").unwrap();
}
@@ -1,69 +1,31 @@
//! The colour picker's **generated art** — the hue disc and the brightness ramp.
//!
//! The reference's `<ColorWheelTexture>` and `<ColorValueTexture>` (`ColorPickerFrame.xml` l.186-221)
//! carry no `file=`, and that is not an oversight: there is no BLP anywhere in the 1.12 MPQ chain
//! that is a colour wheel. The client computes those pixels. The engine core therefore hands us the
//! rect and the widget's HSV ([`QuadContent::ColorWheel`]/[`QuadContent::ColorValue`]) and the
//! pixels are made here — the same division of labour the `<Minimap>` slot uses.
//!
//! **Both images are static**, which is why a live drag regenerates nothing: the disc does not
//! depend on the colour at all, and the strip is `rgb(h, s, 1)` times a fixed greyscale ramp
//! (`hsv_to_rgb` scales all three outputs by `v` and by nothing else, so `rgb(h, s, v)` really is
//! `v · rgb(h, s, 1)`).
//!
//! The reference's drawn appearance, and what each fact cost to get wrong:
//!
//! * **The disc is a 128×128 generated ARGB texture** (`0x78b580`, 16384 dwords into a
//! function-local static, uploaded once per process), drawn unmodulated — not a vertex-coloured
//! fan. It never reaches the gradient helper `0x77f910`.
//! * **It is drawn at `V = 1.0`, a `mov`-immediate (`0x78b68b mov [ebp-0x24],0x3f800000`), never
//! the widget's `[this+0x330]`.** Lowering the brightness slider does not darken the wheel — the
//! thumb moves and nothing else does. This file first shipped the opposite, as a flagged guess.
//! * **The texel lattice is integers**, `X, Y ∈ [−64, 63]`, drawn iff `X² + Y² ≤ 0x1000` —
//! boundary *inclusive* — and outside is `0x00000000`, a hard alpha cut with no mask and no
//! feathering. `S = √(X²+Y²)/64`; `H = (atan2(Y, X) + π)·180/π`, the exact inverse of the pick
//! law (`0x78bd80`), which is what makes clicking a texel select the colour
//! that texel shows. Row 0 is `Y = +63`: the top.
//! * **The strip is an 8×8 solid texture of `HSV(H, S, 1)` times a vertical vertex gradient**,
//! black at the bottom and white at the top (`0x78b8a0` + `0x78b92a call 0x77f910`, and
//! `0x7705b0` fixes the winding TL/BL/TR/BR). The product is `HSV(H, S, v)` at every row — so
//! `V` moves no strip pixel either. We compute the same product the other way round, one static
//! greyscale ramp times the hue tint, which is exact because **this pass composites in gamma
//! bytes** (decision 0254): the quad's `color × texel` multiply *is* the client's.
//! The colour picker's generated art. The reference's `<ColorWheelTexture>` and
//! `<ColorValueTexture>` (`ColorPickerFrame.xml:186-215`) name no `file=`: the 1.12 client
//! computes the pixels. Both images are static, since `hsv_to_rgb` scales by `v` alone. The
//! client's strip is an 8×8 `HSV(H, S, 1)` texture times a vertex gradient, black at the bottom
//! and white at the top (`0x78b8a0`, calling `0x77f910` at `0x78b92a`; winding `0x7705b0`); a
//! static grey ramp times the hue tint is the same product, as this pass composites in gamma.
use benilla_ui::widget::ColorSelectState;
/// The disc's texel size — the client's own `0x80`, and the size of the reference's element, so at
/// the reference's 1024×768 the image draws 1:1.
/// The disc's texel size, the client's `0x80` and the XML element's 128: 1:1 at 1024×768.
pub(super) const WHEEL_PX: u32 = 128;
/// The ramp's texel height (its width is 1 — every column is identical). 256 rows is one step per
/// output level, so the gradient has no banding of its own to add.
/// The ramp's height, one texel per output level so it adds no banding; it is one texel wide.
pub(super) const RAMP_PX: u32 = 256;
/// The generated-sprite cache keys ([`benilla_assets::WorldAssets::generated_sprite`]).
/// Cache keys for [`benilla_assets::WorldAssets::generated_sprite`], which builds each image once.
pub(super) const WHEEL_KEY: &str = "colorselect/wheel";
pub(super) const RAMP_KEY: &str = "colorselect/value-ramp";
/// A colour channel `0.0..=1.0` as the byte an sRGB-format texture wants. The client's colours are
/// already gamma-space values — the same space `Rgba8UnormSrgb` art is authored in — so this is a
/// scale, not a transfer function.
/// A `0.0..=1.0` channel, already gamma-space, as a texel byte: a scale, not a transfer function.
fn byte(c: f32) -> u8 {
(c.clamp(0.0, 1.0) * 255.0 + 0.5) as u8
}
/// The hue disc: `WHEEL_PX²` RGBA texels, `0x00000000` outside the unit circle.
///
/// Faithful to `0x78b580` down to the lattice. The client walks **integer** `X, Y ∈ [−64, 63]` and
/// tests `X² + Y² ≤ 0x1000` in integers — so the circle is centred half a texel off the image
/// centre, its boundary texels are *inside*, and its edge is a hard alpha cut with no feathering.
/// Each of those is visible at the rim, and each is reproduced rather than improved: a
/// half-texel-recentred, anti-aliased disc would be a different circle from the one the pick law
/// inverts, and the pick law is what decides which colour a click lands on.
///
/// Row 0 is the image's TOP and `Y = +63` — the first dword the client writes is `(X=−64, Y=+63)`.
/// The widget's y runs up and the pick law reads the *cursor* in that space, so a flipped row order
/// would mirror every hue about the horizontal.
/// The hue disc as `0x78b580` fills it: integer `X, Y ∈ [−64, 63]`, drawn iff `X² + Y² ≤ 0x1000`
/// (the rim inside, a hard alpha cut, `0x00000000` outside), `S = √(X² + Y²)/64` and
/// `H = atan2(Y, X)` in degrees plus 180. Row 0 is the top, `Y = +63`. Not recentred or
/// anti-aliased: the pick law (`0x78bd80`) inverts exactly this lattice.
pub(super) fn wheel_pixels() -> (u32, u32, Vec<u8>) {
let n = WHEEL_PX as usize;
let half = (WHEEL_PX / 2) as i32;
@@ -75,7 +37,7 @@ pub(super) fn wheel_pixels() -> (u32, u32, Vec<u8>) {
let x = col as i32 - half;
let r2 = x * x + y * y;
if r2 > half * half {
continue; // outside: left as the transparent black it was initialised to
continue; // outside: stays transparent black
}
let hue = (y as f32).atan2(x as f32).to_degrees() + 180.0;
let sat = (r2 as f32).sqrt() / half as f32;
@@ -90,9 +52,7 @@ pub(super) fn wheel_pixels() -> (u32, u32, Vec<u8>) {
(WHEEL_PX, WHEEL_PX, rgba)
}
/// The brightness ramp: one column, white at the top and black at the bottom, which the strip's
/// quad tints to the live hue. `V` runs 0 at the strip's bottom (the pick law's
/// `(y − bottom)/(top − bottom)`), and row 0 is the top, so row 0 is `V = 1`.
/// The brightness ramp: one column, white at the top (`V = 1`) to black at the bottom (`V = 0`).
pub(super) fn ramp_pixels() -> (u32, u32, Vec<u8>) {
let h = RAMP_PX as usize;
let mut rgba = vec![0u8; h * 4];
@@ -108,18 +68,14 @@ pub(super) fn ramp_pixels() -> (u32, u32, Vec<u8>) {
(1, RAMP_PX, rgba)
}
/// The tint the **disc** quad draws with: nothing but the frame's alpha.
///
/// The wheel is drawn unmodulated at `V = 1`. That is a `mov`-immediate in the fill loop
/// (`0x78b68b`), not a read of `[this+0x330]`, and a census of `[edi+0x3NN]` over the whole
/// function finds only the two `+0x318` hits — so the brightness slider genuinely does not dim the
/// disc. It reads odd next to every other picker on the planet, and it is what the client does.
/// The disc's tint, the frame alpha alone: the reference draws the disc at `V = 1`, an immediate
/// in the fill loop (`0x78b68b`), not the widget's `[this+0x330]`, so the brightness slider never
/// dims it.
pub(super) fn wheel_tint(alpha: f32) -> [f32; 4] {
[1.0, 1.0, 1.0, alpha]
}
/// The tint the **ramp** quad draws with: the current hue and saturation at full value. The ramp's
/// own greyscale supplies the `v` factor, so the product is `rgb(h, s, v)` at every row.
/// The ramp's tint, `rgb(h, s, 1)`: times the ramp's grey it is `rgb(h, s, v)` at every row.
pub(super) fn ramp_tint(hue: f32, sat: f32, alpha: f32) -> [f32; 4] {
let rgb = ColorSelectState::hsv_to_rgb(&[hue, sat, 1.0]);
[rgb[0], rgb[1], rgb[2], alpha]
@@ -129,17 +85,12 @@ pub(super) fn ramp_tint(hue: f32, sat: f32, alpha: f32) -> [f32; 4] {
mod tests {
use super::*;
/// The texel's own `(X, Y)` — the client's integer lattice, `X, Y ∈ [−64, 63]`, row 0 at the
/// top. Stated once here so a test cannot quietly disagree with [`wheel_pixels`] about it.
fn lattice(col: usize, row: usize) -> (i32, i32) {
let half = (WHEEL_PX / 2) as i32;
(col as i32 - half, half - 1 - row as i32)
}
/// The one property the disc must have: the colour a texel *shows* is the colour a click on
/// that texel *selects*. The right-hand side here is the reference's pick law
/// (`0x78bd80`), which `benilla-ui`'s own `wheel_hs` transcribes — if the two
/// ever part company, one of these sides moves and this fails.
/// The right side is the reference's pick law (`0x78bd80`), as in `benilla-ui`'s `wheel_hs`.
#[test]
fn every_texel_shows_the_colour_a_click_on_it_would_pick() {
let (w, h, rgba) = wheel_pixels();
@@ -152,7 +103,6 @@ mod tests {
if x * x + y * y > (WHEEL_PX / 2).pow(2) as i32 {
continue;
}
// The pick law, in the widget's own normalised coordinates.
let (nx, ny) = (x as f32 / half, y as f32 / half);
let hue = (ny.atan2(nx) + std::f32::consts::PI).to_degrees();
let want =
@@ -166,10 +116,7 @@ mod tests {
}
}
/// The disc's cardinal points, spelled out — this is what pins the *orientation* of the whole
/// image, and it is the half a sign error would silently mirror. Off the reference's fill loop
/// (`0x78b580`): **red LEFT, chartreuse BOTTOM, cyan RIGHT, violet TOP**, hue rising
/// counter-clockwise.
/// The reference's fill loop (`0x78b580`): red left, chartreuse bottom, cyan right, violet top.
#[test]
fn the_discs_cardinal_hues_are_the_pick_laws() {
let (w, h, rgba) = wheel_pixels();
@@ -177,8 +124,7 @@ mod tests {
let i = (row * w as usize + col) * 4;
[rgba[i], rgba[i + 1], rgba[i + 2]]
};
// The row/col where the lattice coordinate is 0 — not the image centre, because the
// integer lattice is half a texel off it (`Y = 63 − row` has no zero-crossing at 63.5).
// `X = 0` at column 64 and `Y = 0` at row 63: the lattice is half a texel off centre.
let mid = (WHEEL_PX / 2) as usize;
let left = at(1, mid);
let right = at(w as usize - 2, mid);
@@ -202,9 +148,6 @@ mod tests {
);
}
/// The alpha is a hard cut at `X² + Y² ≤ 0x1000`, boundary **inclusive** — no feathering, no
/// mask. A disc with an opaque square around it would paint a black box over the window; a
/// disc one texel small would clip the fully-saturated rim the pick law can still reach.
#[test]
fn the_alpha_is_the_clients_hard_inclusive_cut() {
let (w, _, rgba) = wheel_pixels();
@@ -225,7 +168,7 @@ mod tests {
}
// The boundary itself: (0, −64) is exactly r² = 0x1000 and the client keeps it (`jg`).
assert_eq!(alpha(half as usize, n - 1), 255, "the cut is inclusive");
// Saturation 0 is white, and the lattice's zero is at (64, 63) — one texel up from centre.
// Saturation 0 is white, at the lattice's zero: texel (64, 63).
let c = ((half - 1) as usize * n + half as usize) * 4;
assert!(
rgba[c] > 250 && rgba[c + 1] > 250 && rgba[c + 2] > 250,
@@ -234,9 +177,6 @@ mod tests {
);
}
/// The ramp times its tint reproduces `hsv_to_rgb` at that row's `V` — the identity the whole
/// two-static-images design rests on, and the reason this can be one image instead of one per
/// hue. Checked in the gamma space both sides live in (decision 0254).
#[test]
fn the_ramp_times_its_tint_is_the_colour_at_that_brightness() {
let (_, h, rgba) = ramp_pixels();
File diff suppressed because it is too large Load Diff
+58 -186
View File
@@ -1,8 +1,6 @@
//! The Text-arm rasterization: one `QuadContent::Text` quad — a region FontString, a
//! message-frame ring line, or the focused editbox's windowed text — into glyph [`UiQuad`]s:
//! the editbox window + selection/caret, the ellipsis-truncate seam, the drop shadow, and the
//! hyperlink span collection. Split out of the extraction pass ([`super`]) when it crossed the
//! size budget; the arm's host-loop context arrives bundled as [`TextHost`].
//! The Text arm: one `QuadContent::Text` quad (a region FontString, a message-frame line or the
//! focused editbox's text) into glyph [`UiQuad`]s, with the editbox's selection and caret, the
//! ellipsis, the drop shadow and the hyperlink spans.
use bevy::prelude::*;
@@ -13,12 +11,11 @@ use benilla_ui::widget::FrameHandle;
use crate::ui_pass::{UiQuad, UvRect};
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).
/// `WOW_TEXT_PROBE=1`: log each Text quad's drawn string, font and ink rows; read once.
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::paint_script`] call site.
/// The `QuadContent::Text` payload minus the text: the region's resolved style.
pub(super) struct TextStyle {
pub color: Option<[f32; 4]>,
pub justify_h: JustifyH,
@@ -29,42 +26,31 @@ 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.
/// The V-plate's name and level, seated on the plate's device-snapped overlay, not the UI grid.
pub world_seat: bool,
}
/// The host-loop context one Text quad draws under: the extracted quad's identity (z/alpha/
/// target), its y-flipped rect + clip, the focused editbox's text-UI geometry (matched to this
/// quad inside), and the screen height for the link-span flip back to engine space.
/// The extraction loop's context for one Text quad.
pub(super) struct TextHost<'a> {
pub z: u64,
pub alpha: f32,
pub target: ZTarget,
pub rect: Rect,
pub clip: Option<Rect>,
/// The focused editbox's text-UI geometry, unfiltered — [`emit`] matches it to this quad
/// by [`EditBoxTextUi::target`].
/// The focused editbox's text UI, unfiltered: [`emit`] matches it to this quad by `target`.
pub ebox: Option<&'a EditBoxTextUi>,
pub screen_h: f32,
/// The 768-virtual scale `s = windowH/768` (decision 0582): rects arrive pre-scaled (px);
/// this converts the remaining unit-space inputs — font heights, shadow offsets, the
/// engine's editbox caret/selection x-offsets — into the same px space.
/// The seam scale `windowH/768 × uiScale`, for the unit-space inputs; rects arrive in px.
pub scale: f32,
/// The owning frame's `effective_scale` ([`benilla_ui::script::ExtractedQuad::scale`]): the
/// rect already carries it; the FONT metrics — glyph raster size and shadow offset, both
/// frame-local — multiply by it here (the real client's text rides `SetScale`; 0219 §2's
/// divergence, closed). NOT applied to the editbox caret/selection/advance x-offsets: those
/// arrive in screen UI units (the advance measure already rode the scale — see
/// [`benilla_ui::script::EditBoxAdvanceRequest`]'s `scale` doc).
/// The frame's effective scale, already in the rect: glyph size and shadow offset ride it, as
/// the 1.12 client's text rides `SetScale`; the editbox x-offsets arrive in screen UI units.
pub font_scale: f32,
/// Captures pin the caret blink ON (deterministic pixels); live, the engine's phase decides.
/// Captures pin the caret on for deterministic pixels; live, the engine's blink decides.
pub caret_pinned: bool,
}
/// Rasterize one Text quad into `out` (and its hyperlink spans into `link_spans`, message-frame
/// lines only). The glyph quads share the owning region's `z` — see [`layout_text_quads`]'s doc
/// for why that's already the correct total-order slot.
/// Rasterize one Text quad into `out`, and a message-frame line's hyperlink spans into
/// `link_spans`. Glyph quads take the region's `z` ([`layout_text_quads`]).
pub(super) fn emit(
atlas: &mut UiFontAtlas,
text: &str,
@@ -74,9 +60,7 @@ 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.
// The block's top snaps to the UI grid, except on a world overlay, which has its own seat.
let seat = if style.world_seat {
TextSeat::Exact
} else {
@@ -84,10 +68,7 @@ pub(super) fn emit(
};
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
// scale (`drawn_px`, decision 0582): one-to-one text unit-caps at 32 (a frame-LOCAL cap,
// like the seam scale it precedes) then scales; a SetTextHeight override scales uncapped.
// The shadow twin below inherits the spec (`..spec`).
// One-to-one text caps at 32 frame units, then scales; a `SetTextHeight` size is uncapped.
height: crate::ui_text::drawn_px(
style.font_height,
style.text_height,
@@ -96,11 +77,8 @@ pub(super) fn emit(
outline: style.outline,
alpha_gradient: style.alpha_gradient,
};
// The focused edit box's text draws WINDOWED (`0x77da80`): the scroll window's substring,
// left-anchored at the line origin in an unbounded rect (the window never wraps — the box
// edge clips instead), the selection highlight behind everything, the white caret after.
// `line_origin` gives the origin + the line cell; the x-offsets inside it are the engine's
// advance-derived geometry.
// The focused editbox draws windowed (`0x77da80`): the scroll window's substring from the
// line origin, unwrapped and clipped at the box edge, the selection behind, the caret after.
let ebox = host.ebox.filter(|u| u.target == host.target);
let mut draw_text: &str = text;
let mut draw_rect = host.rect;
@@ -109,23 +87,15 @@ pub(super) fn emit(
v: style.justify_v,
};
let mut text_clip = host.clip;
// The shadow's whole-pixel displacement ([`shadow_offset_px`]), hoisted: the band scissor
// below has to admit the ink it puts under the fill.
// Hoisted: the band scissor below admits the ink the shadow puts under the fill.
let shadow_delta = style.shadow.map(|sh| {
(
shadow_offset_px(sh.offset[0] * host.scale * host.font_scale),
shadow_offset_px(-sh.offset[1] * host.scale * host.font_scale),
)
});
// A message-frame ring line's scissor follows the seat, at the bottom edge only.
//
// The band ladder stacks UP from the frame's bottom edge, so the newest line is flush with it
// by construction and can never overflow there — the scissor's real job is the OTHER end, the
// half-fitting scrollback line at the frame's top, and that edge is untouched. What the bottom
// edge did cut was ink the renderer had deliberately placed below the band: the seat nudge
// (0351) lowers every UI text block one px, and the drop shadow sits one further px under the
// fill. Two of our own dials against a scissor that predates them — and the newest chat line
// lost the tails of its descenders and the feet of its brackets (director, 2026-07-26).
// A message line's scissor gives back the ink the seat nudge and the shadow put below its
// band, at the bottom edge only: the top edge still clips a half-fitting scrollback line.
if matches!(host.target, ZTarget::Frame(_)) {
if let Some(c) = text_clip.as_mut() {
c.max.y += band_clip_slack(shadow_delta.map(|(_, dy)| dy));
@@ -139,8 +109,6 @@ pub(super) fn emit(
ebox_geom = Some((x0, top, cell_h));
text_clip = Some(host.clip.map_or(host.rect, |c| c.intersect(host.rect)));
if !ui.multi_line {
// Single-line: the windowed draw — the scroll window's substring, left-anchored in
// an unbounded rect (the box edge clips instead of wrapping).
draw_text = drawn;
draw_rect = Rect::new(x0, host.rect.min.y, x0 + 100_000.0, host.rect.max.y);
draw_justify = crate::ui_text::Justify {
@@ -148,14 +116,13 @@ pub(super) fn emit(
v: style.justify_v,
};
}
// Multiline draws the whole block through the ordinary wrapped path below (the text
// region's justify is already the editbox law: TOP/LEFT) — only the caret/selection are
// seated by `(row, x)` at the same row pitch the wrap answered.
// A multiline box draws wrapped (its region is TOP/LEFT already); the caret and the
// selection seat by `(row, x)` at the wrap's row pitch.
for &(row, sx0, sx1) in &ui.selection {
let hc = ui.highlight_color;
#[allow(clippy::cast_precision_loss)]
let ry = top + row as f32 * cell_h;
// The engine's selection x-span is in UI units (its advance table was fed ÷scale).
// The engine's selection x-span is in UI units.
out.push(UiQuad {
rect: Rect::new(
x0 + sx0 * host.scale,
@@ -181,15 +148,9 @@ pub(super) fn emit(
});
}
}
// The height-gated ellipsis-truncate (`ellipsize_to_fit` — CSimpleFontString `0x771ec0`,
// decision 0292's residue landed): a region FontString whose wrapped text needs more lines
// than its height-pinned rect allows draws `prefix + "..."` instead — the bag title, the
// unit-frame names, the minimap zone text. Region FontStrings only, exactly the client's
// seam: the editbox windows (never truncates), message-frame ring lines size their band FROM
// the text, and auto-height FontStrings fit by construction (the measure round-trip), so the
// gate inside is geometric and needs no fixed-vs-measured flag. Computed before the shadow
// so the shadow pass draws the same display string (the client's shadow is a second draw of
// the same truncated CGxString).
// A region FontString whose wrapped text needs more lines than its rect holds draws
// `prefix + "..."` (`CSimpleFontString` `0x771ec0`); an editbox never truncates and a message
// line sizes to its text. Before the shadow, which the client draws from the same string.
let ellipsized = match host.target {
ZTarget::Region(region) if ebox.is_none() => {
crate::ui_text::ellipsize_to_fit(atlas, region, draw_text, draw_rect, spec)
@@ -199,26 +160,15 @@ pub(super) fn emit(
if let Some(display) = ellipsized.as_deref() {
draw_text = display;
}
// The probe prints the DISPLAY string (post-ellipsis, post-editbox-window) — what this pass
// actually draws, which is what a truncation report needs to show.
// The probe prints the displayed string, after the ellipsis and the editbox window.
let probe = *TEXT_PROBE;
if probe {
// For the focused edit box, also the two numbers that must agree: where the engine puts the
// caret (`caret=`, advance-table-derived) and how wide the text this pass actually draws is
// (`ink=`). They diverge by the width of the markup when the advance table is measured over
// the raw buffer — the caret-out-in-space report (decision 1075) as a pair of numbers.
// For the focused editbox, the engine's caret x and the drawn width, which must agree.
let ebox_geom = ebox.map(|ui| {
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.
// `px` is the drawn height, after the cap, the seam and the frame scale.
info!(
"text probe: [{:.0},{:.0} {:.0}x{:.0}] h={:?} px={:?} flags={:?} face={:?}{} {:?}",
draw_rect.min.x,
@@ -233,18 +183,10 @@ pub(super) fn emit(
&draw_text[..draw_text.len().min(60)]
);
}
// The drop shadow (font object `<Shadow>` — MasterFont's (1,-1) black covers the whole
// GameFont* family): the same layout at an offset rect in the shadow color, pushed FIRST so
// the stable z-sort keeps it behind the glyph pass. Offset is WoW y-up (`y="-1"` = down) →
// y-down screen dy = −y. Markup color codes inside the text tint the shadow run too (v1
// corner, invisible for solid-color strings). The shadow is a single flat offset copy —
// never itself outlined — it lays out identically to its fill (a shadow with different steps
// would smear under long strings) and redraws the same composite cells in the shadow color,
// where the ring's black is indistinguishable from the shadow's.
// The font object's `<Shadow>` (`MasterFont`'s `(1, -1)` black, `Fonts.xml:55`, which most
// GameFonts inherit): the fill's layout and cells again at the offset in the shadow colour,
// pushed first so the stable sort draws it behind. WoW's `y="-1"` is down: screen `dy = −y`.
if let (Some(sh), Some((dx, dy))) = (style.shadow, shadow_delta) {
// WHOLE pixels (`shadow_offset_px`, computed above): the shadow is a rigid copy of the
// fill, so its displacement must be an integer in the rect's own space — see that fn for
// why a fractional one makes the offset itself wobble line to line.
let srect = Rect::new(
draw_rect.min.x + dx,
draw_rect.min.y + dy,
@@ -263,25 +205,19 @@ pub(super) fn emit(
seat,
);
for q in &mut sq {
// Flatten rgb to the shadow color (markup tints ride the fill only); the alpha is
// every scalar the fill rides plus the shadow's own — see [`shadow_alpha`], which
// names the set and the symptom a missing member wears.
// Markup tints ride the fill only; the alpha is [`shadow_alpha`].
q.color = [
sh.color[0],
sh.color[1],
sh.color[2],
shadow_alpha(q.color[3], base_color[3], host.alpha),
];
// Every glyph quad inherits the owning Text quad's ScrollFrame clip — the
// FontString's own extract-time clip, not a per-glyph concept.
q.clip = text_clip;
}
out.extend(sq);
}
// `font`/`font_height` come from the region's resolved font object (`Fonts.xml`).
let mut glyphs = if let ZTarget::Frame(fh) = host.target {
// A frame-targeted Text quad is a message-frame ring line: collect its hyperlink spans
// for the engine's click hit-test (y-down → y-up flip).
// A frame-targeted Text quad is a message line: collect its links for the click hit-test.
let mut spans = Vec::new();
let g = layout_text_quads_links(
&mut atlas.lock(),
@@ -298,7 +234,7 @@ pub(super) fn emit(
&mut spans,
);
for sp in spans {
// px → the engine's y-up UI-unit space (÷scale after the flip).
// Back to the engine's y-up UI units.
link_spans.push((
fh,
benilla_ui::layout::Rect::new(
@@ -326,15 +262,10 @@ pub(super) fn emit(
};
for q in &mut glyphs {
q.color[3] *= host.alpha;
// Every glyph quad inherits the Text quad's ScrollFrame clip (decision 0112) —
// `ui_pass`'s CPU clip already applies uniformly to any `UiQuad`, glyph or not.
q.clip = text_clip;
}
// The probe's seat line: the drawn INK rows (glyph-quad union, logical px, relative to the
// rect top) — the measurable half of the vertical-seat law
// (`0x5d1360`): 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).
// The seat probe: the fill's ink rows relative to the rect top, to compare with the
// vertical-seat law's `d + ascender` (`0x5d1360`); the shadow's quads are not in `glyphs`.
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);
@@ -342,12 +273,7 @@ pub(super) fn emit(
y0 = y0.min(q.rect.min.y);
y1 = y1.max(q.rect.max.y);
}
// **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.
// Where the plate's text inked, beside the `vpl` tag's `plate=` and `paint=` lines.
if vpl {
benilla_assets::trace::line(
"vpl",
@@ -374,15 +300,14 @@ pub(super) fn emit(
}
}
out.extend(glyphs);
// The focused box's caret: a 1-px WHITE bar (the client's ctor `0xffffffff` caret texture —
// never the text color) one line cell tall at the engine's advance-derived x, pushed after
// the glyphs so the stable sort draws it on top.
// The caret: a 1 px bar in the constructor's white (`0xffffffff`), one line cell tall at the
// engine's x, pushed after the glyphs to draw on top. The reference's is 4 UI units wide
// (`0x77b8c0`) and takes the edit box's text colour on a font change (`0x77e2a0`).
if let (Some(ui), Some((x0, top, cell_h))) = (ebox, ebox_geom) {
if host.caret_pinned || ui.caret_on {
#[allow(clippy::cast_precision_loss)]
let top = top + ui.caret_row as f32 * cell_h;
// caret_x is engine UI units (÷scale advances); the 1-px bar width stays device-thin
// (the client's 4-unit caret, `0x77b8c0`, is a named residual).
// `caret_x` is in engine UI units.
let cx = x0 + ui.caret_x * host.scale;
out.push(UiQuad {
rect: Rect::new(cx, top, cx + 1.0, top + cell_h),
@@ -406,34 +331,16 @@ pub(super) fn emit(
}
}
/// One axis of the drop shadow's displacement, in the rect's own px space: the font object's
/// unit offset times the seam scale, **rounded to a whole pixel** (never to zero — a shadow the
/// scale shrank below half a pixel still draws one, the reference's look at any window size).
///
/// It must be a whole pixel because the shadow is laid out as a second, offset copy of the fill,
/// and each copy independently takes the client's single vertical anchor snap (`snap_block_top`
/// — `ceil(y − 0.5)`). A snap commutes with an integer translate and *only* with an integer
/// translate: at a fractional offset `d`, `ceil(y + d − 0.5) − ceil(y − 0.5)` is `⌈d⌉` for some
/// fractional parts of `y` and `⌊d⌋` for others — so the shadow's distance from its own glyphs
/// changed with where the string happened to land. The chat window made that visible and
/// permanent: its newest line's band is pinned to the frame's bottom edge, one fixed fractional
/// position, which sat inside the wide-by-one window — so the most recent message, and only it,
/// wore a shadow a pixel further out than every line above it (director, 2026-07-26; measured at
/// 1600×900 ×0.9 UI scale, seam scale 1.055 → the fill/shadow gap alternated 1 px and 2 px).
///
/// The world's floating combat text reached the same conclusion from its own law
/// (`combat_text::law::shadow_offset_px` rounds `0.002·viewport`); this is the UI's half.
/// How far below its band a message-frame line's ink can reach, in the rect's own px space — the
/// slack its scissor's bottom edge has to give back.
///
/// Two renderer offsets put ink under the geometric band, and neither existed when the band model
/// was written: the seat nudge ([`crate::ui_text::UI_SEAT_NUDGE`]) drops every UI text block one
/// px, and the drop shadow ([`shadow_offset_px`]) draws a copy one further px down. A shadow that
/// rises (`dy < 0`) adds nothing at the bottom.
/// How far below its band a message-frame line's ink reaches, in the rect's px space: the seat
/// nudge ([`crate::ui_text::UI_SEAT_NUDGE`]) plus a falling shadow's offset.
fn band_clip_slack(shadow_dy: Option<f32>) -> f32 {
crate::ui_text::UI_SEAT_NUDGE + shadow_dy.map_or(0.0, |dy| dy.max(0.0))
}
/// One axis of the drop shadow's offset in the rect's px space, rounded to a whole pixel but
/// never to zero, as the reference's shadow shows at any window size. Fill and shadow each take
/// the client's vertical snap, `ceil(y − 0.5)`, which commutes only with an integer translate: a
/// fractional offset makes the gap between them alternate between 1 and 2 px.
fn shadow_offset_px(scaled: f32) -> f32 {
let rounded = scaled.round();
if rounded == 0.0 && scaled != 0.0 {
@@ -443,21 +350,9 @@ fn shadow_offset_px(scaled: f32) -> f32 {
}
}
/// A shadow glyph's alpha: **every scalar the fill rides, plus its own.**
///
/// Named because the set is the whole law and a missing member is invisible until something fades
/// through the one that was dropped. There are three:
///
/// - `layout` — what the shadow pass laid out with: the font object's `<Shadow>` colour alpha,
/// times the write-on gradient's per-glyph ramp.
/// - `fill` — the FILL colour's alpha (`base_color[3]`): `SetTextColor(r,g,b,a)`, and every
/// MessageFrame line, whose fade is a colour ramp rather than a frame-alpha one.
/// - `host` — the region's effective frame alpha (`SetAlpha` and every parent's).
///
/// `fill` was the one missing. A frame that fades through its colour (UIErrorsFrame,
/// RaidWarningFrame, chat) holds `host` at 1.0 throughout, so the shadow stayed a fully opaque
/// black copy of a vanishing string: the text read as turning BLACK across the ramp and then
/// popping out when the line retired, instead of fading.
/// A shadow glyph's alpha, every scalar the fill rides plus its own: `layout` (the `<Shadow>`
/// colour's alpha times the write-on gradient), `fill` (the fill colour's alpha, which
/// `SetTextColor` and a MessageFrame line's fade move) and `host` (the effective frame alpha).
fn shadow_alpha(layout: f32, fill: f32, host: f32) -> f32 {
layout * fill * host
}
@@ -466,47 +361,28 @@ fn shadow_alpha(layout: f32, fill: f32, host: f32) -> f32 {
mod tests {
use super::{band_clip_slack, shadow_alpha, shadow_offset_px};
/// A drop shadow fades with the string it belongs to — through EITHER fade lane.
///
/// The regression this pins: `UIErrorsFrame`'s "Requires Mining" and every other MessageFrame
/// line ramps its COLOUR alpha and holds frame alpha at 1.0, so a shadow that only rode
/// `host` stayed opaque black under a vanishing red fill — the text turned black across the
/// ramp and then vanished in one frame (director, 2026-08-22). Anything that fades through
/// `SetTextColor`'s alpha wore the same defect.
#[test]
fn a_drop_shadow_fades_through_the_colour_lane_as_well_as_the_frame_lane() {
// Nothing fading: an opaque shadow, as before.
assert_eq!(shadow_alpha(1.0, 1.0, 1.0), 1.0);
// The colour lane alone (a message-frame line mid-ramp): the shadow follows it. This is
// the assertion that fails without the fix — it used to answer 1.0.
// The colour lane alone: a message-frame line mid-fade.
assert_eq!(shadow_alpha(1.0, 0.25, 1.0), 0.25);
// The frame lane alone (UIFrameFadeOut, the zone splash): unchanged behaviour.
// The frame lane alone (`UIFrameFadeOut`).
assert_eq!(shadow_alpha(1.0, 1.0, 0.25), 0.25);
// Both lanes compose, and the font object's own semi-transparent shadow rides on top.
// Both lanes, under a font object's own semi-transparent shadow.
assert_eq!(shadow_alpha(0.8, 0.5, 0.5), 0.2);
// Fully faded fill leaves NO shadow behind — the black that used to outlive the text.
assert_eq!(shadow_alpha(1.0, 0.0, 1.0), 0.0);
}
/// The newest chat line's band bottom IS the frame's bottom edge, so every px the renderer
/// adds below the band is a px the scissor would otherwise eat — the descender tails and
/// bracket feet the director saw sliced off. The slack is the sum of the two offsets that put
/// ink there, and nothing else.
#[test]
fn the_band_scissor_gives_back_exactly_the_seat_and_the_shadow() {
// The shipped GameFont case: seat nudge 1 + a 1px drop shadow.
assert_eq!(band_clip_slack(Some(1.0)), 2.0);
// A font object with no shadow still owes the seat.
assert_eq!(band_clip_slack(None), 1.0);
// A shadow that rises adds nothing under the line.
assert_eq!(band_clip_slack(Some(-1.0)), 1.0);
// A bigger window scales the shadow, and the slack with it.
assert_eq!(band_clip_slack(Some(2.0)), 3.0);
}
/// The whole-pixel law, at the three scales that matter: the 1:1 seam (offset 1 px), the
/// director's 1600×900 ×0.9 (1.055 → 1 px, the fix's own case), and a small capture window
/// whose scale would otherwise round the shadow away entirely.
/// `1.0546875` is the seam scale of a 1600×900 window at uiScale 0.9.
#[test]
fn a_shadow_offset_is_a_whole_pixel_and_never_vanishes() {
assert_eq!(shadow_offset_px(1.0), 1.0);
@@ -516,14 +392,10 @@ mod tests {
// 377-tall capture window × 0.9: 0.44 px would round to nothing.
assert_eq!(shadow_offset_px(0.4418), 1.0);
assert_eq!(shadow_offset_px(-0.4418), -1.0);
// A font object with no offset on an axis keeps none.
assert_eq!(shadow_offset_px(0.0), 0.0);
}
/// The property the fix exists for: an integer translate commutes with the client's single
/// vertical snap (`ceil(y − 0.5)`), so the fill→shadow gap is the same at every fractional
/// position a string can land on. A fractional offset does not — the sweep below is exactly
/// the wobble the chat window's newest line wore.
/// An integer offset commutes with the client's vertical snap, `ceil(y − 0.5)`.
#[test]
fn a_whole_pixel_offset_survives_the_anchor_snap_at_every_fraction() {
let snap = |y: f32| (y - 0.5).ceil();
@@ -536,7 +408,7 @@ mod tests {
"whole-px offset must translate the snapped block exactly, y={y}"
);
}
// …and the unrounded offset provably does not (both gaps occur over one unit of y).
// The unrounded offset gives both a 1 and a 2 px gap over one unit of y.
let raw = 1.0546875f32;
let gaps: std::collections::BTreeSet<i32> = (0..1000)
.map(|step| {
+5 -16
View File
@@ -1,9 +1,5 @@
//! 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.
//! The stock fade and flash kits (`UIParent.lua`), walked from `UIParent`'s own OnUpdate
//! (`UIParent.xml:17`), so hiding `UIParent` stops both.
use benilla_ui::script::UiScript;
@@ -46,7 +42,7 @@ fn a_started_fade_ramps_off_uiparents_tick_and_completes() {
"half the timeToFade in, half the ramp: {mid}"
);
s.tick(0.6); // past timeToFade: the fade completes and leaves the list
s.tick(0.6); // past timeToFade
s.resolve();
assert_eq!(
s.eval::<f64>("return BenillaFadeProbe:GetAlpha()").unwrap(),
@@ -61,11 +57,6 @@ fn a_started_fade_ramps_off_uiparents_tick_and_completes() {
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() {
benilla_formats::wow_data_or_skip!();
@@ -82,7 +73,6 @@ fn a_flash_alternates_then_stops() {
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)
@@ -95,7 +85,7 @@ fn a_flash_alternates_then_stops() {
"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).
// Re-arming a flashing frame returns early (`UIParent.lua:1235`).
s.run("UIFrameFlash(BenillaFlashProbe, 1, 1, 1, nil, 0, 0)")
.unwrap();
assert_eq!(
@@ -104,8 +94,7 @@ fn a_flash_alternates_then_stops() {
"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.
// Past flashDuration the frame leaves both lists at full alpha (`UIParent.lua:1272`).
for _ in 0..40 {
s.tick(0.016);
s.resolve();
@@ -1,30 +1,12 @@
//! The **reference's own** faux-scroll kit — `Interface\FrameXML\UIPanelTemplates`, off the
//! player's chain since 1837, our transcription deleted by 1860 — under its own
//! names — `FauxScrollFrame_Update` / `_GetOffset` / `_SetOffset` / `_OnVerticalScroll` and
//! `ScrollFrame_OnLoad` (decision 1190: a name the shipped 1.12 UI defines is FrameXML's).
//!
//! These drive the kit the way an addon does — a bare instance of `FauxScrollFrameTemplate` with
//! rows of its own — rather than through one of our six owner windows, because the API is now a
//! public surface and its edges (a list that overflows, one that exactly fits, one that shrinks
//! under a scrolled offset, the shrink/widen tail, the return value) belong to it and not to any
//! window. The windows' own tests still cover their wiring.
//!
//! The last test is the one worth reading: it drives the reference's *whole* path — bar drag →
//! `SetVerticalScroll` → the frame's `<OnVerticalScroll>` → `FauxScrollFrame_OnVerticalScroll` —
//! on a real `<ScrollFrame>` with a scroll child, which is exactly the shape 27 corpus addons
//! write. `<ScrollChild>` landed with 1205, so the template's own child arrives through
//! inheritance now (`framexml.rs merge` clones the base's children) — which is why the fixture
//! here must be a `<ScrollFrame>`: `merge` takes the OVERRIDING node's tag, and a `<Frame>` would
//! skip the loader's ScrollChild pass and leave `$parentScrollChildFrame` nil for the reference's
//! `FauxScrollFrame_Update`, which touches it unguarded (decision 1860).
//! The stock faux-scroll kit (`UIPanelTemplates.lua`), driven as an addon drives it: a bare
//! `FauxScrollFrameTemplate` with its own rows. The fixture must be a `<ScrollFrame>`, since only
//! that tag gets the template's scroll child, which `FauxScrollFrame_Update` reads unguarded.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// A document written here in the test, loaded against the templates already registered — the
/// cross-file template registry is on the `Model`, so an inline doc reaches `ScrollTemplates.xml`'s
/// `FauxScrollFrameTemplate` exactly as `TrainerFrame.xml` does.
/// Loads an inline document against the templates already registered.
fn load_inline(s: &UiScript, xml: &str) {
let doc = benilla_ui::framexml::parse(xml).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
@@ -35,8 +17,7 @@ fn load_inline(s: &UiScript, xml: &str) {
);
}
/// The kit plus a list an addon might own: five visible rows, a moving highlight, and a faux
/// scroll frame over them.
/// The kit and an addon's list: five rows, a highlight and a faux scroll frame over them.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -73,9 +54,7 @@ fn harness() -> UiScript {
s
}
/// `ScrollFrame_OnLoad` ran off the template: a fresh frame is at the top, with an empty range and
/// both arrows greyed. (The reference's own `UIPanelScrollFrameTemplate` OnLoad — the name we now
/// wear instead of a `Benilla*` one.)
/// Stock `ScrollFrame_OnLoad` runs off the template (`UIPanelTemplates.lua:244-252`).
#[test]
fn a_fresh_faux_frame_loads_at_the_top_with_no_range() {
benilla_formats::wow_data_or_skip!();
@@ -95,8 +74,7 @@ fn a_fresh_faux_frame_loads_at_the_top_with_no_range() {
.unwrap());
}
/// **More rows than fit**: the bar comes up, its range is the overflow in pixels, its step is one
/// row, and `FauxScrollFrame_Update` returns the reference's `showScrollBar`.
/// The bar's range is the overflow in pixels and its step one row (`UIPanelTemplates.lua:189-190`).
#[test]
fn more_rows_than_fit_raise_the_bar_over_the_overflow_range() {
benilla_formats::wow_data_or_skip!();
@@ -137,8 +115,7 @@ fn more_rows_than_fit_raise_the_bar_over_the_overflow_range() {
.unwrap());
}
/// **Exactly fitting** is the boundary the reference draws at `numItems > numToDisplay`: five rows
/// in five slots shows nothing, and there is no off-by-one that leaves a dead bar up.
/// The bar shows only for `numItems > numToDisplay` (`UIPanelTemplates.lua:165`).
#[test]
fn a_list_that_exactly_fits_shows_no_bar() {
benilla_formats::wow_data_or_skip!();
@@ -156,7 +133,6 @@ fn a_list_that_exactly_fits_shows_no_bar() {
.unwrap();
assert_eq!(hi, 0.0, "no overflow, no range");
// One more row and it appears — the same call, one item along.
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_Update(TestScroll, 6, 5, 16)")
.unwrap(),
@@ -167,24 +143,15 @@ fn a_list_that_exactly_fits_shows_no_bar() {
.unwrap());
}
/// **Fewer rows than fit**, arrived at by the list SHRINKING under a scrolled offset.
///
/// **The clamp lives on the BAR, not on `frame.offset` — the reference's own arrangement, and ours
/// diverged from it until 1860.** `FauxScrollFrame_SetOffset` is two lines in the reference
/// (`frame.offset = offset`, UIPanelTemplates.lua:239-241): it does not move the thumb, so the
/// stored offset and the bar can disagree until something scrolls. `FauxScrollFrame_Update` then
/// re-ranges the bar (`SetMinMaxValues(0, (numItems - numToDisplay) * valueStep)`), and it is the
/// slider's own clamp — firing OnValueChanged into the owner's `<OnVerticalScroll>` — that walks
/// a too-deep offset back. Our deleted copy wrote `frame.offset` directly instead, which is why
/// this test used to read a clamped offset immediately.
/// The clamp lives on the bar: stock `FauxScrollFrame_SetOffset` only stores the offset
/// (`UIPanelTemplates.lua:239-241`), and a scroll inside the re-ranged bar walks it back.
#[test]
fn a_shrinking_list_clamps_the_offset_back_into_range() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("FauxScrollFrame_Update(TestScroll, 20, 5, 16)")
.unwrap();
// The scroll range comes off the child's RESOLVED height, one solve behind the `SetHeight`
// the update just did (0251's lag). The app resolves every frame; a test has to say so.
// The range follows the child's resolved height, a solve behind the update's `SetHeight`.
s.resolve();
s.run("FauxScrollFrame_SetOffset(TestScroll, 12)").unwrap();
s.resolve();
@@ -194,7 +161,7 @@ fn a_shrinking_list_clamps_the_offset_back_into_range() {
12
);
// The list drops to 8 rows: the deepest legal offset is now 3, and the BAR is what says so.
// Down to 8 rows: the deepest offset is now 3.
s.run("FauxScrollFrame_Update(TestScroll, 8, 5, 16)")
.unwrap();
s.resolve();
@@ -206,8 +173,6 @@ fn a_shrinking_list_clamps_the_offset_back_into_range() {
(0.0, 48.0),
"the bar re-ranges to (numItems - numToDisplay) * valueStep = 3 rows"
);
// Scrolling now cannot go deeper than that range, which is how the reference walks a stale
// offset back — the offset itself is untouched until it does.
s.run("TestScrollScrollBar:SetValue(999)").unwrap();
s.resolve();
assert!(
@@ -223,7 +188,7 @@ fn a_shrinking_list_clamps_the_offset_back_into_range() {
"and the thumb followed the clamp"
);
// Down to fewer than fit: offset 0, bar gone, and nothing left pointing past the end.
// Fewer than fit: the stock update sets the bar to 0 (`UIPanelTemplates.lua:169`).
s.run("FauxScrollFrame_Update(TestScroll, 3, 5, 16)")
.unwrap();
assert_eq!(
@@ -237,9 +202,8 @@ fn a_shrinking_list_clamps_the_offset_back_into_range() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's **shrink/widen tail** — the six trailing arguments 28 corpus addons pass and
/// benilla's own windows never do. Rows narrow when the bar takes the gutter and widen back when
/// it leaves (ref UIPanelTemplates.lua l.205-223).
/// The six trailing arguments narrow the rows while the bar shows and widen them back when it
/// hides (`UIPanelTemplates.lua:205-223`).
#[test]
fn the_shrink_widen_tail_resizes_the_rows_and_the_highlight() {
benilla_formats::wow_data_or_skip!();
@@ -262,7 +226,6 @@ fn the_shrink_widen_tail_resizes_the_rows_and_the_highlight() {
276.0
);
// The list fits again: every row goes back to the wide measurement.
s.run(
"FauxScrollFrame_Update(TestScroll, 4, 5, 16, \"TestRow\", 280, 300, TestHighlight, 276, 296)",
)
@@ -281,8 +244,8 @@ fn the_shrink_widen_tail_resizes_the_rows_and_the_highlight() {
);
}
/// Dragging the bar moves the offset a whole row at a time and repaints the owner's list — the
/// value model in one test (pixels on the bar, rows in the offset, `floor(v/step + 0.5)` between).
/// Pixels on the bar, rows in the offset, `floor(v / step + 0.5)` between
/// (`UIPanelTemplates.lua:231`).
#[test]
fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
benilla_formats::wow_data_or_skip!();
@@ -291,7 +254,7 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
.unwrap();
s.run("FauxScrollFrame_Update(TestScroll, 12, 5, 16)")
.unwrap();
s.resolve(); // the range is a solve behind the update's SetHeight (0251)
s.resolve(); // the range is a solve behind the update's SetHeight
let before = s.eval::<i64>("return TestRepaints").unwrap();
s.run("TestScrollScrollBar:SetValue(48)").unwrap();
@@ -300,21 +263,15 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
.unwrap(),
3
);
// The drag repainted. NOT an exact count: the reference's `FauxScrollFrame_OnVerticalScroll`
// re-sets the bar it was called from (`scrollbar:SetValue(arg1)`, UIPanelTemplates.lua:230),
// so one drag can round-trip the value-changed chain more than once. Our deleted copy took the
// offset straight off the slider and could promise one repaint per row; the reference cannot,
// and pinning the number here would be pinning that re-entry rather than the contract (1860).
// Not an exact count: `FauxScrollFrame_OnVerticalScroll` re-sets its own bar
// (`UIPanelTemplates.lua:230`), so one drag can repaint more than once.
assert!(
s.eval::<i64>("return TestRepaints").unwrap() > before,
"the drag repainted the owner's list"
);
// 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.
// `SetValue` snaps to `min + n * step` before its change test (`0x789930`), so 51px stays at
// the 48 the bar holds and fires nothing.
let settled = s.eval::<i64>("return TestRepaints").unwrap();
s.run("TestScrollScrollBar:SetValue(51)").unwrap();
assert_eq!(
@@ -329,12 +286,8 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
"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).
// A scroll through the frame always repaints: `FauxScrollFrame_OnVerticalScroll` calls the
// update with no compare (`UIPanelTemplates.lua:228-232`).
s.run("TestScroll:SetVerticalScroll(51)").unwrap();
assert_eq!(
s.eval::<i64>("return FauxScrollFrame_GetOffset(TestScroll)")
@@ -349,15 +302,8 @@ fn dragging_the_bar_steps_the_offset_by_rows_and_repaints() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The addon path, end to end** — the reference's wiring rather than ours: a real `<ScrollFrame>`
/// on `FauxScrollFrameTemplate` whose `<OnVerticalScroll>` calls `FauxScrollFrame_OnVerticalScroll`,
/// driven by a drag of the shared bar.
///
/// 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.
/// The addon path end to end: bar value, `SetVerticalScroll`, the frame's `<OnVerticalScroll>`,
/// then `FauxScrollFrame_OnVerticalScroll`. `SetScrollChild` seats a child by hand, as addons do.
#[test]
fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
benilla_formats::wow_data_or_skip!();
@@ -380,7 +326,7 @@ fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
s.run("AddonRepaints = 0 function AddonScroll_Update() AddonRepaints = AddonRepaints + 1 end")
.unwrap();
// The instance's own tag wins over the template's on inherit, so this really is a ScrollFrame.
// An inheriting node keeps its own tag, so this is a ScrollFrame.
assert!(
s.eval::<bool>("return type(AddonScroll.SetVerticalScroll) == 'function'")
.unwrap(),
@@ -414,13 +360,8 @@ fn the_reference_on_vertical_scroll_path_runs_once_a_scroll_child_exists() {
);
}
/// **The ScrollingEdit trio answers to the REFERENCE's names, and to a bare call.**
///
/// `InviteOMatic` wires `ScrollingEdit_OnTextChanged` straight into an EditBox's `OnTextChanged`
/// and raised `attempt to call global` the moment anyone typed — found by the use-probe, since it
/// only fires on input. Both helpers take an OPTIONAL scroll frame and fall back to
/// `this:GetParent()` (ref `UIPanelTemplates.lua` l.307-310); an addon wiring the bare name depends
/// on that fallback, so the no-argument path is what this test drives.
/// With no argument, stock `ScrollingEdit_OnTextChanged` falls back to `this:GetParent()`
/// (`UIPanelTemplates.lua:307-310`), which addons that wire it bare rely on.
#[test]
fn scrolling_edit_helpers_answer_bare_calls_from_a_handler() {
benilla_formats::wow_data_or_skip!();
@@ -444,7 +385,7 @@ fn scrolling_edit_helpers_answer_bare_calls_from_a_handler() {
</Ui>"#,
);
// Bare call with `this` set to the edit box — the addon's exact wiring.
// A bare call with `this` set, as a handler makes it.
s.run(
"this = SeEdit; ScrollingEdit_OnTextChanged(); ScrollingEdit_OnCursorChanged(0, 42, 0, 14); this = nil",
)
@@ -455,11 +396,9 @@ fn scrolling_edit_helpers_answer_bare_calls_from_a_handler() {
s.errors()
);
// OnCursorChanged records where the caret is, which is what an OnUpdate would follow.
assert_eq!(s.eval::<f32>("return SeEdit.cursorOffset").unwrap(), 42.0);
assert_eq!(s.eval::<f32>("return SeEdit.cursorHeight").unwrap(), 14.0);
// ...and the explicit-frame form works too (the reference's first branch).
s.run("ScrollingEdit_OnTextChanged(SeScroll)").unwrap();
assert!(s.errors().is_empty(), "explicit form: {:?}", s.errors());
}
@@ -1,85 +1,25 @@
//! **The reference's frame FLAGS are the test oracle** — the flag half of decision 0675's "the
//! reference file is the test oracle", and the gate for the class decision 1739 closed.
//! The reference's frame flags as a test oracle: every frame both UIs name must carry the
//! reference's `toplevel`, effective mouse enable, `id` and parent.
//!
//! A window's numbers are the stock file's own now. Nothing guarded its *flags*,
//! and they went quietly wrong at scale: on 2026-08-30 a sweep found **47 frames** the reference
//! marks `toplevel` and ours did not (so no window ever came to the front), **23** the reference
//! makes mouse-interactive and ours did not (so a click on a window's own background fell through
//! to the 3D world), and **81** carrying a reference `id=` ours dropped (so `GetID()` — the
//! contract every 1.12 addon reads a slot index out of — answered 0). Every gate was green
//! throughout, because a flag is invisible to a behavioural test that never presses on the frame.
//! Our side is read off the loaded engine (`IsToplevel`, `IsMouseEnabled`, `GetID`, `GetParent`),
//! never our XML: a renamed template hides from a name-keyed diff, and a mouse handler in
//! `<Scripts>` reaches the same enable as `enableMouse=` (`0x76af00(2,-1)`). The reference side is
//! its XML off the player's chain, so a `SetID` or `EnableMouse` it makes from Lua reads as absent,
//! which can only under-report.
//!
//! ## Two properties make this a guard rather than a comfort
//! A parent comes from `parent=`, on the element or a template it inherits, or from nesting in
//! another frame's `<Frames>`. It decides whether a frame can show over a fullscreen panel:
//! `SetFullScreenFrame` hides `UIParent` and all below it (`UIParent.lua:852-861`). Templates are
//! compared through their instances, so a [`KNOWN`] entry naming one reads as stale.
//!
//! **Our side is read from the ENGINE, never from our XML.** An attribute-vs-attribute diff is
//! precisely what let the class hide, twice over: benilla renames templates (`ChatFrameTemplate` →
//! `BenillaChatFrameTemplate`), so a name-keyed text diff reports a gap of zero on a window that is
//! entirely missing the flag; and the `<Scripts>` **auto-enable** law (an
//! `<OnEnter>`/`<OnLeave>`/`<OnMouseDown>`/
//! `<OnMouseUp>`/`<OnDragStart>` reaches the same enable primitive `0x76af00(2,-1)` the attribute
//! does) makes `enableMouse=` a poor proxy for whether the frame actually takes the mouse. Asking
//! the loaded engine — `IsToplevel()`, `IsMouseEnabled()`, `GetID()`, `GetParent()` — is immune to
//! both, and to a third: a `parent=` can arrive from a template or from nesting, and `GetParent()`
//! 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. **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
//!
//! `toplevel`, the effective mouse enable, `id` and the **parent** are mechanical: the reference's
//! value is right for any frame we transcribe, and a difference is a defect. The neighbours are
//! **not**, and are deliberately out of scope rather than silently tolerated — `movable` is an
//! inert flag for all but two reference frames (nothing calls `StartMoving` on the rest, so
//! copying it would be cargo-cult), `frameStrata` decides what draws over what and is the
//! director's call, `setAllPoints` has an exact `<Size>`+`<Anchors>` equivalent that five of our
//! pages deliberately use, and `hidden` is equivalent whenever the reference hides in `OnLoad`
//! instead. Decision 1739 carries the reasoning per attribute.
//!
//! **The parent joined them at decision 1757, and it is the flag with teeth.** Until 1734 restored
//! `SetFullScreenFrame`'s `UIParent:Hide()`, a `parent=` was little more than a coordinate space;
//! after it, the seat decides whether a frame can be on screen *at all* while a fullscreen panel
//! is up. 1734 swept the direction it went looking for — 72 declarations the reference has and we
//! had dropped — and nothing asked the opposite question, so the frames we parent that the
//! reference leaves top-level went the other way in silence: opening the world map hid the map's
//! own blackout (the 3D world came back through the margins beside the 4:3 sheet — the director
//! reported it), the shared dropdown lists could not open over it, and the screenshot
//! confirmation could not appear during the fly-by whose SCREENSHOT key `CinematicFrame` hands
//! back by name. A gate that reads one direction of a two-directional property is a gate someone
//! has to remember to run backwards.
//!
//! Both ways a frame acquires a parent count, because the XML makes them look unrelated: the
//! `parent=` attribute — **on the element or on a template it inherits**, which is where the
//! reference keeps the chat frames' — and nesting inside another frame's `<Frames>`. Reading only
//! instances reports all fourteen chat frames as top-level; reading only attributes reports
//! `ScreenshotStatus` as parentless when the reference nests it in `WorldFrame` for the property
//! `WorldFrame.xml`'s header states outright: *"Children of the world frame are visible even when
//! the UI is turned off."*
//!
//! ## What it can and cannot see
//!
//! The population is the shipped tree's **named, published frames**, so a virtual template is not
//! compared directly — but every instance of one is, carrying the template's resolved flags, which
//! is the same coverage by a different route (a `KNOWN` entry naming a template therefore reads as
//! stale and is refused).
//!
//! 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. It
//! would be a `KNOWN` entry's reason if any frame still needed one.
//!
//! The whole module skips cleanly with no install — the reference corpus is read off the
//! player's own patch chain, like every other client-data test here.
//! `movable`, `frameStrata`, `setAllPoints` and `hidden` are out of scope: each has an equivalent
//! spelling or is not mechanical.
use std::collections::{HashMap, HashSet};
use benilla_ui::framexml::{self, Element, TopLevel};
/// Element tags that are *frames* — the ones with flags to compare. Regions (`<Texture>`,
/// `<FontString>`) carry names too and must not shadow a frame of the same name.
/// The frame tags; a region carries a name too and must not shadow a frame of that name.
const FRAME_TAGS: &[&str] = &[
"Frame",
"Button",
@@ -102,9 +42,8 @@ const FRAME_TAGS: &[&str] = &[
"WorldFrame",
];
/// The five `<Scripts>` handler names that auto-enable the MOUSE kind, and only those five
/// (the kind-2 OR-chain, `0x769fb7`..`0x76a022`).
/// `OnDragStop`/`OnReceiveDrag` bind a slot and trip no enable — they are deliberately absent.
/// The `<Scripts>` handlers that auto-enable the mouse (the kind-2 OR-chain,
/// `0x769fb7`..`0x76a022`); `OnDragStop` and `OnReceiveDrag` enable nothing.
const MOUSE_HANDLERS: &[&str] = &[
"OnEnter",
"OnLeave",
@@ -113,24 +52,13 @@ const MOUSE_HANDLERS: &[&str] = &[
"OnDragStart",
];
/// Whether an element's TAG is a widget kind the constructor mouse-enables — so it needs no
/// `enableMouse` and no handler to be clickable.
///
/// **Answered by the engine, not by a copy of its list.** This used to be a `MOUSE_BY_CTOR` array
/// here whose own comment said it was "deliberately the SAME list `WidgetArena::create` uses… so a
/// wrong entry is wrong in one place rather than two". It was a second list, and it drifted
/// silently the first time the engine's was edited — this sweep then reported six scroll frames as
/// divergences that were only the two models disagreeing with each other, which is precisely what
/// the comment claimed could not happen.
/// Whether the constructor mouse-enables this widget kind; asked of the engine, never a copy.
fn tag_mouse_enabled_by_ctor(tag: &str) -> bool {
benilla_ui::script::frame_kind_from_tag(tag)
.is_some_and(benilla_ui::widget::mouse_enabled_by_ctor)
}
/// One accepted difference between benilla and the reference, with the reason it is accepted.
///
/// `frame` is benilla's name for it; `flag` is which of the four; `why` is why the difference is
/// right (or, for the handler gaps, why the honest fix is not this flag).
/// One accepted difference: benilla's frame name, the flag, and why the difference is right.
struct Known {
frame: &'static str,
flag: Flag,
@@ -156,85 +84,14 @@ impl std::fmt::Display for Flag {
}
}
/// 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.
/// The accepted differences, each with its reason; empty, so any new divergence fails the gate.
const KNOWN: &[Known] = &[
// ── The reference had it and we did not: seven handler gaps, not flag gaps (all retired) ───
//
// 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
// a plain StatusBar" — true of OUR `PetPaperDollFrame.xml`, which is deleted. The pet page is
// the reference's own file now, so its XP bar inherits `TextStatusBar` because it IS the
// reference's declaration, and the divergence has nothing left to describe.
//
// Seven `*ScrollFrame` mouse entries RETIRED here (1795). They read "our faux scroll pane is a
// Frame with an explicit `<OnMouseWheel>`, not a ScrollFrame" — a divergence that existed only
// 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.
// 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.
// ── We take the mouse where the reference does not ─────────────────────────────────────────
//
// The merchant rows' divergence RETIRED (1751): our `MerchantFrame.xml` is gone and the
// reference's own file is on the player's chain, so its rows are its rows. There were 13
// 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.
// 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
// takes the mouse from that kind's constructor, even though a faux pane never really scrolls —
// the kind is chosen for the wheel and the scrollbar plumbing. benilla's `FauxScrollFrameTemplate`
// is a plain `<Frame>` and wires the wheel explicitly, with an `<OnMouseWheel>` on the pane
// routed through `BenillaFauxScrollFrame_OnMouseWheel` (FriendsFrame.xml's own note at the
// friends pane). Same behaviour — the list scrolls under the wheel — reached a different way,
// and the reference's own faux panes use nothing else the mouse flag gives.
//
// The two mail panes are a further step out: ours are flat art with a FontString body, a
// render approximation MailFrame.xml names at the site, so neither side scrolls them.
// ── id ─────────────────────────────────────────────────────────────────────────────────────
//
// Empty, and it is worth saying why rather than leaving a bare header. The reference side of
// this comparison is XML only: the sweep cannot see a `SetID` the reference makes from Lua at
// `OnLoad`, so a frame the reference numbers *there* used to read as 0 here while ours
// declared the number in XML. The five entries that lived here were all one case — the bag
// bar's `CharacterBag0..3Slot` and `KeyRingButton`.
//
// 1751's third window deleted them by making the difference not exist: the bar IS
// `Interface\FrameXML\MainMenuBarBagButtons.xml` now, so those ids come from
// `PaperDollItemSlotButton_OnLoad`'s `GetInventorySlotInfo` and from `this:SetID(
// KEYRING_CONTAINER)` — the reference's own Lua, on both sides. This gate reported them the
// frame it was built to report (1751 §5: the drift instruments retire with the copies).
// ── parent ─────────────────────────────────────────────────────────────────────────────────
//
// 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.
// A parent entry is acceptable only inside `UIParent`'s tree: a seat crossing between it and
// the top level is a defect, since `SetFullScreenFrame` hides `UIParent` and all below it.
];
/// Every reference FrameXML document, read off the player's own patch chain in the client's
/// load order: the files `FrameXML.toc` lists, and what each `<Include>`s, each once. `None`
/// without an install. Off the chain, never an extracted folder, so the gate is armed on any
/// machine with the install rather than on the one that happened to extract it.
/// Every stock FrameXML document off the player's chain, in `FrameXML.toc` order with each
/// `<Include>`, once each; `None` without an install.
fn reference_documents() -> Option<Vec<framexml::ParsedDocument>> {
let data = benilla_formats::wow_data()?;
let chain = benilla_formats::open_chain(&data).ok()?;
@@ -256,8 +113,7 @@ fn reference_documents() -> Option<Vec<framexml::ParsedDocument>> {
let Ok(bytes) = chain.read(&path) else {
continue;
};
// Blizzard ships a UTF-8 BOM on some of these and stray high bytes in comments; the parse
// is what matters, so read lossily rather than refusing the file.
// Some files carry a UTF-8 BOM and stray high bytes in comments: read lossily.
let text = String::from_utf8_lossy(&bytes);
let Ok(doc) = framexml::parse(text.trim_start_matches('\u{feff}')) else {
continue;
@@ -273,9 +129,8 @@ fn reference_documents() -> Option<Vec<framexml::ParsedDocument>> {
Some(docs)
}
/// Every named *frame* element in the reference corpus, keyed by name — templates and instances
/// alike, nested `<Frames>` included, `$parent`-relative names excluded (they repeat across
/// templates and name nothing on their own).
/// Every named frame element in the corpus, templates and instances, nested ones included;
/// `$parent` names are skipped, as they name nothing on their own.
fn reference_frames() -> Option<HashMap<String, Element>> {
let mut out: HashMap<String, Element> = HashMap::new();
for doc in reference_documents()? {
@@ -288,8 +143,7 @@ fn reference_frames() -> Option<HashMap<String, Element>> {
Some(out)
}
/// Recurse an element tree, publishing every named frame. First name wins, matching the client's
/// auto-publish rule.
/// Publish every named frame under `el`; the first name wins, as in the client's auto-publish.
fn collect_named(el: &Element, out: &mut HashMap<String, Element>) {
if FRAME_TAGS.iter().any(|t| t.eq_ignore_ascii_case(&el.tag)) {
if let Some(name) = el.attr("name") {
@@ -303,15 +157,9 @@ fn collect_named(el: &Element, out: &mut HashMap<String, Element>) {
}
}
/// Every named reference frame's **enclosing frame**, if it is nested inside one.
///
/// A frame acquires its parent one of two ways, and the XML gives no hint that they are the same
/// question: `parent="X"` (on the element, or on a template it inherits — see [`resolved_attr`]),
/// or *nesting* inside another frame's `<Frames>`. The reference uses both heavily —
/// `DropDownList1` is top-level with neither, `ScreenshotStatus` carries no attribute at all and
/// is nested in `WorldFrame` — so reading only one of them is worse than reading neither. This
/// half is the nesting; `resolved_attr` is the other, and the attribute wins where both exist.
/// `enclosing` is the nearest named frame ancestor, threaded down the walk.
/// Each named frame's nearest named enclosing frame: the nesting half of its parent, where
/// [`resolved_attr`] reads `parent=` and wins. `ScreenshotStatus` has no `parent=` and is nested
/// in `WorldFrame` (`WorldFrame.xml:42`); `DropDownList1` has neither.
fn collect_nesting(
el: &Element,
enclosing: Option<&str>,
@@ -345,8 +193,7 @@ fn reference_nesting() -> Option<HashMap<String, Option<String>>> {
Some(out)
}
/// The templates `el` inherits, **right to left** — a later name in the list overrides an earlier
/// one, so the search for an inherited value has to try the last one first.
/// The templates `el` inherits, last first: a later name overrides an earlier one.
fn inherits(el: &Element) -> impl Iterator<Item = &str> {
el.attr("inherits")
.unwrap_or("")
@@ -358,8 +205,7 @@ fn inherits(el: &Element) -> impl Iterator<Item = &str> {
.rev()
}
/// An attribute's value on `name`, following `inherits=`; the element's own value wins over any
/// template's. `depth` guards a malformed cycle (the shipped corpus has none).
/// `attr` on `name`, else on its templates; `depth` guards a malformed cycle.
fn resolved_attr<'a>(
name: &str,
frames: &'a HashMap<String, Element>,
@@ -398,9 +244,8 @@ fn declares_handler(
own || inherits(el).any(|t| declares_handler(t, frames, handlers, depth + 1))
}
/// Whether the reference's frame of this name **takes the mouse** once loaded — the three ways
/// `0x76af00(2, -1)` is reached: the widget's own ctor, the
/// `enableMouse` attribute, or an auto-enabling `<Scripts>` handler.
/// Whether the reference frame takes the mouse once loaded: `0x76af00(2, -1)` is reached from its
/// constructor, `enableMouse`, or an auto-enabling handler.
fn reference_takes_mouse(name: &str, frames: &HashMap<String, Element>) -> bool {
let Some(el) = frames.get(name) else {
return false;
@@ -415,9 +260,7 @@ fn reference_takes_mouse(name: &str, frames: &HashMap<String, Element>) -> bool
declares_handler(name, frames, MOUSE_HANDLERS, 0)
}
/// The reference name for one of ours: the same name, or the one behind our `Benilla` prefix
/// (`BenillaChatFrameTemplate` is the reference's `ChatFrameTemplate` — the rename that made a
/// text diff report a gap of zero on a window missing every flag).
/// The reference name for one of ours: the same name, or the one behind a `Benilla` prefix.
fn reference_name<'a>(ours: &str, frames: &'a HashMap<String, Element>) -> Option<&'a str> {
if let Some((k, _)) = frames.get_key_value(ours) {
return Some(k);
@@ -431,12 +274,8 @@ fn describe(frame: &str, flag: Flag, ours: &str, theirs: &str) -> String {
format!(" {frame} — {flag}: ours {ours}, reference {theirs}")
}
/// **Every frame both UIs name carries the reference's `toplevel`, mouse enable, `id` and
/// parent** — read off the loaded engine, with [`KNOWN`] the only accepted differences.
///
/// Verified to fail: delete `toplevel="true"` from `CharacterFrame.xml` and this names
/// `CharacterFrame`; delete `id="1"` from `ActionButton1` and it names that; put
/// `parent="UIParent"` back on `BlackoutWorld` and it names that.
/// Every frame both UIs name carries the reference's `toplevel`, mouse enable, `id` and parent,
/// read off the loaded engine; [`KNOWN`] holds the only accepted differences.
#[test]
fn the_shipped_frames_carry_the_references_flags() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -451,10 +290,7 @@ fn the_shipped_frames_carry_the_references_flags() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
// The UI loads on world entry, so a player always exists by then.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -468,9 +304,7 @@ fn the_shipped_frames_carry_the_references_flags() {
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
// The loaded frame's own parent, by name — `GetParent()`, not the `parent=` attribute, so a
// frame that acquires its parent by NESTING reads the same as one that declares it. An empty
// string is a top-level frame.
// `GetParent()` by name, so nesting and `parent=` read alike; "" is a top-level frame.
let parent_of = |n: &str| -> Option<String> {
s.eval::<String>(&format!(
"local f = getglobal(\"{n}\") if not f or not f.GetParent then return \"\" end \
@@ -480,7 +314,6 @@ fn the_shipped_frames_carry_the_references_flags() {
.filter(|v| !v.is_empty())
};
// Read the flags off the ENGINE, not off our XML — the whole point of the module.
let flags = |n: &str| -> Option<(bool, bool, i64)> {
s.eval::<i64>(&format!(
"local f = getglobal(\"{n}\") \
@@ -499,8 +332,7 @@ fn the_shipped_frames_carry_the_references_flags() {
let mut divergences: Vec<String> = Vec::new();
let mut compared = 0usize;
// Every KNOWN entry starts unclaimed; a real divergence claims it. What is left at the end
// is an entry describing a difference that no longer exists.
// A divergence claims its KNOWN entry; one left unclaimed at the end is stale.
let mut unused: Vec<&Known> = KNOWN.iter().collect();
let mut ours: Vec<String> = super::shipped_xml_tests::shipped_frame_names();
@@ -520,14 +352,9 @@ fn the_shipped_frames_carry_the_references_flags() {
let want_id: i64 = resolved_attr(theirs, &reference, "id", 0)
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
// The declared parent wins over the nesting, and `resolved_attr` follows `inherits=` —
// which is not a detail: the reference puts the chat frames' `parent="UIParent"` on
// `FloatingChatFrameTemplate`/`ChatTabTemplate`, and reading instances alone would report
// all fourteen as top-level. That template blind spot is the one 1734's own gap analysis
// fell into (commit "the chat templates carry parent=UIParent").
//
// Parent names are then compared through the same rename map the frames themselves are: a
// benilla-only prefix on the PARENT would otherwise report every child of it as diverged.
// `parent=` wins over nesting and follows `inherits=`: the chat frames take theirs from
// `FloatingChatFrameTemplate` and `ChatTabTemplate` (`FloatingChatFrame.xml:211`, `:36`).
// A parent name goes through the same `Benilla` prefix map as a frame's.
let want_parent = resolved_attr(theirs, &reference, "parent", 0)
.map(str::to_string)
.or_else(|| nesting.get(theirs).cloned().flatten());
@@ -572,14 +399,8 @@ fn the_shipped_frames_carry_the_references_flags() {
}
assert!(
// 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.
// The floor guards the pairing, not the census: the paired count falls as each of our
// files declaring a reference-named frame retires.
compared > 5,
"only {compared} frames compared — the pairing broke, and the sweep guards nothing"
);
@@ -590,8 +411,6 @@ fn the_shipped_frames_carry_the_references_flags() {
divergences.len(),
divergences.join("\n")
);
// A KNOWN entry that no longer describes a real difference is stale documentation claiming a
// divergence that has been fixed — the exact rot this module exists to prevent elsewhere.
let stale: Vec<String> = unused
.iter()
.map(|k| format!(" {} ({}) — claimed: {}", k.frame, k.flag, k.why))
+34 -108
View File
@@ -1,11 +1,5 @@
//! The social window (decision 0668, `FriendsFrame.xml`): the tab strip, the two list tabs and
//! their toggle pair, the who list's columns, and the verbs each button queues — driven exactly
//! as `ui_social`'s feed drives it (a pushed [`SocialState`] snapshot, then the list event).
//!
//! What these guard that the Rust-side unit tests structurally cannot: the *window* is Lua over a
//! snapshot, so a getter whose returns are in the wrong order, a row template whose FontString is
//! misnamed, or a button wired to the wrong verb are all invisible to `ui_social`'s tests and
//! green in the parse sweep. Each test here fails on exactly one of those.
//! The social window (`FriendsFrame.xml`), fed as `ui_social` feeds it: a pushed [`SocialState`]
//! snapshot, then the list event.
use benilla_ui::script::{FriendInfo, SocialRequest, SocialState, UiScript, WhoInfo};
@@ -17,7 +11,6 @@ fn setup() -> UiScript {
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(),
@@ -29,7 +22,6 @@ fn who(name: &str, level: u32, class: &str, zone: &str) -> WhoInfo {
}
}
/// 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()
@@ -46,23 +38,14 @@ fn friend(name: &str, level: u32, class: &str, area: &str, connected: bool) -> F
}
}
/// Push a snapshot and fire the list event that follows it, exactly as `feed_social` does.
/// Pushes a snapshot and fires the list event after it, as `feed_social` does.
fn push(s: &mut UiScript, state: SocialState, event: &str) {
s.set_social(state);
s.fire_event(event, Vec::new());
}
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — the
/// GUILDLESS case of `InGuildCheck`.
///
/// **This assertion changed meaning with decision 1257 and was kept rather than deleted.** It used
/// to pin a hardcoded `PanelTemplates_DisableTab(this, 3)`, because there was no guild arc and the
/// tab was permanently dead. The tab is live now, and this is the other half of its law: with no
/// guild seated in the engine, `IsInGuild()` answers nil, `FriendsFrame_OnShow` runs
/// `InGuildCheck()`, and the tab greys — which is exactly what the reference does for a character
/// in no guild. The IN-guild half is `guild_tests`. Note that this file seats no guild fixture at
/// all: it is asserting against the REAL `script::guild` bindings' empty state, which is the state
/// every character starts a session in.
/// With no guild seated, `IsInGuild()` is nil and `FriendsFrame_OnShow`'s `InGuildCheck()` greys
/// the guild tab (`FriendsFrame.lua:78`, `:920-922`).
#[test]
fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -88,8 +71,7 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
1,
"the guild tab is disabled, not absent"
);
// …and the tab refuses to be reached by NUMBER either, which is the second of the two locks
// (`ToggleFriendsFrame`'s own `tab == 3 and not IsInGuild()` early-out).
// `ToggleFriendsFrame(3)` returns early without a guild (`FriendsFrame.lua:750`).
s.run("ToggleFriendsFrame(3)").unwrap();
assert!(
!s.eval::<bool>("return GuildFrame:IsVisible()").unwrap(),
@@ -98,10 +80,7 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
assert!(s
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
// The two tab kinds are different templates and must not be swapped: the strip along the
// bottom is the big window tab (20px end slices), the Friends/Ignore pair inside tab 1 is the
// ref's compact TabButtonTemplate (16px). Getting this wrong is invisible to every behaviour
// assertion — it only shows up as the wrong-looking window.
// Big window tabs (20px ends) along the bottom; the Friends/Ignore pair is compact (16px).
assert_eq!(
s.eval::<f64>("return FriendsFrameTab1Left:GetWidth()")
.unwrap(),
@@ -114,28 +93,21 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
16.0,
"the in-panel toggle pair is the compact tab art"
);
// …and toggling the same tab again closes the window (the ref's own second branch).
// The selected tab again closes the window (`FriendsFrame.lua:753-755`).
s.run("ToggleFriendsFrame(1)").unwrap();
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** (`0x76a0d0`) — 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.
/// The tabs inherit through `FriendsFrameTabTemplate`, which adds only `<OnClick>`; handlers
/// replace per name (`0x76a0d0`), so the base template's `<OnShow>` fit still runs two hops down.
#[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.
/// 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).
// Synchronous, like the app's `AtlasMeasurer`: the reference fits the tab inline.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
super::test_ui::load_social_ui(&mut s);
s.run("ToggleFriendsFrame(1)").unwrap();
@@ -158,9 +130,7 @@ fn the_social_tabs_fit_their_labels_on_the_first_show() {
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.
/// Fails if `GetFriendInfo`'s six returns come back out of order.
#[test]
fn friend_rows_render_the_online_and_offline_templates() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -209,8 +179,6 @@ fn friend_rows_render_the_online_and_offline_templates() {
);
}
/// The three friend buttons follow the SELECTED friend: an offline one can be removed but not
/// whispered or invited, and an empty list disables all three.
#[test]
fn the_friend_buttons_follow_the_selection() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -249,8 +217,7 @@ fn the_friend_buttons_follow_the_selection() {
);
}
/// Remove Friend addresses the selected ROW (the wire removes by guid, which the app resolves
/// from this index), and Group Invite addresses the NAME.
/// Remove Friend queues the row index (the app resolves the guid); Group Invite queues the name.
#[test]
fn the_friend_buttons_queue_their_verbs() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -282,9 +249,7 @@ 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).
// Stock `ChatFrame_OpenChat` leaves the text for the box's next `OnUpdate` to apply.
s.run("FriendsFrame_SendMessage()").unwrap();
s.tick(0.05);
assert!(s
@@ -297,8 +262,6 @@ fn the_friend_buttons_queue_their_verbs() {
);
}
/// The Ignore toggle-tab swaps tab 1's list without leaving the tab, and the ignore rows render
/// their names. `ShowIgnorePanel()` (what `/ignore` with no name runs) lands on the same list.
#[test]
fn the_ignore_list_is_the_other_half_of_tab_one() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -337,7 +300,6 @@ fn the_ignore_list_is_the_other_half_of_tab_one() {
"Ninja"
);
// Remove Player un-ignores the selected name.
let _ = s.take_social_requests();
s.run("FriendsFrame_UnIgnore()").unwrap();
assert_eq!(
@@ -345,14 +307,12 @@ fn the_ignore_list_is_the_other_half_of_tab_one() {
vec![SocialRequest::DelIgnore("Spammer".to_string())]
);
// Back to the friends half — through the IGNORE frame's own Friends tab, since the friends
// frame's copy is hidden with it (the ref ships the pair twice for exactly this reason).
// Through the ignore frame's own toggle pair: the friends frame's copy hides with its list.
s.run("IgnoreFrameToggleTab1:Click()").unwrap();
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).
// Stock `ShowIgnorePanel` only shows the window (`FriendsFrame.lua:781-788`).
s.run("ShowIgnorePanel()").unwrap();
assert!(s.eval::<bool>("return FriendsFrame:IsVisible()").unwrap());
assert!(s
@@ -360,9 +320,7 @@ fn the_ignore_list_is_the_other_half_of_tab_one() {
.unwrap());
}
/// The who list fills its four columns, the variable column follows the dropdown, and the totals
/// line uses the singular/plural template. The class/race swap between the wire and the Lua API
/// makes the column order worth pinning.
/// The wire sends class before race, and `GetWhoInfo` returns race before class.
#[test]
fn who_rows_fill_their_columns() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -415,7 +373,6 @@ fn who_rows_fill_their_columns() {
);
}
/// The variable column really is variable: picking Guild re-paints it and sorts by that key.
#[test]
fn the_who_dropdown_switches_the_variable_column() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -447,7 +404,6 @@ fn the_who_dropdown_switches_the_variable_column() {
"Legacy of Steel"
);
// A column header sorts by its own key.
s.run("WhoFrameColumnHeader3:Click()").unwrap();
assert_eq!(
s.take_social_requests(),
@@ -455,15 +411,8 @@ 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.
/// The reference's seven-slot sort chain (`SortWho`, `0x5ad890`): a repeated header reverses and
/// an earlier key breaks ties. Rows are read with no tick: `WHO_LIST_UPDATE` fires inside the call.
#[test]
fn the_who_headers_sort_and_a_repeated_click_reverses() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -496,21 +445,18 @@ fn the_who_headers_sort_and_a_repeated_click_reverses() {
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.
// Header 3 is Level (`FriendsFrame.xml:1394`).
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.
// Name, promoted from behind, keeps the direction it was left in.
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]
fn the_who_buttons_need_a_selected_row() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -563,9 +509,7 @@ 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.)
// Stock `WhoList_Update` keys the buttons on `selectedWho`, which an answer never clears.
assert!(
s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
.unwrap(),
@@ -573,9 +517,7 @@ fn the_who_buttons_need_a_selected_row() {
);
}
/// The Who frame tells the engine where the NEXT answer goes: showing it claims the results,
/// hiding it hands them back to the chat frame. Without this, a `/who` typed with the window
/// closed would print nothing at all.
/// The who frame's show and hide route answers with `SetWhoToUI` (`FriendsFrame.xml:1711-1716`).
#[test]
fn showing_the_who_frame_claims_the_next_answer() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -594,7 +536,6 @@ fn showing_the_who_frame_claims_the_next_answer() {
);
}
/// The `/who` edit box sends its filter verbatim — the string the app parses into wire fields.
#[test]
fn the_who_edit_box_sends_its_filter() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -610,8 +551,6 @@ fn the_who_edit_box_sends_its_filter() {
);
}
/// 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]
fn add_friend_without_a_target_opens_the_name_dialog() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -636,8 +575,7 @@ fn add_friend_without_a_target_opens_the_name_dialog() {
let _ = s.take_social_requests();
s.run("StaticPopup1EditBox:SetText(\"Onerogue\")").unwrap();
// Through the button: the stock dialog's OnAccept reads `this:GetParent()`, which only a
// real click seats.
// Through the button: the stock `OnAccept` reads `this:GetParent()`, which a click seats.
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(
s.take_social_requests(),
@@ -649,8 +587,7 @@ fn add_friend_without_a_target_opens_the_name_dialog() {
);
}
/// A re-opened dialog starts EMPTY. Left filled, a reflexive Enter would befriend whoever was
/// typed last time.
/// The stock `ADD_FRIEND`'s `OnHide` empties the box (`StaticPopup.lua:788-793`).
#[test]
fn the_name_dialog_reopens_empty() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -667,9 +604,7 @@ fn the_name_dialog_reopens_empty() {
);
}
/// Right-clicking a `/who` row opens the shared FRIEND menu addressed by NAME — the point of
/// that menu, since a who hit has no unit token behind it. Whisper and Invite are the rows that
/// survive UnitPopup's gating, and each one acts on the name.
/// A who hit has no unit token, so the shared `FRIEND` menu is addressed by name.
#[test]
fn right_clicking_a_who_row_opens_the_friend_menu() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -699,14 +634,12 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
"Tigole",
"addressed by name, not by a unit token"
);
// …and a right-click must NOT also select the row (the ref's two branches are exclusive).
assert!(
!s.eval::<bool>("return WhoFrameAddFriendButton:IsEnabled() ~= 0")
.unwrap(),
"right-click does not select"
);
// Whisper acts on the name.
let whisper = r#"
for i = 1, UIDROPDOWNMENU_MAXBUTTONS do
local b = getglobal("DropDownList1Button" .. i)
@@ -718,8 +651,7 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
Some(1),
"the menu has a Whisper row"
);
// 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).
// The stock `WHISPER` row is `ChatFrame_SendTell(name)`, applied at the box's next `OnUpdate`.
s.tick(0.05);
assert_eq!(
s.eval::<(String, String)>("return ChatFrameEditBox.chatType, ChatFrameEditBox.tellTarget")
@@ -728,8 +660,7 @@ fn right_clicking_a_who_row_opens_the_friend_menu() {
);
}
/// An OFFLINE friend's right-click opens nothing — there is no verb to offer them, which is the
/// ref's own `connected` gate on the whole menu.
/// Stock `FriendsFrame_ShowDropdown` opens only for a connected friend (`FriendsFrame.lua:35-37`).
#[test]
fn right_clicking_an_offline_friend_opens_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -752,8 +683,7 @@ fn right_clicking_an_offline_friend_opens_nothing() {
);
}
/// Selecting a row queues the selection to the app AND reads back immediately — the same-tick
/// read `FriendsList_Update` does right after `SetSelectedFriend`.
/// `FriendsList_Update` reads the selection back right after `SetSelectedFriend`.
#[test]
fn selecting_a_row_reads_back_in_the_same_tick() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -784,13 +714,9 @@ fn selecting_a_row_reads_back_in_the_same_tick() {
.contains(&SocialRequest::SelectFriend(2)));
}
/// **B363 — the who list reaches its last rows.** The symptom: a `/who` of 49 shows 17, and the
/// knob travels while the rows stay. 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.
/// `WhoListScrollFrame` is 287 tall (`FriendsFrame.xml:1661`), so its overflow `n * 16 - 287` is
/// 15px short of the bar's `(n - 17) * 16`; the reference stores the scroll value unclamped
/// (`0x786db0`), so the bar's end reaches the last row.
#[test]
fn the_who_list_reaches_its_last_row_at_the_bars_end() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -818,7 +744,7 @@ fn the_who_list_reaches_its_last_row_at_the_bars_end() {
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The reference's own numbers, and the control: the overflow is shorter than the bar.
// The control: the overflow is shorter than the bar's range.
let (_, bar_max) = s
.eval::<(f64, f64)>("return WhoListScrollFrameScrollBar:GetMinMaxValues()")
.unwrap();
@@ -1,19 +1,5 @@
//! The shipped `assets/ui/GameMenuFrame.xml` — the frame ESC opens (decision 0674).
//!
//! What these guard, in order: the ladder geometry (the ERA menu shape minus its AddOns rung —
//! 200×267, seven rungs in three 20-gapped sections, the era layout engine's own numbers
//! precomputed in the XML's header note); the greyed pending entries; the live buttons' wire
//! intents and sounds;
//! the ESC ladder's two new rungs (open when nothing is left to eat, close before everything
//! else); the micro button's `clicked` toggle; the native-center rule that makes the menu take
//! the screen (windows close on the way in, and nothing opens while it is up); and the camp/quit
//! countdown dialogs end to end. The Options button's own click path is options_tests'.
//!
//! The **window** most of these park in the menu's way is a bag, and since decision 1751 that is
//! the reference's own `ContainerFrame1..12` off the player's installed patch chain — so those
//! tests take [`bag_harness_with`], gate on `wow_data_or_skip!`, and ask [`bag_open`] rather than
//! naming a frame (the twelve windows are recycled). The bag BAR the greying test measures —
//! `MainMenuBarBackpackButton` and `CharacterBag0Slot`..`3Slot` — is still ours, unchanged.
//! Tests for our `GameMenuFrame.xml`, the frame ESC opens. The bags in its way are the stock
//! `ContainerFrame1..12`, recycled, so tests ask [`bag_open`] rather than naming a frame.
use benilla_ui::script::{
ContainerSlot, ContainerState, LootRow, LootState, SessionRequest, SoundRequest, UiScript,
@@ -21,25 +7,14 @@ use benilla_ui::script::{
use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI};
/// The engine the menu needs behind it: fonts, the panel manager + popup engine, the shared widget
/// kit, and the menu. `extra` adds the files a given test wants in the way (a bag, a loot window).
///
/// `UIPanelTemplates.xml` is not optional and never was in production: `GameMenuButtonTemplate`
/// lives there (the reference's own file for it) since the colour picker needed it from above
/// GameMenuFrame.xml's deliberately-LAST seat in the manifest. Without it every rung comes out
/// sizeless and the ladder geometry below reads nil — which is exactly how the move was caught.
///
/// **`extra` is DEDUPED against this base list**, and that is what lets a caller hand it
/// [`BAG_UI`] whole (which names `Fonts.xml`, `MoneyFrame.xml`, `UiPanels.xml` and
/// `UIPanelTemplates.xml` for its own reasons) without loading a file — and re-running its
/// OnLoads — twice. Order is still the caller's: the first mention of a file is where it lands.
/// The menu over what it needs (`GameMenuButtonTemplate` is `UIPanelTemplates.xml:403`), with
/// `extra` in its way, deduped so no file loads twice.
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let files: Vec<&str> = [
"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.
// Before the menu: its `parent="UIParent"` resolves at load.
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
@@ -68,14 +43,7 @@ fn harness() -> UiScript {
harness_with(&[])
}
/// [`harness_with`], with the reference's bag stack folded in — the harness for every test below
/// that parks a BAG in the menu's way. Since decision 1751 those windows are the reference's own
/// `ContainerFrame1..12`, executed off the player's installed patch chain, so `BAG_UI` names
/// `Interface\FrameXML\ContainerFrame.xml` and every caller opens with `wow_data_or_skip!`.
///
/// `before` loads AHEAD of the stack, for the one file that has to: `ActionBar.xml` declares
/// `MainMenuBarArtFrame`, which `BagFrame.xml`'s bag bar anchors into and seats itself above.
/// `after` is the ordinary tail (a merchant, a loot window).
/// [`harness_with`] over the stock bag stack, with `before` ahead of it and `after` behind.
fn bag_harness_with(before: &[&str], after: &[&str]) -> UiScript {
let files: Vec<&str> = before
.iter()
@@ -86,12 +54,8 @@ fn bag_harness_with(before: &[&str], after: &[&str]) -> UiScript {
harness_with(&files)
}
/// The seven buttons, top to bottom — the ERA ladder, its own shape (the director's call on the
/// 0951 review; GameMenuFrame.xml's header SCOPE note quotes the era source), minus two seats:
/// 0997's carved Key Bindings seat left with its standalone window (decision 1008 folded key
/// bindings into the Options window, where the era menu always pointed), and the AddOns rung
/// left with the in-game panel when the director made the char-select AddOns screen the only
/// addon UI (it had been live since 1197).
/// Top to bottom. Deviation: the 1.15 era client's ESC ladder, because its settings screens
/// replace 1.12's; without the AddOns rung, as the character-select screen is the only addon UI.
const LADDER: [&str; 7] = [
"GameMenuButtonOptions",
"GameMenuButtonEditMode",
@@ -102,11 +66,8 @@ const LADDER: [&str; 7] = [
"GameMenuButtonContinue",
];
/// The ladder geometry — the era layout engine's own numbers (MainMenuFrameTemplates: padding
/// 32/28/28/28, spacing 0, AddSection gap 20) over our seven rungs: 200×267, each button
/// 144×21 at x=28, tops at 32/73/94/115/156/177/218 — three sections split by the 20-unit
/// gaps after Options, after Macros, and before Return to Game. First thing to break if the
/// era shape is ever "tidied".
/// The era layout (MainMenuFrameTemplates: padding 32 top and 28 elsewhere, 20-unit section
/// gaps) over seven 144x21 rungs: 200x267, gaps after Options, after Macros and before Return.
#[test]
fn the_menu_has_the_era_frame_and_button_ladder() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -147,11 +108,6 @@ fn the_menu_has_the_era_frame_and_button_ladder() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The two entries with nothing behind them — Edit Mode, Support — are DISABLED (the pending
/// idiom: grey label, `-Disabled` art, exactly how the era menu greys a dead entry). Everything
/// else in the ladder is live. **Macros left this list in decision 0983**, when the macro window
/// landed behind it. (AddOns had left it in 1197 and then left the ladder entirely with its
/// panel — the char-select AddOns screen is the only addon UI.)
#[test]
fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -178,15 +134,13 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
"{name} is live"
);
}
// The labels read the era strings (Edit Mode through the HUD_EDIT_MODE_MENU global — the
// label has to come from the string rather than a literal).
// Edit Mode reads the era's `HUD_EDIT_MODE_MENU` and Continue 1.12's `RETURN_TO_GAME`; Options
// is a literal, as 1.12's `OPTIONS_MENU` is "Options Menu".
assert_eq!(
s.eval::<String>("return GameMenuButtonEditMode:GetText()")
.unwrap(),
"Edit Mode"
);
// Continue's label is the reference GlobalString, read back through the global; Options is
// the one literal (1.12 GlobalStrings has no GAMEOPTIONS_MENU — the XML says so).
assert_eq!(
s.eval::<String>("return GameMenuButtonContinue:GetText()")
.unwrap(),
@@ -200,9 +154,7 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The ESC ladder's two new rungs (`ToggleGameMenu`, UIParent.lua l.1485-1495): a press with
/// nothing left to eat OPENS the menu (igMainMenuOpen), and the next press closes it
/// (igMainMenuQuit — the reference's own choice of kit) without reaching any rung below.
/// `ToggleGameMenu`'s ESC arm (`UIParent.lua:1482-1496`).
#[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!();
@@ -217,7 +169,7 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
s.set_money(0);
s.set_container(0, Some(backpack()));
// A press with a window open is eaten by CloseAllWindows — the menu stays down.
// With a window open, `CloseAllWindows` eats the press.
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
@@ -227,7 +179,6 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
"and did NOT also open the menu — one eater per press"
);
// Nothing left: the next press opens it.
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
@@ -235,7 +186,6 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOpen".into())));
// And the press after that closes it again.
s.run("ToggleGameMenu()").unwrap();
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
assert!(s
@@ -244,9 +194,7 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The micro button's form (`ToggleGameMenu(1)`, l.1466-1480) is a plain TOGGLE, not the ladder:
/// clicking it with a window open closes the window AND opens the menu in one go — the press-eating
/// rule is the ESC key's, not the button's.
/// `ToggleGameMenu(1)`, the micro button's form (`UIParent.lua:1466-1480`), is a plain toggle.
#[test]
fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -275,20 +223,18 @@ fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The native-center rule (`ShowUIPanel` l.697-702 + `CanOpenPanels`): opening the menu closes the
/// panel slots and the bags, and while it holds the center NOTHING else opens — which is what makes
/// "you can't open your bags with the ESC menu up" true rather than merely greyed.
/// The menu is a native-center panel: showing it closes the panels and bags
/// (`UIParent.lua:698-704`), and while it holds the center `CanOpenPanels` refuses the rest.
#[test]
fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(
&[],
&[
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"ScrollTemplates.xml",
"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.
// The stock loot window, as `test_ui::LOOT_UI`.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
@@ -341,29 +287,24 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
"a native-center frame is up: nothing may open"
);
// The refusal itself: a panel asked to show while the menu is up simply doesn't.
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(
!s.eval::<bool>("return LootFrame:IsVisible()").unwrap(),
"ShowUIPanel refuses a left-area panel behind the menu"
);
// …and works again the moment the menu goes down.
s.run("HideUIPanel(GameMenuFrame)").unwrap();
s.run("ShowUIPanel(LootFrame)").unwrap();
assert!(s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The three live buttons: each plays its own reference kit and does its one thing. Logout and Exit
/// Game both queue on the session seam — the difference between them is entirely app-side (whether
/// the completed logout ends the process), which is why they look identical from here.
/// Logout and Exit Game queue alike here: whether a finished logout ends the process is app-side.
#[test]
fn the_live_buttons_queue_their_intents_and_play_their_kits() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Return to Game — just closes, no intent.
s.run("ToggleGameMenu()").unwrap();
let _ = s.take_sounds();
s.run("GameMenuButtonContinue:Click()").unwrap();
@@ -373,7 +314,6 @@ fn the_live_buttons_queue_their_intents_and_play_their_kits() {
.contains(&SoundRequest::KitName("igMainMenuContinue".into())));
assert!(s.take_session_requests().is_empty());
// Logout — the request, and the menu goes away behind it.
s.run("ToggleGameMenu()").unwrap();
let _ = s.take_sounds();
s.run("GameMenuButtonLogout:Click()").unwrap();
@@ -383,7 +323,6 @@ fn the_live_buttons_queue_their_intents_and_play_their_kits() {
.contains(&SoundRequest::KitName("igMainMenuLogout".into())));
assert!(!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
// Exit Game — the same shape, its own kit.
s.run("ToggleGameMenu()").unwrap();
let _ = s.take_sounds();
s.run("GameMenuButtonQuit:Click()").unwrap();
@@ -394,11 +333,8 @@ fn the_live_buttons_queue_their_intents_and_play_their_kits() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The camp countdown end to end (ref StaticPopup.lua l.564-582 + UIParent.lua l.304-315): the
/// server's non-instant answer becomes `PLAYER_CAMPING`, the dialog counts down from the server's
/// own 20 s, and closing it EARLY calls the logout off. The `%d %s` text is written by the popup
/// engine's countdown branch, not by the entry — a dialog that opened blank and stayed blank would
/// mean CAMP fell out of that which-list.
/// `PLAYER_CAMPING` shows CAMP (`UIParent.lua:304-307`, `StaticPopup.lua:564-583`), whose `%d %s`
/// text the popup engine's countdown branch writes, not the entry (`StaticPopup.lua:1727-1758`).
#[test]
fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -411,7 +347,7 @@ fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
"the camp dialog took an instance"
);
// One tick of the engine's countdown fills the text from CAMP_TIMER.
// One tick of the countdown fills the text from `CAMP_TIMER`.
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.5)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
@@ -419,7 +355,7 @@ fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
"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).
// Cancel is the only button: 1.12 comments out `CAMP_NOW` (`StaticPopup.lua:567`).
assert_eq!(
s.eval::<String>("return StaticPopup1Button1:GetText()")
.unwrap(),
@@ -431,9 +367,7 @@ fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
"no second button"
);
// Closing it early cancels the logout — twice over in the reference's own shape (the button's
// OnAccept, and the OnHide guard that catches every other way out); a doubled cancel is
// harmless on the wire, and losing it would strand the character mid-countdown.
// An early close cancels from both OnAccept and OnHide, as in the reference; harmless twice.
s.run("StaticPopup1Button1:Click()").unwrap();
assert!(
s.take_session_requests()
@@ -446,9 +380,8 @@ fn player_camping_opens_a_counting_dialog_whose_early_close_cancels() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A countdown that RUNS OUT must not cancel itself: the engine zeroes `timeleft` before hiding, and
/// the entry's OnHide guard reads exactly that. (Get this wrong and every logout in the field is
/// cancelled by its own dialog at t=0 — the character never leaves.)
/// The engine zeroes `timeleft` before hiding (`StaticPopup.lua:1716-1722`), and the entry's OnHide
/// cancels only while `timeleft > 0`.
#[test]
fn a_countdown_that_expires_does_not_cancel_the_logout() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -468,8 +401,8 @@ fn a_countdown_that_expires_does_not_cancel_the_logout() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The quit dialog is the camp dialog under another name, plus the one thing a quit can do without
/// the server: "Exit now". And `LOGOUT_CANCEL` (the server's cancel ack) takes either one down.
/// QUIT adds "Exit now" (`ForceQuit`); `LOGOUT_CANCEL`, the server's cancel ack, hides CAMP and
/// QUIT alike (`UIParent.lua:312-316`).
#[test]
fn player_quiting_offers_exit_now_and_logout_cancel_closes_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -496,7 +429,6 @@ fn player_quiting_offers_exit_now_and_logout_cancel_closes_it() {
.take_session_requests()
.contains(&SessionRequest::ForceQuit));
// The server's own cancel ack hides whichever is up, without queueing anything further.
s.fire_event("PLAYER_CAMPING", vec![]);
assert!(s
.eval::<bool>("return StaticPopup_Visible(\"CAMP\") ~= nil")
@@ -509,11 +441,8 @@ fn player_quiting_offers_exit_now_and_logout_cancel_closes_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's own OnShow guard on both live buttons: a menu re-opened over a running countdown
/// shows them dead, because re-arming a logout that is already counting is meaningless.
///
/// Opened with the micro button's form on purpose — the ESC key can't get you here at all, which is
/// the next test.
/// The stock OnShow guard (`GameMenuFrame.xml:134-140`) disables both while CAMP or QUIT counts.
/// Opened with the micro button, because ESC during a countdown dismisses the dialog instead.
#[test]
fn logout_and_exit_read_disabled_while_a_countdown_runs() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -529,7 +458,6 @@ fn logout_and_exit_read_disabled_while_a_countdown_runs() {
.eval::<bool>("return GameMenuButtonQuit:IsEnabled() ~= 0")
.unwrap());
// With the countdown gone, a re-opened menu has them back.
s.run("HideUIPanel(GameMenuFrame)").unwrap();
s.fire_event("LOGOUT_CANCEL", vec![]);
s.run("ToggleGameMenu(1)").unwrap();
@@ -539,10 +467,8 @@ fn logout_and_exit_read_disabled_while_a_countdown_runs() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// ESC with a countdown up never reaches the menu: the popup rung is FIRST in the ladder
/// (`StaticPopup_EscapePressed`, l.1482) and CAMP is `hideOnEscape`, so the press dismisses the
/// dialog — which, per the entry's own OnHide, calls the logout off. Pressing ESC to "get out of"
/// a logout you didn't mean is therefore the whole gesture, and it must not also open the menu.
/// `StaticPopup_EscapePressed` is the first rung (`UIParent.lua:1482`) and CAMP is `hideOnEscape`,
/// so ESC dismisses the dialog, whose OnHide cancels the logout.
#[test]
fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -566,25 +492,18 @@ fn escape_during_a_countdown_cancels_it_and_does_not_open_the_menu() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The world map is subject to the menu like everything else (director-reported: it was "the only
/// one not blocked"). It was a plain toplevel showing itself with a bare `WorldMapFrame:Show()`,
/// so the panel manager never saw it and `CanOpenPanels()` never got a say; it is now the ref's own
/// `area = "full"` row and its toggle goes through ShowUIPanel.
///
/// Both directions matter, and the second is the one a bare `IsVisible()` check would miss: the map
/// must also VACATE the full-screen slot when it closes, or the stale slot refuses every panel
/// afterwards.
/// The map is a stock `area = "full"` panel (`UIParent.lua:30`), so the menu blocks it, and on
/// close it must vacate the full-screen slot, or the stale slot refuses every later panel.
#[test]
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(&[
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's continent/zone pickers initialize into it at OnLoad
"Interface\\FrameXML\\UIDropDownMenu.xml", // the map's zone pickers initialize at OnLoad
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.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.
// The map calls `UpdateMicroButtons` unguarded (`WorldMapFrame.xml:599`, `:606`).
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
@@ -596,14 +515,12 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
r"Interface\FrameXML\WorldMapFrame.xml",
]);
// Opens normally, and takes the full-screen slot.
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(s
.eval::<bool>("return GetFullScreenFrame():GetName() == \"WorldMapFrame\"")
.unwrap());
// Closing gives the slot back — a stale full-screen frame would block every later panel.
s.run("ToggleWorldMap()").unwrap();
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(
@@ -612,8 +529,7 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
"the map vacated the full-screen slot"
);
// With the menu up, it must not open — from the M binding or from the micro button, both of
// which are this same ToggleWorldMap.
// The M binding and the micro button both call `ToggleWorldMap`.
s.run("ToggleGameMenu(1)").unwrap();
assert!(s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap());
s.run("ToggleWorldMap()").unwrap();
@@ -625,16 +541,14 @@ fn the_world_map_cannot_open_behind_the_menu_and_gives_its_slot_back() {
.eval::<bool>("return GetFullScreenFrame() == nil")
.unwrap());
// And it opens again the moment the menu is gone.
s.run("ToggleGameMenu(1)").unwrap();
s.run("ToggleWorldMap()").unwrap();
assert!(s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The other direction of the same rule (ref `ShowUIPanel` l.668-675): with the map holding the
/// full screen, nothing opens behind IT either — and ESC closes the map first, so the press that
/// would have opened the menu goes to the map instead (one eater per press).
/// With the map holding the full screen nothing else opens (`UIParent.lua:668-675`), and ESC
/// closes the map without also opening the menu.
#[test]
fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -644,8 +558,7 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The action-bar cluster the micro menu sits in — the stock map's OnShow/OnHide call
// `UpdateMicroButtons` unguarded (1980).
// The action bar the micro buttons sit on: the map calls `UpdateMicroButtons` unguarded.
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
@@ -657,8 +570,7 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
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.
// The stock loot window, as `test_ui::LOOT_UI`.
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
@@ -678,7 +590,6 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
"a left-area panel must not open behind the full-screen map"
);
// ESC: the map's own rung eats the press (it stands in for the reference frame's OnKeyDown).
s.run("ToggleGameMenu()").unwrap();
assert!(!s.eval::<bool>("return WorldMapFrame:IsVisible()").unwrap());
assert!(
@@ -691,21 +602,13 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bag row under an open menu, pinned at the QUAD level — because "greyed out" and "gone" are
/// the same thing to an `IsEnabled()` assertion, and the difference is what the director saw: the
/// first cut of `Disable_BagButtons` disabled buttons whose art was their NormalTexture, and a
/// disabled button with no DisabledTexture draws no state texture at all
/// (`ButtonState::region_visible`, the byte rule at `SetState 0x779790`) — so the backpack icon
/// vanished off the bar instead of dimming.
///
/// What must hold with the menu up: every one of the five buttons still DRAWS its art, and every
/// one is tinted grey. And on the way back out, tinted white again.
/// Checked at the quad level, as `IsEnabled()` cannot tell greyed from gone: a disabled button
/// draws no state texture without a DisabledTexture (`SetState`, `0x779790`), and each bag icon is
/// a layer texture that `Disable_BagButtons` desaturates (`MainMenuBarBagButtons.lua:102-113`).
#[test]
fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
let _data = benilla_formats::wow_data_or_skip!();
// ActionBar.xml goes AHEAD of the bag stack: it declares MainMenuBarArtFrame, which the bag
// bar anchors into and seats itself above (`BenillaActionBarArt_SeatAbove`, nil-guarded in
// BagFrame.xml precisely because most harnesses load no bar).
// The action bar, whose `MainMenuBarArtFrame` parents the bag bar, ahead of the bag stack.
let mut s = bag_harness_with(
&[
"Interface\\FrameXML\\Cooldown.xml",
@@ -714,7 +617,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"ScrollTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\GameTooltip.xml",
@@ -730,7 +633,6 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
s.set_container(0, Some(backpack()));
s.resolve();
// The backpack icon + the four slot rings, by name.
let art = |s: &UiScript, owner: &str| -> Vec<(String, bool)> {
s.extract()
.into_iter()
@@ -774,8 +676,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
"{owner} must still DRAW under the open menu — greyed is not gone"
);
}
// The backpack icon specifically: still its own art, and carrying SetDesaturation's greyscale
// flag to the renderer (decision 1327 — before it, the grey was the ref's no-shader 0.5 tint).
// The backpack icon keeps its art and carries `SetDesaturation`'s greyscale flag.
let toggle = art(&s, "MainMenuBarBackpackButton");
assert!(
toggle
@@ -807,7 +708,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A one-item backpack (the escape/bag tests' fixture, duplicated so this file is self-contained).
/// A one-item backpack.
fn backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
@@ -839,12 +740,8 @@ fn backpack() -> ContainerState {
}
}
/// The menu wears the shared era-window scale on show (`ERA_WINDOW_SCALE`, OptionsFrame.xml —
/// the stand-in for the era client's UIParent px-per-unit): in the era the menu and the options
/// window draw at the SAME density (the menu has no scale of its own; options' checkFit caps at
/// 1), and ours drew the menu 28% larger relative to the options window until it rode the same
/// knob ("esc menu scale too big" — director, 2026-08-04). Loaded WITH OptionsFrame.xml, the
/// real load order; the bare-menu harness elsewhere exercises the `or 1` guard implicitly.
/// The menu wears the options window's `ERA_WINDOW_SCALE` on show, as the era client draws the two
/// at one density; loaded with OptionsFrame.xml, which defines it.
#[test]
fn the_menu_rides_the_shared_era_window_scale() {
let _data = benilla_formats::wow_data_or_skip!();
+15 -56
View File
@@ -1,67 +1,31 @@
//! The **feed gate** (decision 1439): the input-side early-out for a per-frame UI feed.
//! The feed gate: a per-frame UI feed skips its rebuild when no input changed, the
//! change-detection form of the reference's event-driven pushes.
//!
//! Every VM feed is a rebuild-to-diff: build the fresh snapshot, compare against what was last
//! pushed, push only the difference. The diff half was always cheap; the REBUILD half runs every
//! frame whether or not any input moved, and on a parked frame none do — that waste is what 1435
//! priced and 1436 deferred to its own slice. The gate is the honest fix, not a cache bolted on:
//! the reference client pushes on *events* (its handlers run when a field-watch or a packet says
//! so), and an input-change gate is that same shape written in change detection.
//!
//! **The gate lives in the feed's body, never in a `run_if`.** A run condition is a second
//! parameter list that must mirror the system's own, and nothing ties them together — every
//! param added to the body without its condition twin is a silent stale-window bug. In the body,
//! the gate reads the very params it guards, beside their declarations, and the audit mode below
//! can check it.
//!
//! Three rules for writing one:
//!
//! 1. **Every input the body reads is in the gate.** `Res<T>::is_changed()` for plain resources;
//! a [`Watch`] over the counter for the generation-counted stores (`Cooldowns`, `Items`,
//! `NameCache`, `GuildState` — counted precisely because their lazy `&mut` resolves mark the
//! resource changed every frame, poisoning `is_changed` for everyone); `Changed<T>`/
//! `RemovedComponents<T>` queries for component inputs; an emptiness check for drained
//! side-channels.
//! 2. **The VM reset is an input.** [`super::VmMemo::get_reset`]'s flag ORs in first: a fresh VM
//! re-pushes with every other input unchanged (session.rs's whole failure class).
//! 3. **Evaluate every [`Watch`] unconditionally** (bind `let`s, then OR the bools): a `moved`
//! short-circuited past doesn't observe its counter and would re-open the gate one frame
//! late — harmless, but a cascade of them re-runs the feed for nothing, N frames deep.
//!
//! **The audit (`WOW_FEED_GATE_CHECK=1`)** is the validator that makes the pattern safe to
//! extend: a closed gate still runs its body, and any push the diff then finds is a missed
//! input — [`audit_push`] panics on the spot, naming the feed. A gate bug's natural form is a
//! window that goes stale until something else pokes it, which no capture and no test reliably
//! sees; the audit converts it into a crash in any probe leg that drives the state.
//! The gate sits in the feed's body, never in a `run_if`, and holds every input the body reads:
//! `is_changed()` for a resource, a [`Watch`] on the counter of a generation-counted store (its
//! lazy `&mut` resolves mark it changed every frame), `Changed`/`RemovedComponents` queries, a
//! drained channel's emptiness, and first the VM reset ([`super::VmMemo::get_reset`]). Bind every
//! [`Watch`] before OR-ing them, or a short-circuited one observes a frame late.
/// A remembered counter observation: [`Watch::moved`] answers whether the counter changed since
/// the last call, and observes the new value. Default is "never observed", so the first call
/// always reports movement — which is the safe direction, and exactly right inside a
/// [`super::VmMemo`]-wrapped memory: the VM reset defaults the memo, every watch in it re-opens,
/// and the fresh VM gets its full push with no extra wiring.
///
/// Booleans watch too: `moved(u64::from(present))` turns a presence flip into an edge.
/// A remembered counter value; a bool watches as `u64::from(b)`. The default has observed nothing,
/// so the first [`Watch::moved`] reports movement and a VM reset re-opens every watch in its memo.
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Watch(Option<u64>);
impl Watch {
/// True when `now` differs from the last observed value (or nothing was ever observed);
/// either way `now` is the observation the next call compares against.
/// Whether `now` differs from the last observation; `now` becomes the next one.
pub(crate) fn moved(&mut self, now: u64) -> bool {
self.0.replace(now) != Some(now)
}
}
/// `WOW_FEED_GATE_CHECK=1` — audit mode: gated feeds run their bodies even on a closed gate and
/// [`audit_push`] panics if the diff then pushes anything. Read once; a probe leg opts in.
/// `WOW_FEED_GATE_CHECK=1`: gated bodies run on a closed gate so [`audit_push`] can check them.
pub(crate) fn auditing() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var_os("WOW_FEED_GATE_CHECK").is_some_and(|v| v != "0"))
}
/// `WOW_FEED_GATE_TRACE=1` — once per second per feed, print WHICH gate inputs read open this
/// frame (silent on fully-closed frames). The first question every gate slice asks when a
/// battery reads flat is "which input is holding the gate open?", and it cannot be answered
/// from outside the body — 1439's own wiring anomaly was run to ground with exactly this.
/// `WOW_FEED_GATE_TRACE=1`: at most once a second per feed, prints which inputs hold it open.
pub(crate) fn trace(feed: &'static str, inputs: &[(&str, bool)]) {
use std::sync::Mutex;
use std::time::Instant;
@@ -90,9 +54,7 @@ pub(crate) fn trace(feed: &'static str, inputs: &[(&str, bool)]) {
eprintln!("[gate-trace] {feed}: open by {}", open.join("+"));
}
/// The audit assertion: called at every push/fire site of a gated feed, with the feed's
/// gate-closed flag. A push reached while the gate is closed means an input is missing from the
/// gate — the silent-stale-window bug, converted to a crash that names itself.
/// Called at every push site of a gated feed: a push under a closed gate means a missing input.
#[track_caller]
pub(crate) fn audit_push(gate_closed: bool, feed: &str, what: &str) {
assert!(
@@ -102,9 +64,7 @@ pub(crate) fn audit_push(gate_closed: bool, feed: &str, what: &str) {
);
}
/// One system's gate verdict, binding the two flags every gated body threads: `open` (run the
/// rebuild) and `closed_audit` (the gate said skip, but audit mode runs the body to check it).
/// [`Gate::skip`] is the early-return test; [`Gate::audit`] forwards to [`audit_push`].
/// A gate verdict: the body runs when `open`, or closed under audit mode (`closed_audit`).
pub(crate) struct Gate {
open: bool,
closed_audit: bool,
@@ -147,7 +107,6 @@ mod tests {
#[test]
fn a_defaulted_watch_reopens_like_a_fresh_vm() {
// The property the VmMemo embedding rests on: reset-to-default re-arms the watch.
let mut w = Watch::default();
assert!(w.moved(3));
assert!(!w.moved(3));
@@ -157,7 +116,7 @@ mod tests {
#[test]
fn a_gate_skips_exactly_when_closed_and_not_auditing() {
// `auditing()` is off in the test env (no WOW_FEED_GATE_CHECK); Gate::new(false) skips.
// `auditing()` is off unless the test env sets `WOW_FEED_GATE_CHECK`.
let open = Gate::new(true);
assert!(!open.skip());
let closed = Gate::new(false);
@@ -1,7 +1,5 @@
//! 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`).
//! The stock group-loot roll popups (`GroupLootFrame1..4` in `LootFrame.xml`), driven by a pushed
//! [`LootRollsState`] and `START_LOOT_ROLL`/`CANCEL_LOOT_ROLL`.
use benilla_ui::script::{
DressUpIntent, ExtractedQuad, LootRollEntry, LootRollsState, QuadContent, ScriptValue, UiScript,
@@ -9,13 +7,7 @@ use benilla_ui::script::{
use super::test_ui::{load_ui as load_xml, load_ui_no_warnings as load_xml_no_warnings};
/// Like [`load_xml`], but also demands zero loader WARNINGS — the bar this file's own assignment
/// set for `GroupLootFrame.xml` itself (a stale "unknown template" warning, e.g. the kind
/// LootFrame.xml's now-obsolete `GameFontNormalSmall` caveat would have produced, is exactly what
/// this catches). Not used for the prerequisite files below: Fonts/UiPanels/GameTooltip aren't this
/// assignment's to police and may carry warnings of their own.
/// The centre of the first texture quad whose path contains `needle` (a button's art), for clicking
/// it — the `loot_tests.rs` `icon_center` idiom.
/// The centre of the first texture quad whose path contains `needle`, for clicking it.
fn quad_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
let r = quads
.iter()
@@ -27,7 +19,7 @@ fn quad_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)
}
/// The colour of the first text quad whose text equals `t` (the `loot_tests.rs` `text_color` idiom).
/// The colour of the first text quad reading `t`.
fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
quads.iter().find_map(|q| match &q.content {
QuadContent::Text {
@@ -39,26 +31,12 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
})?
}
/// The four roll popups, off the player's chain.
///
/// They are declared in stock `LootFrame.xml` — `GroupLootFrameTemplate` plus `GroupLootFrame1..4`
/// — and that file has been on the manifest since the loot window migrated. Our own
/// `GroupLootFrame.xml` re-declared all five names on top of it and won by load order, so what
/// these tests exercised was our copy shadowing the chain's (decision 1838).
///
/// `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.
/// The four popups, declared in stock `LootFrame.xml`. The caller loads `UIParent.xml`, whose
/// `UIParent_OnEvent` routes `START_LOOT_ROLL` (`UIParent.lua:513-516`).
fn load_group_loot(s: &UiScript) {
// `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.
// `GroupLootDropDown`'s OnLoad runs its initializer at once, which reads `MAX_PARTY_MEMBERS`
// (`LootFrame.lua:217`), a constant of `PartyMemberFrame.lua`.
load_xml(s, r"Interface\FrameXML\UIDropDownMenu.xml");
// `MAX_PARTY_MEMBERS` — `GroupLootDropDown_OnLoad` counts party rows, and the constant lives
// in stock `PartyMemberFrame.lua`. A real cross-file dependency the reference has too (its own
// toc loads PartyFrame well before LootFrame); the `.lua` alone is the minimal way to satisfy
// it here, because its whole top level is four constants and some function definitions — no
// frames, no side effects — where `PartyFrame.xml` would drag in the entire unit-frame cluster
// for one number.
load_xml(s, r"Interface\FrameXML\PartyMemberFrame.lua");
load_xml(s, r"Interface\FrameXML\LootFrame.xml");
}
@@ -66,14 +44,12 @@ fn load_group_loot(s: &UiScript) {
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The loot window's own labels (`ITEMS`, `PREV`, `NEXT`) are GlobalStrings keys, and the
// 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).
// The loot window's labels (`ITEMS`, `PREV`, `NEXT`) are GlobalStrings keys; a missing one is
// a loader warning, which `load_ui_no_warnings` fails.
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
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, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, r"Interface\FrameXML\ItemButtonTemplate.xml");
// `UIPanelCloseButton`, which the loot window's four close buttons inherit.
// `UIPanelCloseButton`, which each popup's pass button inherits (`LootFrame.xml:364`).
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
@@ -82,7 +58,7 @@ fn setup() -> UiScript {
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\\GameTooltip.xml"); // the vote and item hovers
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
@@ -90,18 +66,15 @@ fn setup() -> UiScript {
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.
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
s
}
/// The two resolved rolls' item links, exactly as `ui_loot_roll.rs` builds them (`item_link`:
/// quality colour + the four-field `|Hitem:` payload) — what the icon button's ctrl/shift arms read.
/// The resolved rolls' links as `ui_loot_roll` builds them: quality colour, four-field `|Hitem:`.
const STAFF_LINK: &str = "|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r";
const SWORD_LINK: &str = "|cffffffff|Hitem:25:0:0:0|h[Worn Shortsword]|h|r";
/// A resolved Epic (BoP) roll and an in-flight one (item template not landed yet) — the same two
/// shapes `loot_roll.rs`'s own test module fixtures, reused here against the real shipped XML.
/// Roll 7 resolved Epic and bind-on-pickup, roll 8 resolved Common, roll 9 with no template yet.
fn rolls() -> LootRollsState {
LootRollsState {
rolls: vec![
@@ -114,7 +87,6 @@ fn rolls() -> LootRollsState {
bind_on_pickup: true,
time_left_ms: 42_000,
item_id: 17182,
// The link lands with the name — one template answer fills both (decision 1059).
link: Some(STAFF_LINK.into()),
random_property_id: 0,
},
@@ -130,8 +102,7 @@ fn rolls() -> LootRollsState {
link: Some(SWORD_LINK.into()),
random_property_id: 0,
},
// The item-template query hasn't landed: name/texture/quality all nil (loot_roll.rs) —
// and no link either: it embeds the name, so it cannot exist before the name does.
// No template yet: name, texture, quality and link (which embeds the name) are nil.
LootRollEntry {
roll_id: 9,
name: None,
@@ -148,8 +119,7 @@ fn rolls() -> LootRollsState {
}
}
/// The XML loads with zero loader warnings/errors, and all four `GroupLootFrame1..4` exist
/// and start hidden.
/// The popups load with no loader warning of any kind and start hidden.
#[test]
fn shipped_group_loot_frame_loads_clean_and_starts_hidden() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -157,11 +127,7 @@ fn shipped_group_loot_frame_loads_clean_and_starts_hidden() {
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
// to assert any more: this used to read `4 * 6 + 1` — six regions per instance plus our own
// `BenillaGroupLootFrameDriver` — and both halves of that number were ours (decision 1838).
// What survives is the assignment that mattered: the file loads with **no warning of any
// kind**, which is what `load_ui_no_warnings` is for.
// The popups come inside the whole loot window, so the frame count is only a floor.
assert!(
load_xml_no_warnings(&s, r"Interface\FrameXML\LootFrame.xml") > 4,
"the loot window brought its frames"
@@ -177,10 +143,8 @@ fn shipped_group_loot_frame_loads_clean_and_starts_hidden() {
}
}
/// `START_LOOT_ROLL` claims the first free frame and shows it; a second `START_LOOT_ROLL` claims
/// the second frame, the first staying up. Also exercises the resolved-roll paint (name text +
/// quality colour + the BoP gold decoration swap + the Timer's primed max) and the Need/Greed/Pass
/// click wiring end-to-end through `RollOnLoot`.
/// `START_LOOT_ROLL` claims the first free popup (`LootFrame.lua:246-258`) and paints the roll:
/// name, quality colour, bind-on-pickup decoration and timer range; Need clicks through.
#[test]
fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -188,7 +152,6 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
load_group_loot(&s);
s.set_loot_rolls(rolls());
// Roll 7 (BoP, Epic) claims frame 1.
s.fire_event(
"START_LOOT_ROLL",
vec![ScriptValue::Int(7), ScriptValue::Int(42_000)],
@@ -198,18 +161,17 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
.eval::<bool>("return GroupLootFrame1:IsVisible()")
.unwrap());
assert_eq!(s.eval::<i64>("return GroupLootFrame1.rollID").unwrap(), 7);
// The Timer's max was primed to the roll's countdown before Show() (GroupLootFrame_OpenNewFrame).
// The timer's range is set before `Show()` (`LootFrame.lua:253`).
assert_eq!(
s.eval::<(f64, f64)>("return GroupLootFrame1Timer:GetMinMaxValues()")
.unwrap(),
(0.0, 42_000.0)
);
// BoP swaps in the gold decoration; frame 2 doesn't exist yet to compare against.
// Bind-on-pickup shows the gold decoration (`LootFrame.lua:263-266`).
assert!(s
.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
.unwrap());
// Roll 8 (BoE, Common) claims frame 2 — frame 1 stays up, untouched.
s.fire_event(
"START_LOOT_ROLL",
vec![ScriptValue::Int(8), ScriptValue::Int(60_000)],
@@ -222,12 +184,10 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
.eval::<bool>("return GroupLootFrame2:IsVisible()")
.unwrap());
assert_eq!(s.eval::<i64>("return GroupLootFrame2.rollID").unwrap(), 8);
// A BoE roll keeps the plain decoration hidden.
assert!(!s
.eval::<bool>("return GroupLootFrame2Decoration:IsShown()")
.unwrap());
// The names painted, quality-coloured (frame 1 purple/Epic, frame 2 white/Common).
s.resolve();
let quads = s.extract();
assert_eq!(
@@ -246,17 +206,8 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
"Common item text is white, got {white:?}"
);
// Clicking Need on frame 1's roll button reaches RollOnLoot(7, 1) — the same click-through
// proof loot_tests.rs runs for LootSlot. Roll 7 is BIND-ON-PICKUP, so the seam's gate holds
// the vote back and asks instead (decision 0594): nothing on the wire until the popup is
// accepted.
//
// Taken BY NAME, not by painter order. This used to be
// `quad_center(&quads, "UI-GroupLoot-Dice-Up")` — the first dice quad — which silently assumed
// declaration order is draw order among the four roll frames. `toplevel="true"` (decision
// 1739) ends that: the frame a roll claims raises above its siblings on Show, so the first
// dice belonged to frame 2 and the click landed on the BoE roll, which has no confirm gate and
// went straight to the wire. The proxy was the fixture, never the assertion.
// Found by name: the popups are `toplevel` (`LootFrame.xml:243`), so draw order is not
// declaration order. Roll 7 is bind-on-pickup, so the vote waits for the confirm.
let (nx, ny) = s
.eval::<(f64, f64)>(
"return (GroupLootFrame1RollButton:GetLeft() + GroupLootFrame1RollButton:GetRight()) \
@@ -278,12 +229,8 @@ fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
assert_eq!(s.take_loot_roll_confirms(), vec![(7, 1)], "Need on roll 7");
}
/// The bind-on-pickup confirm, end to end through the real shipped XML (decision 0594): the
/// driver turns `CONFIRM_LOOT_ROLL` into the popup carrying `(rollID, rollType)`, and the popup's
/// OK re-enters `ConfirmLootRoll` to land the vote the gate withheld.
///
/// 0591 shipped this wrong — a Need on a BoP roll went straight to the wire with no prompt, which
/// binds an epic to you on a single click. The five-line path below is the whole correction.
/// `CONFIRM_LOOT_ROLL` opens the dialog carrying `(rollID, rollType)` (`UIParent.lua:517-523`);
/// its OK calls `ConfirmLootRoll` (`StaticPopup.lua:1375-1377`), which sends the withheld vote.
#[test]
fn the_bop_confirm_popup_lands_the_withheld_vote() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -291,15 +238,14 @@ fn the_bop_confirm_popup_lands_the_withheld_vote() {
load_group_loot(&s);
s.set_loot_rolls(rolls());
// The app fires this after draining the seam's confirm queue (`ui_loot_roll::drain_loot_rolls`).
// The app fires this after draining the confirm queue (`ui_loot_roll::drain_loot_rolls`).
s.fire_event(
"CONFIRM_LOOT_ROLL",
vec![ScriptValue::Int(7), ScriptValue::Int(1)],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The popup is up and carries BOTH halves of the identity — data2 is the rollType, which the
// reference threads through precisely so OnAccept can replay the original click.
// `data2` is the rollType, so `OnAccept` can replay the click.
assert!(
s.eval::<bool>("return StaticPopup_FindVisible(\"CONFIRM_LOOT_ROLL\") ~= nil")
.unwrap(),
@@ -312,8 +258,7 @@ fn the_bop_confirm_popup_lands_the_withheld_vote() {
.unwrap();
assert_eq!((data, data2), (7, 1), "rollID and rollType ride the dialog");
// The real 1.12 LOOT_NO_DROP/OKAY/CANCEL text, the same bar delete_item_tests holds its own
// popup to — these are quoted locals precisely so they render under test too.
// The 1.12 `LOOT_NO_DROP` and `OKAY` strings.
let dialog = s
.eval::<String>("return StaticPopup_FindVisible(\"CONFIRM_LOOT_ROLL\"):GetName()")
.unwrap();
@@ -328,10 +273,8 @@ fn the_bop_confirm_popup_lands_the_withheld_vote() {
"Okay"
);
// Still nothing on the wire while the question is open.
assert!(s.take_loot_roll_votes().is_empty());
// Accepting re-enters ConfirmLootRoll, which bypasses the bind gate and queues the real vote.
s.eval::<bool>(
"local d = StaticPopup_FindVisible(\"CONFIRM_LOOT_ROLL\")\n\
StaticPopupDialogs[\"CONFIRM_LOOT_ROLL\"].OnAccept(d.data, d.data2)\nreturn true",
@@ -345,7 +288,7 @@ fn the_bop_confirm_popup_lands_the_withheld_vote() {
);
}
/// `CANCEL_LOOT_ROLL` for a rollID hides that frame, and ONLY that one.
/// `CANCEL_LOOT_ROLL` hides the popup holding that rollID and no other (`LootFrame.lua:279-285`).
#[test]
fn cancel_loot_roll_hides_only_that_frame() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -363,7 +306,6 @@ fn cancel_loot_roll_hides_only_that_frame() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Cancel roll 7 (frame 1): only frame 1 hides, frame 2 (roll 8) is untouched.
s.fire_event("CANCEL_LOOT_ROLL", vec![ScriptValue::Int(7)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!s
@@ -373,7 +315,7 @@ fn cancel_loot_roll_hides_only_that_frame() {
.eval::<bool>("return GroupLootFrame2:IsVisible()")
.unwrap());
// A cancel for a rollID nobody holds any more (or never held) is a harmless no-op.
// A rollID no popup holds is a no-op.
s.fire_event("CANCEL_LOOT_ROLL", vec![ScriptValue::Int(7)]);
s.fire_event("CANCEL_LOOT_ROLL", vec![ScriptValue::Int(999)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -381,7 +323,6 @@ fn cancel_loot_roll_hides_only_that_frame() {
.eval::<bool>("return GroupLootFrame2:IsVisible()")
.unwrap());
// Cancel roll 8 (frame 2): the last one standing hides too.
s.fire_event("CANCEL_LOOT_ROLL", vec![ScriptValue::Int(8)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!s
@@ -389,9 +330,7 @@ fn cancel_loot_roll_hides_only_that_frame() {
.unwrap());
}
/// A roll whose `GetLootRollItemInfo` answers all-nil (the item template still in flight) opens its
/// frame without erroring, and paints the fallback icon / blank name / common-quality colour rather
/// than crashing on the nils.
/// A roll with its item template in flight opens without error, with no name and no icon.
#[test]
fn in_flight_roll_does_not_error_and_falls_back() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -412,26 +351,19 @@ fn in_flight_roll_does_not_error_and_falls_back() {
.eval::<bool>("return GroupLootFrame1:IsVisible()")
.unwrap());
assert_eq!(s.eval::<i64>("return GroupLootFrame1.rollID").unwrap(), 9);
// Blank name (not an error, not the literal "nil").
// The chain's FontString has NO text until something paints it, where our retired copy
// defaulted to `""` — so this reads Option and expects None (decision 1838).
// `SetText(nil)` (`LootFrame.lua:274`) leaves no text, not "nil".
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
None,
""
);
// No BoP decoration for an unresolved (default-false bindOnPickUp) roll.
assert!(!s
.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
.unwrap());
// **No fallback icon — and that is the reference's behaviour, not a regression to fix here.**
// Our retired file painted `BENILLA_FALLBACK_ICON` (a question mark) whenever the texture was
// nil. The chain's `GroupLootFrame_OnShow` just calls `SetTexture(texture)` with whatever
// `GetLootRollItemInfo` gave it, so an unresolved item leaves the icon empty. The migration
// reverts our embellishment; whether that reads acceptably is a look call, recorded in 1838
// rather than re-authored back in.
// No placeholder icon: `GroupLootFrame_OnShow` passes the nil texture to `SetTexture`
// (`LootFrame.lua:273`), which leaves the icon empty.
s.resolve();
let has_fallback_icon = s.extract().iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
@@ -441,26 +373,12 @@ fn in_flight_roll_does_not_error_and_falls_back() {
!has_fallback_icon,
"the reference paints no placeholder for an unresolved item"
);
// What DOES still hold: no error, and the dialog is up and usable.
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The chain frame paints once and never again — which is exactly why the app holds the event.**
///
/// 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 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.
/// The stock popup paints only in `OnShow`, so a `START_LOOT_ROLL` that beats its roll to the model
/// stays blank. On a cache miss the reference fires it from the item-template arrival callback
/// (`0x61b460`, armed by `0x61b310`), and `feed_loot_rolls` holds it the same way.
#[test]
fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -476,8 +394,6 @@ fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
assert!(s
.eval::<bool>("return GroupLootFrame1:IsVisible()")
.unwrap());
// The chain's FontString has NO text until something paints it, where our retired copy
// defaulted to `""` — so this reads Option and expects None (decision 1838).
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
@@ -485,20 +401,10 @@ fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
"nothing to paint yet — this is the state B371 reported"
);
// ...and then the snapshot carrying it lands.
s.set_loot_rolls(rolls());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// **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.
//
// 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).
// No repaint: nothing re-enters `OnShow`, as in 1.12.
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.unwrap(),
@@ -519,8 +425,6 @@ fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
}),
"and no icon arrives either — same missing repaint, same one cause"
);
// 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.
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
@@ -529,16 +433,8 @@ fn nothing_repaints_a_frame_that_opened_before_its_snapshot() {
);
}
/// The bare-name payoff (the header's "THE FOUR INSTANCES KEEP THE REFERENCE'S BARE NAMES"): the
/// ref's `UIPARENT_MANAGED_FRAME_POSITIONS["GroupLootFrame1"]` row actually engages, so the roll
/// dialogs ride the bottom-bar stack instead of sitting on top of the action bars.
///
/// This is the test that FALSIFIES the naming choice: under a `Benilla`-prefixed name
/// `UIParent_ManageFramePositions`'s literal `getglobal(name)` misses, the row no-ops through the
/// ref's own `if ( frame )` guard, and every assertion below collapses to the static XML 60.
/// Mirrors `cast_tests::managed_positions_track_the_bottom_bar_stack` (the same mechanism, the
/// same bar-visibility stubs); the arithmetic is the ref table's own row — baseY 60, bottomEither
/// 42, pet 42.
/// `UIPARENT_MANAGED_FRAME_POSITIONS["GroupLootFrame1"]` (baseY 60, bottomEither 42, pet 42;
/// `UIParent.lua:1577`) stacks the popups over the bottom bars; the pass finds the frame by name.
#[test]
fn managed_positions_engage_for_the_bare_frame_name() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -548,47 +444,32 @@ fn managed_positions_engage_for_the_bare_frame_name() {
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, 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.
// `GameTooltip.xml` brings `TOOLTIP_DEFAULT_COLOR`, which the dropdown lists read in their
// OnLoad (`UIDropDownMenuTemplates.xml:186`).
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();
// The loader's post-load bootstrap with no bars in existence: the row's bare base, which
// already differs from nothing here (the XML anchor is also 60) — the real proof is below.
// No bars: the row's base, 60, which the XML anchor also gives.
s.run("UIParent_ManageFramePositions()").unwrap();
s.resolve();
assert_eq!(bottom(&s), 60.0, "baseY");
// The always-on bottom multibars (0270) appear.
// 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.
// **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).
// The pass reads `SHOW_MULTI_ACTIONBAR_1`/`_2`, not the bars (`UIParent.lua:1599-1606`).
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.
// 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.
// The stance bar shows too. The pass touches its three shelf textures by name, unguarded
// (`UIParent.lua:1705-1732`), so the stand-in bar needs stand-in textures.
s.run(
"local t = { Show = function() end, Hide = function() end } \
ShapeshiftBarLeft, ShapeshiftBarMiddle, ShapeshiftBarRight = t, t, t",
@@ -602,27 +483,20 @@ fn managed_positions_engage_for_the_bare_frame_name() {
s.resolve();
assert_eq!(bottom(&s), 144.0, "60 + 42 + pet 42");
// And it settles back when the stance bar hides.
s.run("ShapeshiftBarFrame:Hide() UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 102.0, "back to the multibar-only stack");
}
/// The roll popup's item icon (ref `$parentIconFrame` OnClick, LootFrame.xml l.353-361): CTRL
/// previews the rolled item in the dressing room (decision 1060), SHIFT posts its link into an open
/// chat edit box (decision 1059). Both read `GetLootRollItemLink(rollID)`, the binding this arc
/// added — so this pins that getter against the real shipped XML too.
///
/// The control that must not change: the Need/Greed/Pass buttons still vote. The icon button is new
/// click surface on a frame whose whole job is a three-button vote, and a stray vote from the icon
/// (or a swallowed one from the dice) is exactly the regression worth catching.
/// The roll icon's OnClick (`LootFrame.xml:353-361`): ctrl dresses up the item and shift posts
/// its link to an open chat box, both through `GetLootRollItemLink`; the icon never votes.
#[test]
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, r"Interface\FrameXML\UIParent.xml"); // UIParent + UIParent.lua, the reference's own (1988)
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\DressUpFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
@@ -635,8 +509,7 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
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
// real vote rather than the bind-on-pickup confirm.
// Roll 8 is not bind-on-pickup, so the dice below vote without a confirm.
s.fire_event(
"START_LOOT_ROLL",
vec![ScriptValue::Int(8), ScriptValue::Int(60_000)],
@@ -646,8 +519,7 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
let quads = s.extract();
let (x, y) = quad_center(&quads, "INV_Sword_04");
// A plain click on the icon does nothing at all — the reference's handler has no third branch,
// and the vote is the dice's job, not the icon's.
// A plain click does nothing: the handler has only the ctrl and shift arms.
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
@@ -655,7 +527,6 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
"an unmodified icon click votes nothing"
);
// SHIFT with the chat edit box open → the link.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
@@ -668,7 +539,6 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
"the rolled item's full escaped link landed in the chat box"
);
// CTRL → the dressing room wearing it.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
@@ -683,7 +553,6 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
"no modified icon click may vote"
);
// The control: Need on the dice still reaches the wire (roll 8 is not bind-on-pickup).
let (nx, ny) = quad_center(&quads, "UI-GroupLoot-Dice-Up");
s.mouse_button(nx, ny, "LeftButton", true);
s.mouse_button(nx, ny, "LeftButton", false);
@@ -695,28 +564,15 @@ fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The roll-frame template is published under **the reference's own name**, and an addon that
/// inherits it finds the parts it reaches for (decision 1254).
///
/// It shipped as `BenillaGroupLootFrameTemplate`. The `Benilla` prefix exists so a name WE invented
/// cannot collide with one an addon expects — and this template is the opposite of an invention: a
/// line-by-line transcription of 1.12's own `GroupLootFrameTemplate`, which addons inherit *by that
/// name*. `Bongos_RollBar/bar.lua:90` builds its four roll frames with
/// `CreateFrame("Frame", "BRollBarFrame"..i, bar, "GroupLootFrameTemplate")`; that resolves on the
/// real client and missed here for no reason but our prefix.
///
/// The child names are the actual contract, so they are what this asserts. `bar.lua` reaches for
/// `<name>IconFrameIcon`, `<name>Name`, `<name>Corner`, `<name>Decoration` and `<name>Timer` — and
/// `IconFrameIcon` is the one a flat template would get wrong, because it requires the icon texture
/// to be nested inside the `IconFrame` **button**, not hung off the template root.
/// Addons inherit `GroupLootFrameTemplate` by its 1.12 name and reach for its children; the icon
/// is `IconFrameIcon` because it is nested in the `IconFrame` button (`LootFrame.xml:314`).
#[test]
fn the_roll_template_carries_the_reference_name_and_the_parts_addons_reach_for() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
load_group_loot(&s);
// The reference's name resolves — and, since 1253, a miss would RAISE rather than hand back a
// bare frame, so this call failing is this test failing.
// `CreateFrame` raises on an unknown template, so this call is the check.
s.run(r#"Probe = CreateFrame("Frame", "ProbeRoll", nil, "GroupLootFrameTemplate")"#)
.expect("the reference-named template must resolve");
@@ -735,27 +591,17 @@ fn the_roll_template_carries_the_reference_name_and_the_parts_addons_reach_for()
);
}
// The timer is a StatusBar, not a plain frame: `bar.lua:33` calls SetMinMaxValues on it.
// The timer is a StatusBar (`LootFrame.lua:253` sets its range).
s.run(r#"ProbeRollTimer:SetMinMaxValues(0, 60000)"#)
.expect("the timer must answer StatusBar verbs");
// And the old private name is gone, so nothing can quietly depend on it again.
assert!(s
.eval::<bool>(r#"return getglobal("BenillaGroupLootFrameTemplate") == nil"#)
.unwrap());
}
/// The **stock** `GroupLootFrame_OnShow` over an in-flight roll — the same defect 1805 fixed one
/// window over, proven at the file that has not been migrated yet.
///
/// `GroupLootFrame1..4`, their template and `GroupLootFrame_OnShow` all live in the stock
/// `LootFrame.xml`/`.lua`, which has been on the player's chain since 1800; our `GroupLootFrame.xml`
/// loads after it and shadows the lot. So the stock body is dormant, not absent — and it does
/// `local color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:275`) then `color.r` (`:276`) off
/// `GetLootRollItemInfo`, with none of the `or ITEM_QUALITY_COLORS[1]` our copy carries.
///
/// This test loads the stock file WITHOUT ours, so the dormant body runs. It is the guard that stops
/// the eventual GroupLootFrame migration from shipping the loot bug a second time.
/// The stock `GroupLootFrame_OnShow` reads `ITEM_QUALITY_COLORS[quality].r` with no fallback
/// (`LootFrame.lua:275-276`), so an unresolved or unknown roll must still answer a quality.
#[test]
fn the_stock_group_loot_frame_survives_an_in_flight_roll() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -767,8 +613,8 @@ fn the_stock_group_loot_frame_survives_an_in_flight_roll() {
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
s.set_loot_rolls(rolls());
// Roll 9 is the in-flight one; roll 99 does not exist at all (a stale id from a torn-down
// frame). Both take the reference's miss tail, so both must paint rather than raise.
// Roll 9 is in flight and roll 99 unknown: both answer the reference's miss tail, quality 1
// (`0x4c31a3`).
for roll in [9, 99] {
s.run(&format!("GroupLootFrame_OpenNewFrame({roll}, 55000)"))
.unwrap_or_else(|e| panic!("stock OpenNewFrame raised on roll {roll}: {e}"));
@@ -782,8 +628,7 @@ fn the_stock_group_loot_frame_survives_an_in_flight_roll() {
.eval::<bool>("return GroupLootFrame1:IsVisible() and GroupLootFrame2:IsVisible()")
.unwrap());
// …and the colour it painted is the miss tail's Common, read back off the FontString that
// `:276` set — not a nil, and not the Epic of the resolved roll sitting beside it.
// The name is painted Common, set at `LootFrame.lua:276`.
let painted: (f64, f64, f64) = s
.eval("local r, g, b = GroupLootFrame1Name:GetTextColor()\nreturn r, g, b")
.unwrap();
+59 -186
View File
@@ -1,36 +1,15 @@
//! The four guild windows (decision 1257, `FriendsFrame.xml` l.1719-3438 of the reference): the
//! roster pane and its two views, the rank editor, the guild-information notice board, and the
//! member detail card.
//!
//! What these guard that the Rust-side unit tests structurally cannot: the windows are Lua over an
//! engine snapshot, so a getter whose ten returns are in the wrong order, a row template whose
//! FontString is misnamed, a rank comparison written `>=` where the reference wrote `>`, or a
//! button wired to the wrong verb are all invisible to `script::guild`'s own tests and green in the
//! parse sweep. Each test below fails on exactly one of those.
//!
//! **The engine API is STOOD IN FOR here, deliberately.** `benilla-ui/src/script/guild.rs` binds
//! the ~47 real globals over a pushed snapshot; these tests replace all of them with the Lua
//! fixture below *before* the XML loads, so what is under test is the window and nothing else. It
//! also means the window's tests do not need the app's feed seated, and that a change to the
//! engine's plumbing cannot quietly turn one of these green. The fixture's shapes are the ones
//! `script::guild` promises, and two of them are easy to get wrong and worth restating:
//!
//! - **era booleans are `1`/`nil`, never `true`/`false`** — `IsInGuild`, `IsGuildLeader`, every
//! `Can*`, and `online` out of `GetGuildRosterInfo`;
//! - **two index bases coexist**: the roster's `rankIndex` is **0-based** (0 = guild master), while
//! the whole `GuildControl*` family is **1-based**, driven straight off dropdown IDs.
//! The guild windows (`FriendsFrame.xml:1719-3438`): the roster pane and its two views, the rank
//! editor, the guild-information board and the member detail card. A Lua fixture stands in for
//! the `script::guild` globals, seated before the XML loads, so only the windows are under test.
//! Its shapes are the bindings': 1.12 booleans are `1`/`nil`, the roster's `rankIndex` is 0-based
//! (0 = guild master) and the `GuildControl*` family is 1-based, off dropdown IDs.
use benilla_ui::script::{GuildState, ScriptValue, UiScript, UnitState};
/// 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
/// verb appends to `.calls`, which a test drains with `BenillaGuildCalls()`. Tests mutate the
/// table's FIELDS — never replace the table, since the closures below hold it as an upvalue.
///
/// The three seeded members are chosen to cover the rank comparisons the detail card turns on:
/// the guild master (ourselves), a rank-1 officer, and an offline member sitting in the BOTTOM
/// rank — which is also the one that blocks removing that rank in the editor.
/// One mutable table, `BenillaGuildFixture`, is the whole model: getters read it and verbs append
/// to `.calls`, drained by `BenillaGuildCalls()`. Tests mutate its fields and never replace it,
/// since the functions hold it as an upvalue. The members: the guild master (us), a rank-1
/// officer, and an offline member in the bottom rank.
const GUILD_FIXTURE: &str = r#"
BenillaGuildFixture = {
inGuild = 1,
@@ -177,21 +156,13 @@ end
function GuildControlDelRank(name) BenillaGuildRecord("GuildControlDelRank:" .. name) end
"#;
/// The window's own manifest slice, in `benilla.toc` order, with the fixture seated first.
///
/// `UIPanelTemplates.xml` is in the slice where `friends_tests` does not need it: the guild frames
/// inherit the reference's shared `UIPanelButtonTemplate` / `UIPanelCloseButton` /
/// `UIPanelScrollFrameTemplate` rather than a private copy, exactly as the reference's own XML
/// does. `BasicControls.xml` is here for `message()`, which `GuildControlCheckboxUpdate` calls on
/// a missing checkbox.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Before the XML, so `FriendsFrame_OnLoad`'s `GetGuildRosterMOTD()` and
// `GuildControlPopupFrame_OnLoad`'s `GuildControlGetRankFlags()` read the fixture.
// Before the XML: `FriendsFrame_OnLoad` reads `GetGuildRosterMOTD()` and
// `GuildControlPopupFrame_OnLoad` reads `GuildControlGetRankFlags()`.
s.run(GUILD_FIXTURE).unwrap();
// The roster's self-checks (`UnitName("player") == name`) need a seated player, and the
// fixture makes us the guild master, so the names must agree.
// The player is the fixture's guild master; the roster checks `UnitName("player") == name`.
s.set_unit(
"player",
Some(UnitState {
@@ -229,8 +200,7 @@ fn calls(s: &UiScript) -> String {
s.eval::<String>("return BenillaGuildCalls()").unwrap()
}
/// A region's text colour, rounded to two places. The engine stores colours as `f32`, so 0.82 comes
/// back as 0.8199999928474426 and an exact compare would fail on every one of them.
/// A region's text colour to two places: colours are `f32`, so 0.82 reads back 0.8199999928474426.
fn colour(s: &UiScript, region: &str) -> (f64, f64, f64) {
let (r, g, b) = s
.eval::<(f64, f64, f64)>(&format!("return {region}:GetTextColor()"))
@@ -239,9 +209,6 @@ fn colour(s: &UiScript, region: &str) -> (f64, f64, f64) {
(round(r), round(g), round(b))
}
/// The tab is live for a guilded character, the pane paints its four columns from the roster, the
/// title is the GUILD'S name rather than a constant, and showing the pane ASKS for the roster —
/// which the friend list never has to, because that one arrives unasked.
#[test]
fn the_guild_tab_opens_the_roster_and_asks_for_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -271,7 +238,7 @@ fn the_guild_tab_opens_the_roster_and_asks_for_it() {
"showing the pane requests the roster"
);
// The player view is the one that opens (FriendsFrame.playerStatusFrame starts 1).
// The player view opens first: `FriendsFrame_OnLoad` sets `playerStatusFrame = 1`.
assert!(visible(&s, "GuildPlayerStatusFrame"));
assert!(!visible(&s, "GuildStatusFrame"));
@@ -292,8 +259,6 @@ fn the_guild_tab_opens_the_roster_and_asks_for_it() {
"rows past the roster are hidden"
);
// "|cffffffff3|r Guild Members" + "(|cffffffff2|r |cff00ff00Online|r)" — the plural form for
// three, and the online count is a separate string beside it.
assert_eq!(text(&s, "GuildFrameTotals"), "|cffffffff3|r Guild Members");
assert_eq!(
text(&s, "GuildFrameOnlineTotals"),
@@ -301,8 +266,6 @@ fn the_guild_tab_opens_the_roster_and_asks_for_it() {
);
}
/// An OFFLINE member keeps every column — level, class and zone all come down for offline members
/// too, unlike the friends list where an offline friend has no level at all — and only greys.
#[test]
fn an_offline_member_keeps_its_columns_and_only_greys() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -329,9 +292,6 @@ fn an_offline_member_keeps_its_columns_and_only_greys() {
);
}
/// The little page button between the headers and the list flips to the OTHER view — same 13 rows,
/// same scroll frame, different four columns — and its own label swaps to name the view it would
/// take you back to.
#[test]
fn the_page_button_flips_to_the_guild_status_view() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -369,14 +329,10 @@ fn the_page_button_flips_to_the_guild_status_view() {
"an offline member reports how long ago, coarsest unit only"
);
// …and back.
s.run("GuildFrameGuildListToggleButton:Click()").unwrap();
assert!(visible(&s, "GuildPlayerStatusFrame"));
}
/// `GuildFrame_GetLastOnline` takes the COARSEST non-zero unit and never composes two, and all
/// four zero reads "< an hour". Asserted through the function because the four arms are otherwise
/// only reachable by seeding four different members.
#[test]
fn the_last_online_formatter_takes_the_coarsest_unit() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -403,8 +359,6 @@ fn the_last_online_formatter_takes_the_coarsest_unit() {
assert_eq!(last(3, 0, 0, 0), "3 years");
}
/// A left-click selects the row and opens the detail card; clicking that SAME row again closes it
/// and drops the selection, which is the only way back to "nothing selected".
#[test]
fn a_row_click_toggles_the_detail_card() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -437,7 +391,6 @@ fn a_row_click_toggles_the_detail_card() {
"…and clears the selection"
);
// A DIFFERENT row while the card is open re-points it rather than closing it.
s.run("GuildFrameButton2:Click()").unwrap();
s.run("GuildFrameButton3:Click()").unwrap();
assert!(visible(&s, "GuildMemberDetailFrame"));
@@ -449,16 +402,13 @@ fn a_row_click_toggles_the_detail_card() {
);
}
/// The card's four buttons are the rank law, and each of the reference's comparisons matters:
/// promote refuses to make a second-in-command your equal, demote refuses on the bottom rank, and
/// with BOTH arrows dead they leave the card rather than sitting greyed.
/// The rank comparisons of `FriendsFrame.lua:393-415`; with both arrows dead, they leave the card.
#[test]
fn the_detail_buttons_follow_the_rank_comparisons() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
open(&s);
// Row 2 — the officer directly below us. Nothing to promote him to; demote is fine.
s.run("GuildFrameButton2:Click()").unwrap();
assert!(
!enabled(&s, "GuildFramePromoteButton"),
@@ -475,7 +425,6 @@ fn the_detail_buttons_follow_the_rank_comparisons() {
"an online guildmate can be invited"
);
// Row 3 — the bottom rank. Promotable, but there is nothing below to demote him to.
s.run("GuildFrameButton3:Click()").unwrap();
assert!(enabled(&s, "GuildFramePromoteButton"));
assert!(
@@ -487,7 +436,6 @@ fn the_detail_buttons_follow_the_rank_comparisons() {
"…and he is offline"
);
// Row 1 — ourselves, the guild master. Every rank verb is dead, so the arrows go away.
s.run("GuildFrameButton1:Click()").unwrap();
assert!(!enabled(&s, "GuildFramePromoteButton"));
assert!(!enabled(&s, "GuildFrameDemoteButton"));
@@ -505,7 +453,6 @@ fn the_detail_buttons_follow_the_rank_comparisons() {
"nor invite yourself"
);
// The verbs address the selected NAME.
s.run("GuildFrameButton3:Click()").unwrap();
let _ = calls(&s);
s.run("GuildFramePromoteButton:Click()").unwrap();
@@ -516,8 +463,8 @@ fn the_detail_buttons_follow_the_rank_comparisons() {
);
}
/// The officer-note pane is a THREE-state affair, and the third state RESIZES the card: a rank that
/// may not even see officer notes gets 60px less window rather than an empty pane.
/// Without `CanViewOfficerNote` the pane goes and the card shrinks from 255 to 195
/// (`GUILD_DETAIL_OFFICER_HEIGHT`, `GUILD_DETAIL_NORM_HEIGHT`; `FriendsFrame.lua:370-390`).
#[test]
fn the_officer_note_pane_resizes_the_card() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -531,12 +478,12 @@ fn the_officer_note_pane_resizes_the_card() {
255.0
);
// May view but not edit: grey text, mouse-dead pane, and no edit-me placeholder.
// Can view but not edit: grey text, a mouse-dead pane, no placeholder.
s.run("BenillaGuildFixture.rights.editOfficerNote = nil; GuildStatus_Update()")
.unwrap();
assert_eq!(colour(&s, "OfficerNoteText"), (0.65, 0.65, 0.65));
// May not view at all: the pane goes, and the card shrinks.
// Cannot view: the pane goes, and the card shrinks.
s.run("BenillaGuildFixture.rights.viewOfficerNote = nil; GuildStatus_Update()")
.unwrap();
assert!(!visible(&s, "GuildMemberDetailOfficerNoteLabel"));
@@ -548,9 +495,6 @@ fn the_officer_note_pane_resizes_the_card() {
);
}
/// An EMPTY note a rank may edit shows the click-here invitation instead of nothing; the same note
/// with no edit right shows the empty string and a dead pane. Without this the only affordance on
/// an empty note is a blank rectangle nobody would click.
#[test]
fn an_editable_empty_note_invites_the_click() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -573,9 +517,7 @@ fn an_editable_empty_note_invites_the_click() {
assert_eq!(colour(&s, "PersonalNoteText"), (0.65, 0.65, 0.65));
}
/// Clicking a note pane opens the WIDE-box dialog, prefilled with the note it edits, and accepting
/// sends it against the SELECTED roster index. This is the first customer of the popup engine's
/// `hasWideEditBox` (UiPanels.xml) and the only test that proves the 420 widen and the box swap.
/// The guild message dialogs are 420 wide with the wide edit box (`StaticPopup.lua:1581-1587`).
#[test]
fn the_note_pane_opens_the_wide_dialog_and_sends_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -614,8 +556,6 @@ fn the_note_pane_opens_the_wide_dialog_and_sends_it() {
assert!(!visible(&s, "StaticPopup1"), "accepting closes it");
}
/// The message of the day paints the CACHED value, is click-to-edit only for a rank that may set
/// it, and the GUILD_MOTD event repaints it without a roster round trip.
#[test]
fn the_motd_is_cached_click_to_edit_and_right_gated() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -651,7 +591,7 @@ fn the_motd_is_cached_click_to_edit_and_right_gated() {
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildSetMOTD:Raid Wednesday.");
// The event is what actually repaints the pane — the roster never carries it back.
// The pane paints `CURRENT_GUILD_MOTD`, which only `GUILD_MOTD` updates after load.
s.fire_event(
"GUILD_MOTD",
vec![ScriptValue::Str("Raid Thursday.".to_string())],
@@ -659,8 +599,6 @@ fn the_motd_is_cached_click_to_edit_and_right_gated() {
assert_eq!(text(&s, "GuildFrameNotesText"), "Raid Thursday.");
}
/// Guild Control is the guild MASTER's alone and Add Member follows the invite right — the two
/// pane-level buttons, both driven by the repaint rather than by a click.
#[test]
fn the_pane_buttons_follow_the_rights() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -677,7 +615,7 @@ fn the_pane_buttons_follow_the_rights() {
);
assert!(!enabled(&s, "GuildFrameAddMemberButton"));
// Add Member opens the name dialog, and it is the NARROW box (a name, not a sentence).
// Add Member opens the narrow box: a name, not a sentence.
s.run("BenillaGuildFixture.rights.invite = 1; GuildStatus_Update()")
.unwrap();
let _ = calls(&s);
@@ -693,9 +631,7 @@ fn the_pane_buttons_follow_the_rights() {
assert_eq!(calls(&s), "GuildInviteByName:Thrall");
}
/// The rank editor loads its thirteen checkboxes from the rank BUFFER, opens with Accept dead, and
/// arms it on the first edit. The checkbox IDs are the option indices the engine maps to bits, so
/// the click has to carry the index it was declared with.
/// A checkbox's ID is the option index the engine maps to a bit.
#[test]
fn the_rank_editor_loads_its_flags_and_arms_accept_on_an_edit() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -736,14 +672,12 @@ fn the_rank_editor_loads_its_flags_and_arms_accept_on_an_edit() {
s.run("GuildControlPopupFrameCheckbox5:Click()").unwrap();
assert_eq!(
calls(&s),
// The Lua hands `this:GetChecked()` straight through, and in 1.12 that is the NUMBER 1
// (1830) — so 1 is what the reference's own call carries too.
// `this:GetChecked()` passes straight through, and in 1.12 it is the number 1.
"GuildControlSetRankFlag:5:1",
"the checkbox's own ID is what reaches the engine"
);
assert!(enabled(&s, "GuildControlPopupAcceptButton"));
// Accept flushes the buffer under the name in the box, then closes.
s.run("GuildControlPopupFrameEditBox:SetText(\"Warchief\")")
.unwrap();
let _ = calls(&s);
@@ -755,8 +689,6 @@ fn the_rank_editor_loads_its_flags_and_arms_accept_on_an_edit() {
assert!(!visible(&s, "GuildControlPopupFrame"));
}
/// Picking another rank in the dropdown re-loads the buffer, repaints the boxes from THAT rank's
/// flags, and re-disarms Accept — the edit you were half-way through does not follow you.
#[test]
fn switching_rank_reloads_the_buffer_and_disarms_accept() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -766,9 +698,7 @@ fn switching_rank_reloads_the_buffer_and_disarms_accept() {
s.run("GuildControlPopupFrameCheckbox5:Click()").unwrap();
assert!(enabled(&s, "GuildControlPopupAcceptButton"));
// Through the real menu, not the handler: the dropdown's rows ARE the ranks, so the row's own
// ID is the rank the buffer loads, and calling the handler by hand would prove nothing about
// that wiring.
// Through the real menu: a row's ID is the rank the buffer loads (`FriendsFrame.lua:864-871`).
let _ = calls(&s);
s.run("ToggleDropDownMenu(1, nil, GuildControlPopupFrameDropDown)")
.unwrap();
@@ -794,8 +724,8 @@ fn switching_rank_reloads_the_buffer_and_disarms_accept() {
);
}
/// The add/remove rank buttons: ten is the ceiling, and the last rank can only be removed once it
/// is EMPTY — which is why the repaint counts `playersInBotRank` at all.
/// Ten ranks is the ceiling (`FriendsFrame.lua:887`), and the last rank goes only once
/// `playersInBotRank` is zero (`:907-918`).
#[test]
fn the_rank_buttons_follow_the_count_and_the_bottom_rank() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -820,7 +750,7 @@ fn the_rank_buttons_follow_the_count_and_the_bottom_rank() {
s.run("BenillaGuildFixture.ranks = { \"Guild Master\",\"Officer\",\"Veteran\",\"Member\",\"Initiate\",\"Peon\" }")
.unwrap();
// Remove only shows on the LAST rank, and only past five ranks.
// Remove shows only on the last rank, and only past five ranks.
s.run("UIDropDownMenu_SetSelectedID(GuildControlPopupFrameDropDown, 1); GuildControlPopupFrameRemoveRankButton_OnUpdate()")
.unwrap();
assert!(
@@ -851,8 +781,6 @@ fn the_rank_buttons_follow_the_count_and_the_bottom_rank() {
);
}
/// Every column header sorts by its own key, in BOTH views — eight headers over one roster, and a
/// sort is a repaint, never a re-request.
#[test]
fn the_column_headers_sort_by_their_own_keys() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -882,14 +810,10 @@ fn the_column_headers_sort_by_their_own_keys() {
}
}
/// **The Show Offline Members checkbox EXISTS.** The reference declares it `virtual="true"` inside
/// a `<Frames>` block, which reads like "this is a template, do not build it" — and is not: the
/// `virtual` attribute is only consulted by the top-level file loader (`0x6ede10`), while a
/// `<Frames>` child goes straight to the instantiator via `LoadChildFrames 0x76a060`. So the box
/// is a real frame in the reference and must be one here.
///
/// Its click also DROPS THE SELECTION before re-filtering: the roster is about to be re-ordered,
/// so index 7 will not be the member index 7 was.
/// The Show Offline checkbox is declared `virtual="true"` inside a `<Frames>` block
/// (`FriendsFrame.xml:1816`) and is still a real frame: only the top-level loader reads `virtual`
/// (`0x6ede10`), while `LoadChildFrames` (`0x76a060`) instantiates every child. Its click drops the
/// selection before re-filtering (`FriendsFrame.xml:1852`).
#[test]
fn the_show_offline_checkbox_is_real_and_drops_the_selection() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -920,8 +844,6 @@ fn the_show_offline_checkbox_is_real_and_drops_the_selection() {
);
}
/// The guild-information board is read-only without the right: grey text, a dead Accept, and the
/// stored text rather than the click-here invitation.
#[test]
fn the_guild_information_board_is_right_gated() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -956,7 +878,6 @@ fn the_guild_information_board_is_right_gated() {
assert!(!enabled(&s, "GuildInfoSaveButton"));
assert_eq!(colour(&s, "GuildInfoEditBox"), (0.65, 0.65, 0.65));
// …and with the right, an empty board shows the invitation.
s.run("ToggleGuildInfoFrame()").unwrap();
s.run("BenillaGuildFixture.rights.editGuildInfo = 1")
.unwrap();
@@ -964,8 +885,7 @@ fn the_guild_information_board_is_right_gated() {
assert_eq!(text(&s, "GuildInfoEditBox"), "Click here to set message");
}
/// The three satellite windows are mutually exclusive — each one's opener shuts the other two —
/// and closing the social window takes all three with it, since none of them is its child.
/// `FriendsFrame_OnHide` closes all three satellites (`FriendsFrame.lua:133-135`).
#[test]
fn the_three_satellites_are_exclusive_and_close_with_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -993,9 +913,8 @@ fn the_three_satellites_are_exclusive_and_close_with_the_window() {
assert!(!visible(&s, "GuildFrame"));
}
/// `GUILD_ROSTER_UPDATE`'s `arg1` is the STALE flag, and it is the difference between a repaint and
/// a wire round-trip: a column-header sort fires this event without it, and re-requesting there
/// would put a server trip behind every click on a header.
/// `GUILD_ROSTER_UPDATE` re-requests the roster only when `arg1` marks it stale
/// (`FriendsFrame.lua:646-653`); a header sort fires the event without it.
#[test]
fn the_roster_event_only_re_requests_when_told_the_roster_is_stale() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1015,16 +934,14 @@ fn the_roster_event_only_re_requests_when_told_the_roster_is_stale() {
"…but a STALE roster is re-requested"
);
// With the pane closed the event does nothing at all — the arm's own visibility gate.
// With the pane closed the event does nothing: the arm's visibility gate.
s.run("HideUIPanel(FriendsFrame)").unwrap();
let _ = calls(&s);
s.fire_event("GUILD_ROSTER_UPDATE", vec![ScriptValue::Int(1)]);
assert_eq!(calls(&s), "", "the pane is closed; nothing repaints");
}
/// Leaving the guild while STANDING on the guild tab falls back to tab 1 rather than leaving a
/// pane with no guild behind it on screen — the half of `InGuildCheck` that is easy to omit and
/// impossible to notice until it happens.
/// `InGuildCheck` moves a guildless window off the guild tab (`FriendsFrame.lua:920-931`).
#[test]
fn losing_the_guild_falls_back_off_the_guild_tab() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1046,12 +963,11 @@ fn losing_the_guild_falls_back_off_the_guild_tab() {
"and the window falls back to the friends list"
);
// The tab also refuses to be selected by number while guildless (the second lock).
// `ToggleFriendsFrame(3)` returns early while guildless (`FriendsFrame.lua:750`).
s.run("ToggleFriendsFrame(3)").unwrap();
assert!(!visible(&s, "GuildFrame"));
assert!(visible(&s, "FriendsListFrame"));
// Joining one re-arms it.
s.run("BenillaGuildFixture.inGuild = 1").unwrap();
s.fire_event("PLAYER_GUILD_UPDATE", vec![]);
assert_eq!(
@@ -1066,8 +982,8 @@ fn losing_the_guild_falls_back_off_the_guild_tab() {
);
}
/// A right-click on a roster row opens the shared FRIEND menu with the two GUILD rows live. They
/// are gated on this pane being visible, which is what keeps them off a friends-list or /who row.
/// The FRIEND menu's two guild rows are gated on `GuildFrame:IsVisible()`
/// (`UnitPopup.lua:323-330`), which keeps them off a friends-list or `/who` row.
#[test]
fn right_clicking_a_roster_row_offers_the_guild_rows() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1108,7 +1024,7 @@ fn right_clicking_a_roster_row_offers_the_guild_rows() {
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildSetLeaderByName:Furor");
// GUILD_LEAVE is only ever offered on YOURSELF…
// GUILD_LEAVE is offered only on yourself.
s.run("GuildFrameButton1:Click(\"RightButton\")").unwrap();
assert_eq!(
s.eval::<Option<i64>>(&row("GUILD_PROMOTE")).unwrap(),
@@ -1126,17 +1042,13 @@ fn right_clicking_a_roster_row_offers_the_guild_rows() {
assert_eq!(calls(&s), "GuildLeave");
}
/// The same two rows are ABSENT from a `/who` row's menu, which opens the identical FRIEND menu.
/// That gate is `GuildFrame:IsVisible()`, and without it a guild verb would sit on every name in
/// the game.
#[test]
fn the_guild_rows_stay_off_a_who_row_menu() {
let _data = benilla_formats::wow_data_or_skip!();
let s = setup();
s.run("ShowWhoPanel()").unwrap();
// NOT our own name: the WHISPER row hides on yourself, and with INVITE gated the same way the
// menu would have nothing to show and never open — which would make the assertion below pass
// for the wrong reason.
// Not our own name: on yourself the menu has no row to show and never opens, so the check
// below would pass vacuously.
s.run("FriendsFrame_ShowDropdown(\"Thrall\", 1)").unwrap();
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
let present = r#"
@@ -1154,8 +1066,7 @@ fn the_guild_rows_stay_off_a_who_row_menu() {
);
}
/// A guild invite raises the accept/decline dialog wherever you are — it does not need the social
/// window to have ever been opened, which is why it rides its own hidden driver frame.
/// `UIParent_OnEvent` raises the invite dialog (`UIParent.lua:296-303`), social window shut or not.
#[test]
fn a_guild_invite_raises_its_dialog_without_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1208,9 +1119,8 @@ fn a_guild_invite_raises_its_dialog_without_the_window() {
assert!(!visible(&s, "StaticPopup1"));
}
/// Removing a member goes behind a confirm that NAMES them — the registry line carries a
/// placeholder and OnShow splices the real name in, which is the one dialog in this file whose
/// text is rewritten on show.
/// The confirm names the member: the registry text carries a placeholder that OnShow replaces
/// (`StaticPopup.lua:903`, `:911`).
#[test]
fn removing_a_member_names_them_in_the_confirm() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1232,17 +1142,9 @@ fn removing_a_member_names_them_in_the_confirm() {
);
}
/// **`GuildControlGetRankFlags` really answers THIRTEEN values, and the count survives the
/// 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`, 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`.
/// `GuildControlCheckboxUpdate` loops `for i=1, arg.n` (`FriendsFrame.lua:876`), so the binding
/// must push all thirteen returns even when every one is nil. Asks the real `script::guild`, not
/// the fixture.
#[test]
fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1253,7 +1155,7 @@ fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() {
"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"
);
// …and each of them is nil, not `false`: the era boolean law, which `SetChecked` reads.
// Each is nil, not `false`: a 1.12 boolean, which `SetChecked` reads.
assert_eq!(
s.eval::<i64>(
"local function count(...) \
@@ -1268,21 +1170,12 @@ fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() {
);
}
/// **Each `Can*` predicate reads the bit its checkbox owns, end to end.**
///
/// `RANK_RIGHT_BITS`' own unit test asserts the table as *data*. That is necessary and not
/// sufficient: a wrong table is only a bug because the predicates read it, and a mutation that
/// renumbered Promote/Demote/Invite/Remove into the naive contiguous layout left every window test
/// in this file green — they drive the fixture's stand-ins, not the engine's predicates. So this
/// one pushes a real snapshot with exactly ONE right set and asks the real globals.
///
/// The four it checks are precisely the four the naive `1 << (i - 1)` layout would swap, which is
/// what makes it a falsifier rather than a restatement.
/// Asks the real globals over a snapshot with one right set. Checkboxes 5-8 own bits 0x80, 0x100,
/// 0x10 and 0x20, out of order, so a `1 << (i - 1)` table reads the wrong bit for each.
#[test]
fn each_permission_predicate_reads_the_bit_its_checkbox_owns() {
let _data = benilla_formats::wow_data_or_skip!();
// Index 7, "Invite Member" — bit 0x10. A shift-based table would put 0x10 at index 5,
// "Promote", so this single word separates the two layouts in both directions.
// Checkbox 7, Invite Member, is bit 0x10, which a shift-based table gives checkbox 5, Promote.
let mut s = UiScript::new().unwrap();
s.set_guild(GuildState {
in_guild: true,
@@ -1304,7 +1197,7 @@ fn each_permission_predicate_reads_the_bit_its_checkbox_owns() {
);
}
// Index 5, "Promote" — bit 0x80, which the naive layout would call Remove Member.
// Checkbox 5, Promote, is bit 0x80, which a shift-based table gives checkbox 8, Remove Member.
s.set_guild(GuildState {
in_guild: true,
rights: 0x0000_0080,
@@ -1321,7 +1214,6 @@ fn each_permission_predicate_reads_the_bit_its_checkbox_owns() {
0
);
// And a guildless player holds no right at all, whatever the stale word says.
s.set_guild(GuildState {
in_guild: false,
rights: u32::MAX,
@@ -1337,19 +1229,9 @@ fn each_permission_predicate_reads_the_bit_its_checkbox_owns() {
}
}
/// **`GetGuildRosterInfo` really answers TEN values, and the tenth is `status`.** The second
/// place the window and the engine are coupled by arity, and the one that nearly shipped wrong.
///
/// Six of the reference's seven call sites destructure only nine (`FriendsFrame.lua:344`, `:447`,
/// `:479`, `:709`, `StaticPopup.lua:1008`, `:1045`). Only the player-status view takes the tenth
/// (`:541`) and branches on `status == ""` to choose between the "Online" label and the
/// `<AFK>`/`<DND>` tag (`:548-551`). So a nine-value binding leaves exactly one column blank while
/// every other assertion in this file — and every other window — still passes.
///
/// Like the rank-flags falsifier above, this deliberately does NOT install the fixture: the
/// fixture returns ten by construction, so asking it would prove nothing. It asks the real
/// `script::guild`, with an empty roster, where every return is nil and only the *count* is
/// evidence.
/// Only the guild-status view takes the tenth return, `status`, showing it in place of "Online"
/// (`FriendsFrame.lua:541`, `:548-551`); the other call sites take nine. Asks the real
/// `script::guild` with an empty roster, where only the count is evidence.
#[test]
fn the_roster_binding_answers_ten_values_and_the_tenth_is_status() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1368,9 +1250,6 @@ fn the_roster_binding_answers_ten_values_and_the_tenth_is_status() {
);
}
/// The whole slice runs clean: no Lua error reaches the session's error sink through any of the
/// paths above. A handler that raises mid-way still leaves the frames it already touched looking
/// right, so an assertion on the look is not enough on its own.
#[test]
fn driving_the_guild_windows_raises_no_script_errors() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1392,19 +1271,13 @@ fn driving_the_guild_windows_raises_no_script_errors() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Every list in this window scrolls with the wheel — friends, ignore, who AND guild.
///
/// The guild pane shipped without its `OnMouseWheel` while the comment three lines above its own
/// scroll frame said the list was "driven by `updateFunc` + the pane's OnMouseWheel", and while
/// its three siblings each carried one. A gap that the surrounding prose asserts is closed is
/// invisible to reading, so this asserts the WIRING rather than the comment.
#[test]
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).
// The wheel handler is each list's scroll frame's, from `UIPanelScrollFrameTemplate`
// (`UIPanelTemplates.xml:208`), not the pane's.
for frame in [
"FriendsFrameFriendsScrollFrame",
"FriendsFrameIgnoreScrollFrame",
@@ -1,20 +1,11 @@
//! The GM help window (decision 1673, HelpFrame.xml): the category list the DBC feeds it, the two
//! faces of `UPDATE_TICKET`, the queue gate, the ticket toast, and the three dialogs.
//!
//! Written as the **falsification** pass over the transcription rather than a demonstration of it:
//! every test is named after one claim the window makes, and each was checked to fail when the
//! claim is broken. The load-bearing one is
//! [`clicking_a_category_files_a_ticket_under_that_categorys_dbc_id`] — the id travels from
//! `GMTicketCategory.dbc` through a button, a page, and the editor onto the wire, and a break
//! anywhere in that chain files every ticket under the wrong heading with nothing on screen to
//! show for it.
//! The stock GM help window (`HelpFrame.xml`): the two faces of `UPDATE_TICKET`, the ticket
//! toast and its repoll, and the status ask on show.
use benilla_ui::script::{GmTicketIntent, ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
/// The window, its dependencies, and the catalog the app pushes — the real ten `GMTicketCategory`
/// rows, so a test that walks the list is walking the shipped data.
/// The window and its dependencies, with the ten `GMTicketCategory.dbc` rows the app pushes.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -27,18 +18,15 @@ fn setup() -> UiScript {
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).
// Before StaticPopup.xml, whose coin row inherits `SmallMoneyFrameTemplate`.
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.
// `HelpFrame_OnShow` calls `UpdateMicroButtons()` before `GetGMStatus()`
// (`HelpFrame.lua:178`), so the micro row and the bar's button kit load too.
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
@@ -47,9 +35,8 @@ fn setup() -> UiScript {
"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.
// `TicketStatusFrame_OnEvent` re-anchors `TemporaryEnchantFrame` before it arms the
// repoll (`HelpFrame.lua:494`).
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\HelpFrame.xml",
] {
@@ -70,9 +57,7 @@ fn setup() -> UiScript {
s
}
/// The `UPDATE_TICKET` argument list the app's feed builds for an open ticket — category first,
/// text second, exactly as `ui_gm_ticket::update_ticket_args` orders it. Kept in sync by being
/// written the same way in both places; if they ever disagree, this file's tests are what notices.
/// `UPDATE_TICKET`'s args for an open ticket; must match `ui_gm_ticket::update_ticket_args`.
fn open_ticket_args(
category: i64,
text: &str,
@@ -91,10 +76,7 @@ fn open_ticket_args(
]
}
/// **`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
/// looks fine until you try to file a second ticket.
/// With a ticket the form is an editor (Save Changes, Exit); a bare `arg1 = 0` makes it a form.
#[test]
fn an_open_ticket_turns_the_form_into_an_editor_and_a_zero_turns_it_back() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -124,7 +106,7 @@ fn an_open_ticket_turns_the_form_into_an_editor_and_a_zero_turns_it_back() {
1
);
// And now the ordinary answer.
// No ticket: a bare 0.
s.fire_event("UPDATE_TICKET", vec![ScriptValue::Int(0)]);
assert_eq!(
s.eval::<String>("return HelpFrameOpenTicketText:GetText()")
@@ -139,8 +121,6 @@ fn an_open_ticket_turns_the_form_into_an_editor_and_a_zero_turns_it_back() {
);
}
/// 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]
fn the_ticket_toast_follows_the_ticket() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -162,9 +142,7 @@ fn the_ticket_toast_follows_the_ticket() {
);
}
/// The toast's own poll is what keeps a long wait honest: `TicketStatus_OnUpdate` re-asks the
/// server every `GMTICKET_CHECK_INTERVAL`, and not before. This is the reason the app counts
/// answers instead of diffing them, so it is worth a test on this side too.
/// `TicketStatus_OnUpdate` re-asks every `GMTICKET_CHECK_INTERVAL`, 600 s (`HelpFrame.lua:162`).
#[test]
fn the_toast_repolls_the_server_only_after_the_full_interval() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -190,9 +168,7 @@ fn the_toast_repolls_the_server_only_after_the_full_interval() {
);
}
/// `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.
/// `HelpFrame_OnShow` asks the server for the queue status (`HelpFrame.lua:180`).
#[test]
fn toggling_the_window_opens_it_and_asks_for_the_queue_status() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -204,7 +180,6 @@ fn toggling_the_window_opens_it_and_asks_for_the_queue_status() {
vec![GmTicketIntent::AskStatus],
"OnShow calls GetGMStatus — the gate must not run on an assumption"
);
// This is the one test that asserts the OnShow traffic itself; the others drain it away first.
assert!(
s.eval::<bool>("return HelpFrameHome:IsVisible()").unwrap(),
"and it opens on Home"
@@ -1,31 +1,12 @@
//! The shipped **Honor tab** (`Interface\\FrameXML\\HonorFrame.xml`, the reference's own) driven
//! end-to-end, engine-only (no Bevy) — the per-window test module the skills/reputation files
//! establish (decision 1512).
//!
//! What it pins is the PAINT law, the half `benilla-ui`'s own `script::pvp::tests` structurally
//! cannot reach: that module drives the thirteen globals and asserts their tuples, and stops at the
//! seam. Everything below is about what those tuples become on screen — which figure lands in which
//! of the thirteen row slots, what the rank block does with a nil title, which badge file the
//! arithmetic names, and the weekly/session repaint split.
//!
//! Two of these are the arc's own traps, and they are why this file exists rather than trusting the
//! engine tests:
//!
//! - **The badge is the VISUAL rank and the title is the INTERNAL one.** They differ by four, both
//! are numbers, and both come out of one call. A pane that indexed the badge by the internal rank
//! would put a Sergeant's art on a Knight-Captain and no tuple assertion would notice.
//! - **The weekly sections repaint on world entry only.** A pane that repainted everything on every
//! kill would look identical on screen for a whole session and differ only in what it costs —
//! which is to say, it would never be caught by looking.
//! The stock Honor tab (`HonorFrame.xml`) and the inspect window's honor page, engine only: what
//! the honor bindings' answers become on screen.
use benilla_ui::script::{HonorState, UiScript, UnitState};
use super::test_ui::load_ui as load_xml;
/// The fixture snapshot — a rank-12 Alliance character with a figure in every bucket, all thirteen
/// distinct so a swapped pair of rows cannot pass. Deliberately the same numbers as the protocol
/// crate's 50-byte `inspect_honor_stats_golden`, so a failure here can be read straight against the
/// bytes one layer down.
/// A rank-12 Alliance character with thirteen distinct figures, so a swapped pair of rows fails;
/// the numbers are the protocol crate's `inspect_honor_stats_golden`.
fn state() -> HonorState {
HonorState {
session_hk: 17,
@@ -41,24 +22,16 @@ fn state() -> HonorState {
last_week_standing: 57,
lifetime_hk: 3_907,
lifetime_dk: 12,
// Two DIFFERENT ranks on purpose: the current one the badge and title draw, and the
// higher lifetime best the "Highest Rank" row shows. A pane that read one for the other
// passes every same-value fixture.
// Different on purpose: the badge and title draw `rank`, the Highest Rank row the other.
rank: 12,
highest_rank: 14,
// Three quarters through the rank (the binding's constant × 191).
// 191/255, three quarters through the rank.
rank_bar: 191,
}
}
/// The eight rank titles this fixture can ask for, defined as the install's `GlobalStrings.lua`
/// defines them — verbatim (`GlobalStrings.lua:2795`, `:3075`, `:3150-3161`, `:3297`).
///
/// **The inspect page's, since 1751.** The character page loads the player's own file as its first
/// [`super::test_ui::CHARACTER_UI`] entry, because stock `PaperDollFrame_OnLoad` sets seven labels
/// from it; [`shown_inspect_honor_page`] loads no such thing, so these are what its titles read.
/// [`the_real_global_strings_name_the_rank`] is the same assertion against the player's actual
/// file, on both sides.
/// The strings the inspect page's titles read, verbatim from 1.12's `GlobalStrings.lua` (`:2795`,
/// `:3075`, `:3150-3161`, `:3297`).
const RANK_GLOBALS: &str = r#"
NONE = "None"
RANK = "Rank"
@@ -69,37 +42,24 @@ const RANK_GLOBALS: &str = r#"
PVP_RANK_14_0 = "Champion"
"#;
/// The character window, whose fifth tab this page is. Every file it needs — including
/// `Interface\\FrameXML\\HonorFrame.xml` itself, which the shared list carries because
/// `PaperDollFrame_SetLevel` writes `HonorLevelText` on every show
/// (`PaperDollFrame.lua:103`, and `_SetGuild` writes `HonorGuildText` at `:123`) — is
/// [`super::test_ui::CHARACTER_UI`]; this page adds nothing of its own. It was a hand-copied list until the window became the reference's (decision 1751), which
/// is when the list stopped being short enough for a copy to stay honest.
/// The character window, whose fifth tab this page is; [`super::test_ui::CHARACTER_UI`] carries
/// `HonorFrame.xml`, as `PaperDollFrame.lua:103` writes into it on every show.
fn load_page(s: &UiScript) {
for file in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(s, file);
}
}
/// An Alliance, male, level-60 player — the two facts the rank title's GlobalString key is built
/// from (side → the team digit, sex → the `_FEMALE` twin), plus the level line's input.
///
/// **Both halves of the race and class pairs, since 1751.** `UnitRace`/`UnitClass` answer
/// `(localized, file)` or `nil, nil` — the binding `zip`s the two, so a snapshot carrying only the
/// localized name reports the unit as raceless. Stock `PaperDollFrame_SetLevel` writes
/// `HonorLevelText` off `format(TEXT(PLAYER_LEVEL), UnitLevel, UnitRace, UnitClass)` — unguarded,
/// on every show of this page (`PaperDollFrame.lua:100-104`) — so a half-filled snapshot raises
/// there. Ours never called it from the honor tab, which is why the gap sat unseen.
/// An Alliance male level-60 player. Race and class carry both halves: the level line formats
/// `UnitRace` and `UnitClass` unguarded on every show (`PaperDollFrame.lua:100-104`).
fn alliance_player() -> UnitState {
UnitState {
exists: true,
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.
// The title's team digit comes from the race through ChrRaces and FactionTemplate
// (`0x5efe00`), not from the live `faction_group`; human is race 1, team 1.
pvp_team: crate::ui_unit::race_pvp_team(1),
race: Some("Human".into()),
race_file: Some("Human".into()),
@@ -110,8 +70,7 @@ fn alliance_player() -> UnitState {
}
}
/// The Honor page open on its tab with [`state`] pushed, as world entry leaves it (`weekly` true,
/// so every section has been painted at least once).
/// The Honor tab open over [`state`], every section painted, as world entry leaves it.
fn shown_honor_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -129,9 +88,6 @@ fn text(s: &mut UiScript, frame: &str) -> String {
.unwrap_or_else(|e| panic!("{frame}:GetText(): {e}"))
}
/// Every figure the snapshot carries lands in its own row. Thirteen distinct numbers against
/// thirteen slots: any transposition — the two lifetime totals, this week against last week, the
/// standing against a kill count — fails here and only here.
#[test]
fn every_figure_lands_in_its_own_row() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -148,7 +104,7 @@ fn every_figure_lands_in_its_own_row() {
("HonorFrameLastWeekStandingValue", "57"),
("HonorFrameLifeTimeHKValue", "3907"),
("HonorFrameLifeTimeDKValue", "12"),
// The HIGHEST rank's title, not the current one — rank 14, not rank 12.
// The highest rank's title (14), not the current one (12).
("HonorFrameLifeTimeRankValue", "Lieutenant Commander"),
] {
assert_eq!(text(&mut s, frame), want, "{frame}");
@@ -156,15 +112,12 @@ fn every_figure_lands_in_its_own_row() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The rank block: the title is keyed by the INTERNAL rank and the badge by the VISUAL one, which
/// differ by four. This is the arc's central conflation trap (decision 1512) and the assertion that
/// makes it impossible to ship.
/// The title is keyed by the internal rank and the badge by the visual one, four lower.
#[test]
fn the_title_is_the_internal_rank_and_the_badge_is_the_visual_one() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_honor_page();
assert_eq!(text(&mut s, "HonorFrameCurrentPVPTitle"), "Knight-Captain");
// Internal 12 → visual 8. The line reads "(Rank 8)", never "(Rank 12)".
assert_eq!(text(&mut s, "HonorFrameCurrentPVPRank"), "(Rank 8)");
assert!(
s.eval::<bool>("return HonorFramePvPIcon:IsShown()")
@@ -179,9 +132,8 @@ fn the_title_is_the_internal_rank_and_the_badge_is_the_visual_one() {
);
}
/// Rank 0 — every character who has never taken an honorable kill. The title's GlobalString does
/// not exist (there is no `PVP_RANK_0_*`), the binding answers nil, and the pane says NONE with no
/// badge. The nil is the mechanism, so this is the case that proves the pane never invents a title.
/// 1.12 has no `PVP_RANK_0_*` string, so the binding answers nil and the page writes `NONE`
/// (`HonorFrame.lua:44-54`).
#[test]
fn an_unranked_character_reads_none_and_shows_no_badge() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -211,25 +163,15 @@ 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.
/// vmangos puts a GM on faction template 35 (`Player.cpp:2661`), group mask 0, so
/// `UnitFactionGroup` answers nil; the rank title still resolves, as `0x5efe00` reads the unit's
/// race, not its faction. The bar keeps the sideless red, so the side must stay gone.
#[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.
// Grand Marshal, internal rank 18.
rank: 18,
highest_rank: 18,
..state()
@@ -237,7 +179,7 @@ fn a_gm_flagged_player_keeps_his_rank_title_and_loses_only_the_faction_group() {
s.set_unit(
"player",
Some(UnitState {
// `.gm on`: template 35, group mask 0 → no side.
// `.gm on`: no side.
faction_group: None,
faction_group_localized: None,
pvp_rank: 18,
@@ -261,8 +203,7 @@ fn a_gm_flagged_player_keeps_his_rank_title_and_loses_only_the_faction_group() {
.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.
// No side takes the bar colour's `else` arm, red (`HonorFrame.lua:68-72`).
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return HonorFrameProgressBar:GetStatusBarColor()")
.unwrap();
@@ -273,27 +214,19 @@ fn a_gm_flagged_player_keeps_his_rank_title_and_loses_only_the_faction_group() {
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.
#[test]
fn the_bar_takes_the_fraction_and_the_faction_colour() {
let _data = benilla_formats::wow_data_or_skip!();
let s = shown_honor_page();
// Against the binding's own arithmetic, not against `191/255`. The client MULTIPLIES by the
// f32 nearest 1/255 (`0x3B808081`, `0x51aace`) rather than
// dividing, and the two answers differ in the eighth decimal — a tolerance loose enough to
// accept both would stop pinning the correction the moment it was made.
// The client multiplies by the f32 nearest 1/255 (`0x3B808081`, at `0x51aace`) rather than
// dividing by 255; the two differ in the eighth decimal.
let want = 191.0 * f64::from(f32::from_bits(0x3B80_8081));
let binding = s.eval::<f64>("return GetPVPRankProgress()").unwrap();
assert!(
(binding - want).abs() < 1e-12,
"the binding answers 191 × the reference's own constant = {want}, got {binding}"
);
// The BAR is checked at `f32`, and that is not a slackened assertion — a `StatusBar`'s value is
// a C `float` in the real client and an `f32` in ours, so the round trip through the widget is
// *supposed* to lose the tail. Asserting the exact `f64` here would be asserting that our
// status bars are wider than the reference's.
// Compared at `f32`: a StatusBar's value is a `float` in the 1.12 client too.
let value = s
.eval::<f64>("return HonorFrameProgressBar:GetValue()")
.unwrap();
@@ -310,16 +243,13 @@ fn the_bar_takes_the_fraction_and_the_faction_colour() {
);
}
/// The repaint split the reference wrote and we kept: a kill moves the session and rank blocks, and
/// leaves the three weekly sections alone until world entry says otherwise.
///
/// Driven through the real `OnEvent` rather than by calling `HonorFrame_Update` directly, because
/// the flag's *derivation from the event name* is the half that can be got wrong.
/// The weekly sections repaint on world entry only (`HonorFrame.lua:7-33`), so this drives the real
/// `OnEvent`, where the flag comes from the event name.
#[test]
fn a_kill_repaints_the_session_but_not_the_week() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_honor_page();
// Everything moves in the snapshot — including a weekly figure, which is the bait.
// A weekly figure moves too, and the kill must not repaint it.
s.set_honor(Some(HonorState {
session_hk: 18,
this_week_hk: 124,
@@ -338,19 +268,14 @@ fn a_kill_repaints_the_session_but_not_the_week() {
"the weekly block does NOT — it moves at the server's weekly maintenance"
);
// World entry is the flag that says "repaint those too".
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
assert_eq!(text(&mut s, "HonorFrameThisWeekHKValue"), "124");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The rank title against the **real shipped strings**, end to end — the leg the hand-set
/// [`RANK_GLOBALS`] above cannot reach, and the one that proves the key we build
/// (`PVP_RANK_<internal>_<team>`) is the key the player's own file actually defines.
///
/// The two sides matter: rank 12 is "Knight-Captain" to the Alliance and "Legionnaire" to the
/// Horde, so a hardcoded team digit passes one and fails the other. Skips without client data.
/// The key we build, `PVP_RANK_<internal>_<team>`, against the install's own `GlobalStrings.lua`,
/// for both teams.
#[test]
fn the_real_global_strings_name_the_rank() {
let data = benilla_formats::wow_data_or_skip!();
@@ -386,15 +311,12 @@ fn the_real_global_strings_name_the_rank() {
}
// ── The inspect window's honor page ─────────────────────────────────────────────────────────────
//
// Same twelve rows, same two painters, a different data source: the character page reads a
// descriptor block we hold, this one reads a reply that had to be asked for. The tests below are
// about that difference — the twelve-value tuple landing in the right slots, and the ask/hold latch.
// The same rows fed from an inspect reply, which has to be asked for.
use benilla_ui::script::InspectHonorData;
use std::collections::HashMap;
/// A live, inspectable unit at squared distance `d2` (see `inspect_tests`' twin).
/// A live, inspectable unit at squared distance `d2`.
fn reach(d2: f64) -> benilla_ui::script::UnitReach {
benilla_ui::script::UnitReach {
dist_sq: d2,
@@ -402,8 +324,7 @@ fn reach(d2: f64) -> benilla_ui::script::UnitReach {
}
}
/// The reply for the inspected player, carrying the same figures as [`state`] so a row that took
/// its number from the wrong pane's feed is visible as a wrong *value*, not just a wrong layout.
/// The inspected player's reply, with [`state`]'s figures.
fn inspect_reply() -> InspectHonorData {
let s = state();
InspectHonorData {
@@ -419,15 +340,13 @@ fn inspect_reply() -> InspectHonorData {
last_week_standing: s.last_week_standing,
lifetime_hk: s.lifetime_hk,
lifetime_dk: s.lifetime_dk,
// The reply's own `highestRank` — `GetInspectHonorData`'s twelfth return, which the
// reference destructures as `lifetimeRank`.
// `GetInspectHonorData`'s twelfth return, `lifetimeRank` (`InspectHonorFrame.lua:21`).
highest_rank: s.highest_rank,
rank_bar: s.rank_bar,
}
}
/// The inspect window open on its Honor tab, over a rank-12 Alliance target with [`inspect_reply`]
/// held.
/// The inspect window on its Honor tab, over a rank-12 Alliance target, [`inspect_reply`] held.
fn shown_inspect_honor_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -439,39 +358,31 @@ fn shown_inspect_honor_page() -> UiScript {
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The inspect window's four tabs (`Blizzard_InspectUI`) inherit it (1993).
// The inspect window's tabs inherit its tab template.
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// `InspectUnit`'s home since 1832.
"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).
// Before the inspect addon, as `inherits=` resolves at load: its slot buttons inherit
// `ItemButtonTemplate`, its honor rows `HonorFrame.xml`'s row templates.
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\HonorFrame.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).
// A LoadOnDemand addon, seated off the chain and loaded by stock `InspectFrame_LoadUI`
// (`UIParent.lua:170`), as the app does.
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
// the BAR COLOUR, but `GetPVPRankInfo` takes no unit at all and keys its title off the local
// player's side and sex. That is faithful — the ref calls it the same way — and it is sound
// because the server only lets you inspect a player you cannot attack, i.e. your own faction.
// Without a player pushed, every title on this page reads NONE (found by this test).
// The player is needed too: the page titles the target through `GetPVPRankInfo`
// (`InspectHonorFrame.lua:50`), which keys off the local player's side and sex.
s.set_unit("player", Some(alliance_player()));
// 4 yards away (d² = 16), inside the verified `CanInspect` 100.0 — the window refuses to open
// otherwise, and a silently-refused open would read as a broken pane.
// 4 yards (d² = 16), inside `CanInspect`'s d² of 100; the window refuses to open otherwise.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s.set_inspect_honor(Some(inspect_reply()));
s.run(r#"InspectUnit("target")"#).unwrap();
@@ -480,8 +391,7 @@ fn shown_inspect_honor_page() -> UiScript {
s
}
/// The twelve-value tuple lands in the twelve slots, and the rank block reads the INSPECTED unit —
/// not the player. Everything the character page's row test pins, on the other feed.
/// The rank block reads the inspected unit, not the player.
#[test]
fn the_inspect_page_paints_the_reply_it_holds() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -502,8 +412,7 @@ fn the_inspect_page_paints_the_reply_it_holds() {
] {
assert_eq!(text(&mut s, frame), want, "{frame}");
}
// The rank block: the target's CURRENT rank (12 → "Knight-Captain", badge 08), and the bar off
// the reply's own byte rather than the player's.
// The target's current rank, 12, and the bar from the reply's own byte.
assert_eq!(
text(&mut s, "InspectHonorFrameCurrentPVPTitle"),
"Knight-Captain"
@@ -519,25 +428,18 @@ fn the_inspect_page_paints_the_reply_it_holds() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The latch the reference's `OnShow` gates on: with nothing held the page asks and paints nothing;
/// with a reply held it paints and asks for nothing.
///
/// This is the one behaviour on this page that is a *round trip* rather than a read, and getting it
/// backwards is invisible on screen in the common case — a window opened on a player whose data
/// happens to be held looks identical either way.
/// Stock `InspectHonorFrame_OnShow` asks only with no reply held (`InspectHonorFrame.lua:11-17`).
#[test]
fn the_page_asks_only_when_it_holds_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = shown_inspect_honor_page();
// Held: the open above consumed no request.
assert_eq!(
s.take_inspect_honor_requests(),
0,
"a page holding a reply must not re-ask"
);
// Cleared — the app drops the reply when the inspected player changes. Re-showing the page
// asks, and paints nothing until an answer lands.
// The app drops the reply when the inspected player changes; the next show asks.
s.set_inspect_honor(None);
s.run(r#"InspectHonorFrame_OnShow()"#).unwrap();
s.resolve();
+73 -191
View File
@@ -1,8 +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::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.
//! The player-UI input pass: [`feed_ui_input`] feeds mouse and keyboard events into the UI engine
//! once [`super::extract::tick_script`] has resolved the frame's rects.
use bevy::input::keyboard::KeyboardInput;
use bevy::input::mouse::AccumulatedMouseScroll;
@@ -16,12 +13,9 @@ use super::{CursorPayloadHeld, PlayerUiClickConsumed, PlayerUiHover, UiKeyboardC
use crate::bindings::WheelNotches;
use crate::textinput::{self, keymap, HostClipboard};
/// The pointer-side state [`feed_ui_input`] reads and writes, as one
/// [`bevy::ecs::system::SystemParam`] (the argument ceiling): the UI hover + click-consumed
/// outputs, the world pick inputs (LAST frame's hovered unit/GameObject + the occlusion ray —
/// the target chain runs after this pass; a frame's staleness is within the pick's own
/// tolerance) that route the world-click payload legs (decisions 0571 + 0574), and the
/// payload-held mirror.
/// The pointer-side state [`feed_ui_input`] reads and writes. The world pick reads last frame's
/// hover and occlusion ray, since the target chain runs after this pass; one frame of staleness is
/// within the pick's tolerance.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct PointerFeed<'w> {
hover: ResMut<'w, PlayerUiHover>,
@@ -30,23 +24,17 @@ pub(super) struct PointerFeed<'w> {
hovered_object: Res<'w, crate::target::HoveredObject>,
occlusion: Res<'w, crate::target::PickOcclusion>,
payload_held: ResMut<'w, CursorPayloadHeld>,
/// TOGGLEUI ([`crate::ui_hide::UiHidden`]): a hidden UI takes no mouse at all — it rides here
/// because that is precisely the pointer half of this pass.
/// TOGGLEUI ([`crate::ui_hide::UiHidden`]): a hidden UI takes no mouse at all.
hidden: Res<'w, crate::ui_hide::UiHidden>,
/// A headless probe is driving the pointer itself ([`super::SyntheticPointer`]) — the real
/// cursor must not fight it. Same arm as `hidden` below, for the same reason: the mouse half
/// of this pass is not ours this frame.
/// A headless probe drives the pointer ([`super::SyntheticPointer`]), not the real cursor.
synthetic: Res<'w, super::SyntheticPointer>,
/// A capture owns the pointer for its whole run ([`super::CapturePointerPinned`]) — the OS
/// cursor belongs to whoever is at the keyboard and must not reach the shot.
/// A capture owns the pointer ([`super::CapturePointerPinned`]): no OS cursor in the shot.
capture_pinned: Res<'w, super::CapturePointerPinned>,
}
impl PointerFeed<'_> {
/// The reference's click-time pick state, derived from what benilla already tracks: a
/// hovered unit/GameObject is an `Object` pick; else a finite occlusion-ray hit (terrain/
/// WMO/doodad under the cursor) is `Terrain`; else `Nothing` (sky). Decision 0574's
/// terrain-vs-nothing split rides the occlusion ray the unit pick already casts.
/// The reference's click-time pick: a hovered unit or GameObject is `Object`, else a finite
/// occlusion-ray hit (terrain, WMO, doodad) is `Terrain`, else `Nothing` (sky).
fn world_pick(&self) -> benilla_ui::script::WorldPick {
use benilla_ui::script::WorldPick;
if self.hovered.target.is_some() || self.hovered_object.target.is_some() {
@@ -59,14 +47,9 @@ impl PointerFeed<'_> {
}
}
/// The frame's wheel travel into the pane under the cursor's `OnMouseWheel` — **one call per whole
/// notch**, `arg1 = ±1`.
///
/// The travel is normalised to lines first ([`crate::bindings::wheel_lines`]) and the fraction
/// carried in `notches`: a trackpad reports a gesture as a `Pixel` trickle across many frames, and
/// passing each frame's raw delta fired the handler once per frame — the stock handlers act on
/// the sign alone (`ScrollFrameTemplate_OnMouseWheel` scrolls half a pane per call), so a gentle
/// swipe slammed the pane to its end. A mouse wheel's `Line` notch still fires once, as before.
/// One `OnMouseWheel` call per whole notch, `arg1 = ±1`, the fraction carried in `notches`: a
/// trackpad gesture arrives as a `Pixel` trickle, and the stock handlers act on the sign alone
/// (`ScrollFrameTemplate_OnMouseWheel` scrolls half a pane per call, `UIPanelTemplates.lua:150`).
fn feed_wheel(
script: &mut UiScript,
notches: &mut WheelNotches,
@@ -81,50 +64,33 @@ fn feed_wheel(
}
}
/// Feed the window's cursor + buttons + wheel + keyboard into the UI engine (after
/// [`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 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.
/// Feeds cursor, buttons, wheel and keys into the UI engine, publishing [`PlayerUiHover`] (the
/// pointer arbiter yields to the UI) and [`UiKeyboardCapture`] (a key a box or frame ate never
/// fires its binding). Runs in the `UiInput` set, before `WorldStage::Input` and every key reader.
pub(super) fn feed_ui_input(
script: Option<NonSendMut<UiScript>>,
// The window's raw handle rides along with it: on Wayland it carries the `wl_display` the
// clipboard backend is built from (decision 0702). `Option`, because it only appears once
// winit has actually created the surface.
// Carries the `wl_display` the Wayland clipboard backend needs, once winit has a surface.
window: Query<(&Window, Option<&bevy::window::RawHandleWrapper>), With<PrimaryWindow>>,
buttons: Res<ButtonInput<MouseButton>>,
// The frame's wheel travel, and the fraction of a notch carried between frames
// ([`feed_wheel`]) — one param for clippy's argument ceiling.
// The wheel travel and its carried notch fraction, one param for clippy's argument ceiling.
(scroll, mut notches): (Res<AccumulatedMouseScroll>, ResMut<WheelNotches>),
// One [`PointerFeed`] (clippy's argument ceiling): the hover + click-consumed outputs this
// pass writes, the world pick that routes the world-click payload legs (decision 0571), and
// the payload-held mirror written for the Send-side world-click consumers.
mut pointer: PointerFeed,
// Bundled into one param (clippy's argument ceiling): this frame's key messages, the modifier
// mirror, the capture gate the keyboard feed writes, and the held OS pasteboard the three
// clipboard chords resolve against (decision 0702).
// One param for clippy's argument ceiling, with the pasteboard the clipboard chords use.
mut kbd: (
MessageReader<KeyboardInput>,
Res<ButtonInput<KeyCode>>,
ResMut<UiKeyboardCapture>,
NonSendMut<HostClipboard>,
),
// The uiScale dial folded into the seam scale (decision 0584).
// The uiScale dial folded into the seam scale.
ui_scale: Res<super::UiScaleCvar>,
) {
let (keyboard, keys, capture, clipboard) = (&mut kbd.0, &kbd.1, &mut kbd.2, &mut kbd.3);
let world_pick = pointer.world_pick();
let ui_hidden = pointer.hidden.0;
// A headless probe is driving the pointer itself this frame (the drag probe). It takes the
// WHOLE mouse half out — including the else-arm below, which is the half that matters: a
// `pointer_left_window` between the synthetic press and the synthetic release would disarm
// the gesture the probe is in the middle of.
// The two reasons the OS pointer is not ours this frame: a probe is driving a gesture through
// the real pointer path, or this is a capture (where it is never ours). Same treatment — skip
// the mouse half whole, touch nothing — so they read as one condition here.
// The OS pointer is not ours while a probe drives a gesture through the real pointer path or a
// capture pins it: skip the mouse half whole, else-arm included, whose `pointer_left_window`
// would disarm the probe's gesture between its press and release.
let synthetic = pointer.synthetic.0 || pointer.capture_pinned.0;
let (hover, click_consumed, payload_held) = (
&mut pointer.hover,
@@ -146,52 +112,33 @@ pub(super) fn feed_ui_input(
payload_held.0 = false;
return;
};
// `Some` only on a Wayland session — the signal the clipboard backend picks itself by.
let wl_display = textinput::wayland_display(raw_handle);
// The engine's world-drop routing (decisions 0571 + 0574): an object pick keeps every
// payload (the reference's object leg dispatches SELECT with the item still held), terrain
// drops items only, nothing drops any arm.
// The pick routes the world drop: an object keeps the payload, terrain or sky drops it.
script.set_world_pick(world_pick);
// ── Modifiers ── pushed BEFORE the mouse feed: a click handler's modifier fork
// (`IsShiftKeyDown` — the reference's shift-split/ctrl-dressup/shift-pickup) reads the state
// as of the click, not last frame's.
// ── Modifiers ── before the mouse feed, so a click's `IsShiftKeyDown` sees the click's state.
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
let ctrl = keys.pressed(KeyCode::ControlLeft) || keys.pressed(KeyCode::ControlRight);
let alt = keys.pressed(KeyCode::AltLeft) || keys.pressed(KeyCode::AltRight);
script.set_modifiers(shift, ctrl, alt);
// ── Mouse ── (cursor-off-window only skips the mouse feed; keyboard still flows to a focused box)
// A UI hidden by TOGGLEUI takes the same route as a cursor outside the window: an action bar
// 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.
// ── Mouse ── A cursor off the window, or a UI hidden by TOGGLEUI, skips only the mouse feed.
// The headless hover probe's aim stands in for a missing cursor, so `PointerOverUi` rises and
// falls over a panel in an automated run as it does for a person; a real pointer always wins.
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
// (decisions 0582 + 0584's uiScale dial) — flip through the window height, then ÷s into
// the VM's space (the inverse of the extract seam's ×s).
// The window cursor is logical px, y-down from the top left; the UI is y-up in 768-high
// units under uiScale: flip through the window height, then undo the extract seam's scale.
let s = super::seam_scale(window.height(), ui_scale.0);
let (x, y) = (cursor.x / s, (window.height() - cursor.y) / s);
// `WOW_HIT_COST=1` — the hit-test lane's own meter (the resolve-lap report's W9): this
// call rebuilds `order::traversal` + sort + the scroll-clip map EVERY frame the cursor
// is inside the window, moving or not — a real-play cost every parked probe leg is
// structurally blind to (probes park the cursor outside). One aggregate line per
// second, census-meter posture; the number decides whether the epoch-gated skip is
// worth building, before it is built.
// `WOW_HIT_COST=1` meters this call's per-frame hit-test rebuild, one line a second.
static HIT_COST: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
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);
// 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.
// The world frame is mouse-enabled, so an addon's handlers can hover it, but its hit is
// the world's: 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));
@@ -221,15 +168,10 @@ pub(super) fn feed_ui_input(
(MouseButton::Middle, "MiddleButton"),
] {
if buttons.just_pressed(btn) {
// A LEFT press that would complete as a world DROP belongs to the drop flow
// (the drop itself fires on the completed click's RELEASE — 0218's
// byte-verified trigger — but the press is when the world click-pick and
// camera orbit-start would act, so they must yield now, exactly as for a
// hovered click). "Would drop" mirrors `world_drop_click`'s pick routing
// (decisions 0571 + 0574, amended by 0843): ANY payload over terrain/nothing
// drops (the item's popup, the spell/action's silent dismiss). Only a payload
// over an OBJECT is not consumed — the reference runs SELECT with the payload
// still held, so that click must reach the world.
// A left press that would drop into the world (`world_drop_click`: any payload over
// terrain or nothing) is consumed now: the drop fires on the release, but the world
// click-pick and camera orbit act on the press. Over an object the reference runs
// SELECT with the payload still held.
if btn == MouseButton::Left && hover.0.is_none() {
use benilla_ui::script::WorldPick;
let would_drop =
@@ -246,42 +188,27 @@ pub(super) fn feed_ui_input(
}
feed_wheel(&mut script, &mut notches, x, y, &scroll);
} else if !synthetic {
// The OS pointer left the window: leave whatever frame was hovered (once, on the
// Some→None transition) and — every frame it stays outside — clear any armed press/drag
// (`UiScript::pointer_left_window`), since no release is ever fed to end it and a stale
// gesture would fire a spurious `OnDragStart`/`OnClick` on re-entry.
// The OS pointer left: leave the hovered frame once, and disarm any press or drag every
// frame, since no release ends it and a stale gesture would fire on re-entry.
if hover.0.take().is_some() {
script.mouse_move(f32::MIN, f32::MIN);
}
script.pointer_left_window();
}
// ── Keyboard capture gate ── read AFTER the mouse feed (a LeftButton click may have just focused a
// 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.
// ── Keyboard capture gate ── read after the mouse feed (a click may have just focused a box)
// and before the keys (an Escape that clears focus still counts as captured this frame).
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 ────────────────────────────────────────────────────────────────
// 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
// at `0x77b1c4` and the strata walk carries the key down to `CGWorldFrame`, which runs its
// binding. That is what lets you turn while the chat box has focus — and the reference's own
// chat box ships the flag, so this is the default experience, not an edge case.
//
// Both terms are whole-frame (one focused box; ALT off the same modifier mirror), so the
// exemption is too. Only the four arrows may use it: a focused box swallows every other key
// on every other path (`0x77b35e` returns 1).
// An EditBox in alt-arrow mode (`ignoreArrows`, set on the chat box at `ChatFrame.xml:21`)
// passes the arrows on unless ALT is held: its handler returns 0 at `0x77b1c4` and the
// binding runs at `CGWorldFrame`; a focused box swallows any other key (`0x77b35e` returns 1).
capture.arrows_fall_through = !alt && script.editbox_alt_arrow_mode();
// ── Keyboard → VM ── the three box-event keys route to `key_input` by name (and never also
// as text, so Enter isn't a stray newline); every *editing* key goes through the per-OS
// chord table ([`keymap::chord`]) and reaches the box as a semantic `EditAction` — or as
// one of the clipboard operations, handled here (this is a NonSend, main-thread system —
// required for the macOS NSPasteboard). What's left goes to `char_input`, minus
// command-modified chars (Cmd/Ctrl+letter must never type the letter — except Ctrl+Alt, the
// AltGr plane European layouts type real characters with). Repeats carry `state == Pressed`,
// so held keys repeat. Modifiers reuse the mirror read above (the message carries none).
// ── Keyboard → VM ── box-event keys go to `key_input` by name, editing keys through the
// per-OS chord table as an `EditAction` or a clipboard operation (the macOS NSPasteboard
// needs this main-thread system), the rest to `char_input`. Repeats arrive as `Pressed`.
let sup = keys.pressed(KeyCode::SuperLeft) || keys.pressed(KeyCode::SuperRight);
let mods = keymap::Mods {
shift,
@@ -300,17 +227,11 @@ pub(super) fn feed_ui_input(
KeyCode::Tab => Some("TAB"),
_ => None,
};
// The editing keys/chords — dispatched unconditionally like the named keys (the engine's
// route law decides; unfocused they return unconsumed, and the camera/turn keys that
// read arrows after UiInput see them exactly as before).
// Dispatched unconditionally: unfocused, they fall through to the camera and turn keys.
let chord = keymap::chord(ev.key_code, mods, mac);
// A KEYBOARD FRAME gets these by NAME before their chord runs (decision 1319). They are
// the keys a focused EditBox receives as a semantic `EditAction` instead of a name, so
// they never reached `key_input` at all — and a dialog you type into needs its BACKSPACE.
// `frame_key_input` walks in the engine's own order and declines at a focused box, so the
// box's editing keys can never be stolen by a frame below it; a `true` here means a frame
// consumed the key, which suppresses BOTH the chord below and the key's binding (the
// reference's existence gate, `0x76b7d0`).
// A keyboard frame gets these by name before their chord runs (a dialog needs BACKSPACE);
// `frame_key_input` declines at a focused box, so no frame steals its editing keys. `true`
// suppresses the chord and the key's binding (the reference's existence gate, `0x76b7d0`).
let frame_named = match ev.key_code {
KeyCode::Backspace => Some("BACKSPACE"),
KeyCode::Delete => Some("DELETE"),
@@ -324,31 +245,19 @@ pub(super) fn feed_ui_input(
};
if let Some(name) = frame_named {
if script.frame_key_input(name) {
// 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).
// Not `typing`: a frame eating one key is not a box taking focus, and treating it
// as one would stop a held movement key.
capture.consumed.push(ev.key_code);
continue;
}
}
// EVERY OTHER KEY reaches a keyboard frame by name too. The reference's key-down walk
// carries the whole key table — its `arg1` comes from the *same* table the keybinding
// chord uses (`0x4b66b0`), which is [`chord::key_token`] here — and the frame's gate is
// EXISTENCE, not handling: a shown keyboard frame with an `OnKeyDown` swallows the key
// whatever its script does with it (`0x76b7d0`, `0x76ba25`). This host used to feed only
// the ten names above, so `CinematicFrame` — fullscreen, keyboard-enabled, an `OnKeyDown`
// that answers ESCAPE — consumed ESC and let W straight through, and the player walked
// around underneath their own intro cinematic.
//
// The reference's own Lua is the proof this is the law and not an over-reading: that
// 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 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).
// Every other key reaches a keyboard frame by name too: the reference's key-down walk
// takes its `arg1` from the table the binding chord uses (`0x4b66b0`), `chord::key_token`
// here, and the gate is existence, not handling: a shown keyboard frame with an
// `OnKeyDown` swallows the key whatever its script does (`0x76b7d0`, `0x76ba25`).
// Consumption suppresses only the key's binding: `OnChar` is a separate dispatcher
// (`0x765df0`), so the stack-split spinner still gets a digit its `OnKeyDown` ate, and it
// is not a focus change, so it releases nothing held.
else if named.is_none() {
if let Some(token) = crate::bindings::chord::key_token(ev.key_code) {
if script.frame_key_input(token) {
@@ -357,24 +266,15 @@ pub(super) fn feed_ui_input(
}
}
if let Some(name) = named {
// The three box-event keys. An unconsumed press is not acted on here: every GAME
// action of a key — ESCAPE's close/cancel ladder (TOGGLEGAMEMENU), TAB's targeting,
// ENTER's chat open, the number row's action buttons — now lives in the binding
// 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 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).
// An unconsumed press is not acted on here: ESCAPE's close ladder, TAB's targeting and
// ENTER's chat open live in the binding dispatch (`crate::bindings`), which runs after
// this pass and reads the capture gate.
if script.key_input(name) {
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
// at all, which is the reference's `return 0`. It must not be turned into an
// `EditAction` first — `Move { unit: Edge }` reaches the box from HOME/END as well as
// from an arrow, and only the arrow is declined.
// The gate is on the key, not the action: a gated arrow never reaches the box (the
// reference's `return 0`), while HOME/END, which also make `Move { unit: Edge }`, do.
let gated_arrow = capture.arrows_fall_through
&& matches!(
ev.key_code,
@@ -390,10 +290,8 @@ pub(super) fn feed_ui_input(
// Declined: the key falls through to its binding, which `bindings.rs` allows
// because `arrows_fall_through` exempts exactly these four from the typing gate.
keymap::Chord::Edit(_) => {}
// The clipboard trio needs the OS pasteboard, so it resolves here against the held
// [`HostClipboard`]: copy/cut pull the selection out of the box
// (`0x77e1d0` — selection required; a password box yields its mask run, never the
// real text), paste sanitizes+inserts.
// Copy and cut need a selection (`0x77e1d0`), and a password box yields its mask
// run, never the real text; paste sanitizes and inserts.
keymap::Chord::Copy => {
if let Some(text) = script.editbox_copy() {
clipboard.write(wl_display, &text);
@@ -411,14 +309,11 @@ pub(super) fn feed_ui_input(
}
}
} else if !(sup || (ctrl && !alt)) {
// Plain character input. Command-modified chars never insert (an unbound Cmd/Ctrl
// chord like Cmd+L must not type "l") — but Ctrl+Alt passes: that's AltGr, the char
// plane European layouts type real text with (and macOS Option+letter comes through
// with only `alt`, composing its special characters).
// Command-modified chars never insert (Cmd+L must not type "l"), but Ctrl+Alt passes:
// that is AltGr, and macOS Option+letter arrives with only `alt`.
if let Some(text) = &ev.text {
// Same suppression as the named keys, and this is the case that bites: the number
// 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).
// Same suppression as the named keys: the number row is a binding, so a digit typed
// into a keyboard frame (the stack-split spinner) must not also fire an action.
if script.char_input(text) {
capture.consumed.push(ev.key_code);
}
@@ -426,9 +321,8 @@ pub(super) fn feed_ui_input(
}
}
// The payload-held mirror, written LAST (after the mouse + keyboard feeds, so a same-frame
// pickup or an ESC clear is already reflected) — the Send-side view the world-click
// consumers read (decision 0571's no-payload-gated deselect).
// The payload-held mirror, written last so a same-frame pickup or ESC clear shows: the
// Send-side view the world-click deselect gates on.
payload_held.0 = script.cursor_payload().is_some();
for err in script.take_errors() {
@@ -436,18 +330,11 @@ pub(super) fn feed_ui_input(
}
}
// The two hardcoded key tables that used to live here — the ACTIONBUTTON number row and the
// bare-key window toggles — moved into the command registry (`crate::bindings::commands`,
// decision 0997): one chord→command table, rebindable, with 0585's modifier law enforced once
// in the dispatch instead of per branch here.
#[cfg(test)]
mod tests {
use super::*;
use bevy::input::mouse::MouseScrollUnit;
/// A bare VM with one wheel-enabled pane at the screen's centre whose `OnMouseWheel` counts
/// its calls and sums its `arg1`s.
fn wheel_pane() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -481,10 +368,6 @@ mod tests {
}
}
/// **A gentle trackpad swipe is not a notch a frame.** Ten frames of a small `Pixel` delta —
/// a tenth of a line apiece at Bevy's factor, one line in total — must fire the pane's
/// `OnMouseWheel` at most the ONE notch they add up to. Fed raw, it fired ten times, and a
/// stock `ScrollFrameTemplate_OnMouseWheel` moves half a pane per call off the sign alone.
#[test]
fn a_trackpad_trickle_fires_only_the_notches_it_adds_up_to() {
let mut s = wheel_pane();
@@ -507,7 +390,6 @@ mod tests {
assert!(sum <= 1.0, "…and they may move at most one notch: {sum}");
}
/// A mouse wheel's notch arrives as one `Line` delta and fires exactly once, `arg1 = 1`.
#[test]
fn a_line_notch_fires_once() {
let mut s = wheel_pane();
+53 -138
View File
@@ -1,22 +1,6 @@
//! The shipped **inspect window** driven end-to-end, engine-only (no Bevy): the real
//! 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:
//!
//! 1. **The range gate really gates.** `InspectUnit` out of range must not open the window. A
//! `CanInspect` that always answered truthy would look perfectly fine in play — you would just
//! be able to inspect across the zone — so the boundary is asserted at the verified thresholds,
//! including the strict-vs-non-strict asymmetry between the two predicates.
//! 2. **The slots come from the inspected unit, not from us.** The whole slice is one router change
//! (`Model::inv_slot`); if it fell back to the self feed, the window would show *your* gear on
//! someone else's doll — plausible-looking and completely wrong. So the two sources are fed
//! deliberately different items and the doll is checked against the foreign one.
//! 3. **The window's own lifecycle.** Open/close sounds, the level line off the target snapshot, the
//! `UNIT_INVENTORY_CHANGED` repaint, the rotate buttons moving the booth yaw, and
//! `ClearInspectPlayer` firing on hide (without which the app would keep resolving a closed
//! window's target forever).
//! The stock `Blizzard_InspectUI` window, engine-only, fed a synthetic target and a foreign
//! equipment view: the range gate at its thresholds, the doll reading the inspected unit's gear
//! rather than ours, and the window's own lifecycle.
use std::collections::HashMap;
@@ -25,9 +9,8 @@ use benilla_ui::script::{
UnitState,
};
/// A reach entry for a live, inspectable unit at squared distance `d2` — the ordinary case. The
/// `inspectable` half is `CanInspect`'s alone (the app folds vmangos's non-distance refusals into
/// it); every test in this file is about distance, so it stays true.
/// A live, inspectable unit at squared distance `dist_sq`. `inspectable` carries the server's
/// non-distance refusals and only `CanInspect` reads it; these tests are about distance.
fn reach(dist_sq: f64) -> UnitReach {
UnitReach {
dist_sq,
@@ -56,8 +39,8 @@ fn target_unit() -> UnitState {
}
}
/// A slot view as the inspect feed builds one: entry + icon + name + quality, and nothing an item
/// *object* would have supplied (no count>1, no durability, no lock) — see `ui_inspect`'s module doc.
/// A slot view as the inspect feed builds one: entry, icon, name and quality, with nothing an item
/// object would add (no stack count, durability or lock).
fn foreign_slot(entry: u32, icon: &str, name: &str) -> InvSlotView {
InvSlotView {
item_id: entry,
@@ -70,7 +53,7 @@ fn foreign_slot(entry: u32, icon: &str, name: &str) -> InvSlotView {
}
}
/// The inspected unit's equipment: a helm in the head slot (live-API id 1) and nothing else.
/// The inspected unit's equipment: a helm in the head slot (id 1) and nothing else.
fn inspect_view(unit: &str) -> InspectView {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(foreign_slot(
@@ -85,7 +68,7 @@ fn inspect_view(unit: &str) -> InspectView {
}
}
/// OUR equipment, deliberately a different item in the same slot — the router's falsifier.
/// Our own equipment: a different helm in the same slot, so a doll reading the wrong source shows.
fn own_slots() -> InventorySlots {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(foreign_slot(
@@ -96,10 +79,7 @@ fn own_slots() -> InventorySlots {
slots
}
/// Does a texture whose path contains `needle` actually get DRAWN this frame? Regions carry no Lua
/// `GetTexture` in this engine, so the icon assertions go through the render pass exactly as
/// `character_tests.rs`'s do — which also makes them a stronger claim (it renders, not merely
/// "the state says so").
/// Whether a texture whose path contains `needle` is drawn this frame.
fn drawn(s: &mut UiScript, needle: &str) -> bool {
s.resolve();
s.extract().iter().any(
@@ -111,8 +91,7 @@ fn drawn(s: &mut UiScript, needle: &str) -> bool {
fn armed() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// `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).
// `PLAYER_LEVEL`, the template the stock level line formats through.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
@@ -120,78 +99,54 @@ fn armed() -> UiScript {
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, "ScrollTemplates.xml"); // our scroll kit
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).
// The inspect window's tabs inherit its tab template.
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, "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).
// The stock slot buttons inherit `ItemButtonTemplate` — where their `$parentIconTexture`
// comes from, which `InspectPaperDollItemSlotButton_OnLoad` indexes unconditionally. Our own
// slot template declared its icon inline and needed no such file; the manifest has carried
// this entry far above the inspect window all along (decision 1832).
// Before the window, as `inherits=` resolves at load: the slot buttons inherit
// `ItemButtonTemplate`, the honor page the honor row templates `HonorFrame.xml` brings.
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.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).
// A LoadOnDemand addon, seated off the chain as a registry row and loaded by the stock
// `InspectFrame_LoadUI` (`UIParent.lua:170`), which `InspectUnit` calls (`UIParent.lua:223`).
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.
// 4 yards away (d² = 16), inside the 10-yard gate (100.0).
s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s
}
/// The loader itself: the window and its 19 slots + model pane materialize with no errors.
/// The window, its 19 slots and the model pane load with no errors.
#[test]
fn shipped_inspect_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
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).
// The files `armed` loads, in the same order.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
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, "ScrollTemplates.xml");
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, "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).
// The stock slot buttons inherit `ItemButtonTemplate` — where their `$parentIconTexture`
// comes from, which `InspectPaperDollItemSlotButton_OnLoad` indexes unconditionally. Our own
// slot template declared its icon inline and needed no such file; the manifest has carried
// 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");
// 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).
// Each slot carries its `GetInventorySlotInfo` id (1..=19, no ammo slot) as its frame ID, set
// by the stock `InspectPaperDollItemSlotButton_OnLoad`.
for (name, id) in [
("InspectHeadSlot", 1),
("InspectBackSlot", 15),
@@ -207,13 +162,12 @@ fn shipped_inspect_frame_loads_clean() {
}
}
/// **The gate really gates.** Out of range, `InspectUnit` sends nothing and opens nothing; the same
/// call in range does both. A `CanInspect` stuck truthy passes every other test in this file.
/// Out of range, `InspectUnit` neither requests nor opens; in range it does both.
#[test]
fn inspect_unit_refuses_out_of_range() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = armed();
// 11 yards (d² = 121) — past the verified 100.0 threshold.
// 11 yards (d² = 121), past the 100.0 threshold.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(121.0))]));
s.run(r#"InspectUnit("target")"#).unwrap();
@@ -227,7 +181,6 @@ fn inspect_unit_refuses_out_of_range() {
"out of range: no CMSG_INSPECT request is queued"
);
// Step inside the threshold: the same call now opens and requests.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(99.9))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(
@@ -241,11 +194,8 @@ fn inspect_unit_refuses_out_of_range() {
);
}
/// The two verified thresholds and their **different operators** — `CanInspect` refuses only on a
/// strict `100.0 < d²` (so exactly 100.0 is IN range), while `CheckInteractDistance` admits only on
/// a strict `d² < table[type-1]` (so exactly 100.0 is OUT). That asymmetry is the binary's own
/// (`test ah,0x41; jne` at `0x48a28b` vs `test ah,0x5; jp` at `0x48bb0a`), and it is the kind of
/// detail a rewrite silently normalizes — hence a test on the boundary itself.
/// `CanInspect` refuses only when `100.0 < d²` (`0x48a28b`), so exactly 100.0 is in range;
/// `CheckInteractDistance` admits only when `d² < table[type - 1]` (`0x48bb0a`), so 100.0 is out.
#[test]
fn range_predicates_transcribe_the_verified_thresholds() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -269,7 +219,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
"CheckInteractDistance(type 1) at d²={d2}"
);
}
// Type 4 is the 30-yard row (900.0) — the table is indexed, not hardcoded to one distance.
// Type 4 is the 30-yard row (900.0).
s.set_unit_reach(HashMap::from([("target".to_string(), reach(899.0))]));
assert!(s
.eval::<bool>(r#"return CheckInteractDistance("target", 4) ~= nil"#)
@@ -279,11 +229,8 @@ fn range_predicates_transcribe_the_verified_thresholds() {
.unwrap(),
"the same distance is out of range for the 10-yard type"
);
// **A token the object manager holds no unit for answers nil — the null-object arm** (report
// B316, `0x48babe`). This is the party member outside the local
// area: the roster wire gives them a GUID, so the token resolves, and the object lookup then
// misses silently. It used to answer 1, which lit every distance row for exactly the member
// who was furthest away.
// A token with no unit in the object manager answers nil (`0x48babe`): a party member outside
// the local area has a GUID from the roster but no object.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("party3", 1) == nil"#)
.unwrap(),
@@ -295,7 +242,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
"…and CanInspect agrees, through its own null-`this` tail"
);
// The `type` argument's three degenerate arms, each the binary's own answer.
// The `type` argument's degenerate arms, each the reference's answer.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(1.0))]));
for bad in ["0", "5", "-1", "0.5"] {
assert!(
@@ -306,22 +253,20 @@ fn range_predicates_transcribe_the_verified_thresholds() {
"type {bad} is outside the table (unsigned compare on trunc(type) − 1)"
);
}
// …but a fractional type INSIDE the range truncates toward zero and answers its row.
// A fractional type inside the range truncates toward zero and answers its row.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("target", 1.9) ~= nil"#)
.unwrap(),
"1.9 chops to 1"
);
// A missing `type` is a usage ERROR, not nil — the reference's `luaL_error`, which longjmps.
// A missing `type` is a usage error, not nil: the reference raises through `luaL_error`.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("target") ~= nil"#)
.is_err(),
"no distIndex is a script error"
);
// The token is CASE-FOLDED, like every compare in the resolver both predicates reach their
// unit through (`_strnicmp`, 1247) — the same fold `UnitName("Target")` already gets. An
// addon that capitalises must not be told the unit is out of range.
// The token is case-folded, as every compare in the unit resolver is (`_strnicmp`).
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("TARGET", 1) ~= nil"#)
.unwrap(),
@@ -334,8 +279,7 @@ fn range_predicates_transcribe_the_verified_thresholds() {
);
}
/// **The doll shows THEIR gear, not ours.** Both sources are fed, with different items in the same
/// slot; the inspected token must read the foreign one and `"player"` must still read ours.
/// The inspected token reads the foreign view, and `"player"` still reads our own equipment.
#[test]
fn inventory_bindings_route_by_unit_token() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -358,8 +302,7 @@ fn inventory_bindings_route_by_unit_token() {
assert!(s
.eval::<bool>(r#"return GetInventoryItemTexture("party2", 1) == nil"#)
.unwrap());
// The inspect view is keyed on the token it was built for: if the window rebinds to another
// token, the stale view must not answer for it.
// The view answers only for the token it was built for.
s.set_inspect(Some(inspect_view("party1")));
assert!(
s.eval::<bool>(r#"return GetInventoryItemTexture("target", 1) == nil"#)
@@ -368,19 +311,16 @@ fn inventory_bindings_route_by_unit_token() {
);
}
/// The whole window contract in one drive: open through `ShowUIPanel` with the ref's sound, the
/// name/level lines off the target snapshot, the slot icon painted from the foreign view, an empty
/// slot falling back to its paper-doll art, the `UNIT_INVENTORY_CHANGED` repaint, the rotate buttons
/// moving the booth yaw, and closing firing `ClearInspectPlayer`.
/// One drive through the window: the open sound, the name and level lines, the slot icons, the
/// `UNIT_INVENTORY_CHANGED` repaint, the rotate buttons and `ClearInspectPlayer` on close.
#[test]
fn shipped_inspect_frame_drives_end_to_end() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = armed();
// Hidden at load, and no sound queued (never transitions on startup).
assert!(!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
assert!(s.take_sounds().is_empty());
// The pane's OnLoad set the ref's own default facing (InspectModelFrame_OnLoad, 0.61).
// `InspectModelFrame_OnLoad` sets the default facing, 0.61.
assert!(
(s.model_pane_facing("InspectModelFrame") - 0.61).abs() < 0.0001,
"default facing 0.61, got {}",
@@ -417,8 +357,7 @@ fn shipped_inspect_frame_drives_end_to_end() {
"an empty slot falls back to its own paper-doll art"
);
// The repaint path: the feed pushes a changed view and fires the event the ref's slot buttons
// listen for (filtered on arg1 == the inspected unit).
// The feed pushes a changed view and fires the event the slot buttons filter on `arg1`.
let mut swapped = inspect_view("target");
swapped.slots[1] = Some(foreign_slot(
7366,
@@ -435,7 +374,6 @@ fn shipped_inspect_frame_drives_end_to_end() {
"UNIT_INVENTORY_CHANGED for the inspected unit repaints the slot"
);
// An event for somebody else must NOT repaint (the ref's arg1 filter).
let mut other = inspect_view("target");
other.slots[1] = Some(foreign_slot(1, "Interface\\Icons\\INV_Helmet_ZZ", "Nope"));
s.set_inspect(Some(other));
@@ -448,8 +386,7 @@ fn shipped_inspect_frame_drives_end_to_end() {
"an event for another unit leaves the doll alone"
);
// Rotate: the ref's sign convention (left subtracts, right adds 0.03) onto the booth yaw, with
// the kit each click.
// Rotate: left subtracts 0.03 from the booth yaw and right adds it, each click with its sound.
s.run("InspectModelRotateLeftButton:Click()").unwrap();
assert!(
(s.model_pane_facing("InspectModelFrame") - 0.58).abs() < 0.0001,
@@ -473,8 +410,7 @@ fn shipped_inspect_frame_drives_end_to_end() {
"each rotate click plays the kit"
);
// Closing plays the close sound AND tells the app to stop inspecting — without the latter the
// feed would keep resolving a closed window's target forever.
// Closing plays its sound and calls `ClearInspectPlayer`, which stops the app's inspect feed.
assert!(
!s.take_inspect_clear(),
"ClearInspectPlayer not called while open"
@@ -493,14 +429,9 @@ fn shipped_inspect_frame_drives_end_to_end() {
assert!(s.errors().is_empty(), "close errors: {:?}", s.errors());
}
/// The tab chrome, and where the ref's "toggle the showing page shut" branch actually lives.
///
/// `PanelTemplates_SelectTab` **disables** the selected tab button (`UiPanels.xml`, the ref's own
/// UIPanelTemplates), so the SELECTED tab is inert from the moment it opens — clicking it
/// cannot close anything. `ToggleInspect`'s close-when-already-showing branch
/// (`Blizzard_InspectUI.lua:67-83`) is therefore reachable only by calling `ToggleInspect` directly,
/// which is how a keybinding would. Both halves asserted, because the first one is what makes the
/// second one non-obvious.
/// `PanelTemplates_SelectTab` disables the selected tab (`UIPanelTemplates.lua:125`), so clicking
/// it does nothing; `ToggleInspect`'s close-when-showing branch (`Blizzard_InspectUI.lua:67-83`)
/// is reached only by calling it directly.
#[test]
fn the_active_tab_is_inert_and_toggle_inspect_closes() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -508,7 +439,7 @@ fn the_active_tab_is_inert_and_toggle_inspect_closes() {
s.run(r#"InspectUnit("target")"#).unwrap();
let _ = s.take_sounds();
// NB this engine's `IsEnabled` returns a plain bool, not the live API's 1/nil.
// `IsEnabled` answers the number 1 or 0 (`0x7800b0`), and 0 is truthy in Lua.
assert!(
!s.eval::<bool>("return InspectFrameTab1:IsEnabled() ~= 0")
.unwrap(),
@@ -520,7 +451,6 @@ fn the_active_tab_is_inert_and_toggle_inspect_closes() {
"clicking the disabled active tab changes nothing"
);
// The reachable path: ToggleInspect on the page already showing closes the window.
s.run(r#"ToggleInspect("InspectPaperDollFrame")"#).unwrap();
assert!(
!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
@@ -529,25 +459,11 @@ fn the_active_tab_is_inert_and_toggle_inspect_closes() {
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
}
/// The other branch of `ToggleInspect` — re-opening after a close — **errors, and that is the
/// reference's own behaviour** (decision 1834 settled what 1832 had to leave open).
///
/// `InspectFrame_OnHide` sets `this.unit = nil`, so a re-open runs `InspectFrame_OnShow` with no
/// token. In the reference, `SetPortraitTexture 0x519ef0` gates its unit
/// argument at `0x519fb4` with `lua_isstring` and raises `Usage: SetPortraitTexture(texture,
/// "unit")`, and `luaL_error` does not return — so the reference raises here too, on its own
/// shipped code. The widget clear sits six instructions PAST the gate, so the portrait is not even
/// blanked; it is left exactly as it was.
///
/// In the reference the raise unwinds to the handler's `lua_pcall`, abandons the rest of `OnShow`,
/// and pops the ScriptErrors dialog through `_ERRORMESSAGE`. Ours collects it into `errors` and
/// abandons the handler the same way. So: the window shows, the portrait and name do not update,
/// and there is exactly one error — which is what this now asserts.
///
/// One correction worth keeping: stock 1.12 ships **no** inspect keybind (0 of 234
/// `Binding name=` entries) and no caller of `ToggleInspect` outside the addon's own tab buttons,
/// which are children of the hidden `InspectFrame`. So on the stock UI's own paths this is
/// unreachable — only an addon or a macro gets here. It is still ours to reproduce.
/// Re-opening through `ToggleInspect` after a close raises, as in the reference:
/// `InspectFrame_OnHide` clears `this.unit`, so `OnShow` runs with no token, and
/// `SetPortraitTexture` (`0x519ef0`) raises its usage error at its `lua_isstring` gate
/// (`0x519fb4`), before the portrait is touched. Stock 1.12 has no inspect binding and no caller
/// outside the hidden window's own tabs, so only an addon or a macro gets here.
#[test]
fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -562,9 +478,8 @@ fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"the window still shows — Show() runs before OnShow raises"
);
// TWO raises, not one: `InspectFrame` and `InspectPaperDollFrame` each have their own OnShow,
// each fired under its own pcall, so each abandons independently — the first at the portrait's
// gate, the second at `UnitLevel`'s. Both carry the reference's own `Usage:` text.
// Two raises: `InspectFrame` and `InspectPaperDollFrame` each run their own `OnShow` under
// their own pcall, the first dying at the portrait's gate, the second at `UnitLevel`'s.
let errors = s.errors();
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
assert!(
@@ -1,24 +1,16 @@
//! The instance-lockout **Era surface** (decision 1748): the three engine bindings, and the one
//! place in the shipped UI that reads them — the SELF menu's "Reset all instances" row and the
//! `CONFIRM_RESET_INSTANCES` dialog behind it.
//!
//! The four chat lines the family's other packets raise are engine-composed and have no Lua at
//! all (no FrameXML file mentions `RAID_INSTANCE_WELCOME` or its siblings); they are tested at the
//! composer, in `crate::ui_instance`.
//! The instance-lockout verbs and their one stock caller, the SELF menu's reset row and its
//! confirm. The family's chat lines have no Lua; `crate::ui_instance` composes and tests them.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// The three bindings answer with the reference's own shapes — `IsInInstance` a PAIR, both
/// readers `1`/`nil` rather than `true`/`false` (the reference pushes the double 1.0), and
/// `ResetInstances` a queue.
/// `IsInInstance` returns a pair; it and `CanShowResetInstances` answer 1 or nil, not booleans.
#[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.
assert_eq!(
s.eval::<(Option<f64>, String)>("return IsInInstance()")
.unwrap(),
@@ -31,7 +23,7 @@ fn the_three_bindings_have_the_reference_shapes() {
(1, Some(1.0), "party"),
(2, Some(1.0), "raid"),
(3, Some(1.0), "pvp"),
// Past the reference's own `cmp esi,4; jae` guard.
// Type 4 and up fall past the reference's `cmp esi,4; jae` guard.
(4, Some(1.0), "none"),
] {
s.set_instance_type(Some(ty));
@@ -62,17 +54,13 @@ fn the_three_bindings_have_the_reference_shapes() {
assert_eq!(s.take_reset_instance_asks(), 0, "the drain is a take");
}
/// The whole shipped path, through the real hit test: right-click your own portrait, the row is
/// there only while `CanShowResetInstances()` is true, clicking it raises the reference's confirm,
/// and Yes is the only thing that sends.
/// Through the real hit test: right-click the player portrait, click the row, answer the confirm.
#[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,
// `GROUP` l.2029) — production runs the player's own string table at boot.
// Verbatim 1.12 values from `GlobalStrings.lua`.
s.run(
r#"
-- The stock raid pane concatenates this into each of its eight group headers inside
@@ -95,7 +83,7 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
r"Interface\FrameXML\UIPanelTemplates.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\\LocaleProperties.lua", // `GetText`, the gender and plural lookup
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
@@ -113,9 +101,8 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
] {
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).
// Blizzard_RaidUI is LoadOnDemand: seated as an addon row, as the app does, then loaded by
// `RaidFrame_LoadUI` (`UIParent.lua:182`).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_RaidUI");
s.run("RaidFrame_LoadUI()").unwrap();
s.resolve();
@@ -136,11 +123,8 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
.collect()
};
// Off: the row is not in the menu at all — hidden, not greyed (the reference's
// `UnitPopupShown[index] = 0`). Solo, it is the ONLY row of the SELF menu that could show
// (the loot trio, Leave and the raid marks all need a party), so the menu's "nothing but
// CANCEL" early-out fires and no menu opens at all. That is the reference's behaviour too,
// and it is the sharpest possible assertion that the row is really gone.
// Off, the row is hidden, not greyed (`UnitPopup.lua:379`). Solo it is the SELF menu's only
// candidate row, so with it gone the menu does not open at all.
open_self_menu(&mut s);
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
@@ -148,7 +132,6 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
"solo with no lockout to reset, the SELF menu has nothing to show"
);
// On: the row appears, and with it the menu.
s.set_can_reset_instances(true);
open_self_menu(&mut s);
assert!(
@@ -161,7 +144,6 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
.position(|l| l == "Reset all instances")
.unwrap_or_else(|| panic!("the row shows once the binding says so: {labels:?}"));
// Clicking it sends NOTHING — it raises the confirm.
let button = format!("DropDownList1Button{}", row + 1);
let (cx, cy) = s
.eval::<(f64, f64)>(&format!("return {button}:GetCenter()"))
@@ -194,7 +176,6 @@ fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
"No"
);
// No sends nothing; Yes sends exactly one.
let (nx, ny) = s
.eval::<(f64, f64)>("return StaticPopup1Button2:GetCenter()")
.unwrap();
@@ -1,34 +1,13 @@
//! The shipped `assets/ui/KeyBindingsPage.xml` + OptionsFrame.xml's Keybindings body — the
//! Options window's Keybindings category over the engine's binding table (decision 1008,
//! superseding 0997's standalone window; the provenance block in the XML).
//!
//! What these guard: the module + the options window load clean together; the page is an
//! ordinary category (body-swapped, Defaults live, the Unbind button only here); the section
//! tree is the honest tree's non-empty categories in 1.12 `Bindings.xml` order, COLLAPSED by
//! default (the era's own state) and toggling like the era's expandable sections; the capture
//! flow — select a capsule → the host arm arms → the canonical chord lands through
//! `KeyBindings_OnHostKey` — binds LIVE-COMMIT (every mutation queues the host persist,
//! 1008's law; closing the window keeps everything), steals with the red 1.12 message only
//! when the victim goes bare, takes the wheel onto ANY command (1295 — the engine refuses
//! nothing but a key string that is not a key, and that refusal restores the old key); the
//! character-specific checkbox runs 1.12's set model with the era's
//! confirm-on-uncheck-only; search surfaces binding matches as LIVE rows under the
//! Keybindings redirect head; and the action bar's abbreviation law (`GetBindingText`
//! transcribed in UIParent.xml) reads `s-2`, the ref's own Lua.
//!
//! 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.
//! The options window's Keybindings page (`KeyBindingsPage.xml`, its body in `OptionsFrame.xml`)
//! over the engine's binding table. Deviation: it is the era Settings panel's page, because 1.12's
//! settings screens are much worse to use; capture and set follow 1.12's `Blizzard_BindingUI`.
use benilla_ui::script::keybind::{KeybindCommand, KeybindRequest};
use benilla_ui::script::{QuadContent, UiScript};
use crate::bindings::commands::SPECS;
/// The page's real neighbourhood, in the manifest's own order, with the registry seeded the
/// way the app seeds it (`crate::bindings::seed_bindings` — registration before any show).
/// The page's files in manifest order, the registry seeded first, as `seed_bindings_for_vm` does.
pub(crate) fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
let cmds: Vec<KeybindCommand> = SPECS
@@ -61,14 +40,7 @@ pub(crate) fn harness() -> UiScript {
"OptionsFrame.xml",
"GameMenuFrame.xml",
] {
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and must come off the player's chain, which
// `std::fs::read_to_string` under `assets/ui` cannot do — it goes looking for
// `assets/ui/Interface/FrameXML/...` and fails. The shared loader resolves both shapes, and
// its own doc already records this consolidation happening once before. Hand-rolling it
// here is what made this kit break the moment a file it loads migrated (1751).
// `load_ui_strict` for the files whose art actually matters here: an unknown template is
// a loader WARNING, not an error, so a silently skinless window would otherwise pass.
// Strict for ours: an unknown template only warns, so a skinless window would pass.
if file == "KeyBindingsPage.xml" || file == "OptionsFrame.xml" {
super::test_ui::load_ui_strict(&s, file);
} else {
@@ -115,8 +87,7 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
assert!(s
.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).
// The categories in `Bindings.xml` order, each a header collapsed by default, as in the era.
let mut expected: Vec<&str> = Vec::new();
for spec in SPECS {
if !expected.contains(&spec.category) {
@@ -134,9 +105,7 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
.unwrap(),
"all sections collapsed: nothing past the headers"
);
// The multibar section is real (1008): its header token sits in the tree.
assert!(expected.contains(&"BINDING_HEADER_MULTIACTIONBAR"));
// Expanding Movement puts its rows under the header, byte-real defaults on the capsules.
s.run(&format!("{ROW}1Header:Click()")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}2Description:GetText()"))
@@ -148,7 +117,7 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
.unwrap(),
label(&s, "KEY_BUTTON3", "BUTTON3")
);
// The same rows with the strings pinned by hand, so the expectation is literal 1.12 text.
// 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"
@@ -171,14 +140,13 @@ fn the_page_is_an_options_category_with_the_collapsed_honest_tree() {
.unwrap(),
"Middle Mouse"
);
// Collapse again: the next header slides back up under the first.
// Collapsing slides the next header back under the first.
s.run(&format!("{ROW}1Header:Click()")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}2HeaderText:GetText()"))
.unwrap(),
label(&s, "BINDING_HEADER_CHAT", "BINDING_HEADER_CHAT")
);
// Leaving the page hides its body and the Unbind button.
s.run(r#"BenillaOptionsFrame_SelectCategory("Controls")"#)
.unwrap();
assert!(!s
@@ -196,8 +164,8 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
let mut s = harness();
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests(); // drop any noise before the flow under test
// Row 3 is MOVEFORWARD (W, UP). Selecting its Key 1 capsule arms the host seam.
s.take_keybind_requests();
// Row 3 is MOVEFORWARD (W, UP); selecting its Key 1 capsule arms the capture.
assert!(!s.bind_capture_armed());
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(
@@ -209,13 +177,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.unwrap(),
"Unbind arms with the selection"
);
// The host hands back a canonical chord: J binds into slot 1, W's old seat; UP survives
// in slot 2; the capture disarms; the table is LIVE and the bind COMMITTED (Save queued —
// 1008's live-commit law, where 0997 waited for Okay).
//
// **`J`, not `F`** — this leg is about the plain bind, so its key has to be one nothing else
// holds, and `F` stopped being one when 1745 registered `ASSISTTARGET` on its byte-real
// default. The steal path has its own leg below (`T`), which is where a taken key belongs.
// J, a key no command holds, takes slot 1 from W; UP stays in slot 2 and the bind saves.
s.run(r#"KeyBindings_OnHostKey("J")"#).unwrap();
assert!(!s.bind_capture_armed(), "a completed bind disarms");
assert!(s
@@ -234,8 +196,8 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.unwrap(),
"Key Bound Successfully"
);
// Stealing the LAST key of another command names the victim in red (1.12's
// KEY_UNBOUND_ERROR). T is ATTACKTARGET's only key.
// T is ATTACKTARGET's only key, so taking it names the victim in red (1.12's
// `KEY_UNBOUND_ERROR`, `Blizzard_BindingUI.lua:185-190`).
s.run(&format!("{ROW}3Key2Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("T")"#).unwrap();
assert_eq!(
@@ -249,9 +211,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.contains(&victim),
"the newly-bare victim is named"
);
// **The wheel binds like any other key** (B265, decision 1295) — including onto MOVEFORWARD,
// which has press+release state. This block used to assert the opposite; `0x4b7490` never
// reads a command node, so the refusal it asserted is not in the client.
// The wheel binds onto a press+release command too: `0x4b7490` never reads the command.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("MOUSEWHEELUP")"#).unwrap();
assert!(
@@ -259,9 +219,7 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.unwrap(),
"the notch takes the slot"
);
// …and the notch STEALS the wheel from the camera, whose last key it was — so the page says
// 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.
// It was the camera zoom's last key, so the page names that in red.
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerBodyKeybindingsOutput:GetText()")
.unwrap(),
@@ -270,9 +228,8 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
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
// (1.12's KeyBindingFrame_SetBinding, its wheel-shaped message and all).
// A key string the engine rejects restores the slot's old key and shows the only refusal text
// 1.12 has, the wheel's (`KeyBindingFrame_SetBinding`, `Blizzard_BindingUI.lua:260-270`).
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("SCROLLLOCK")"#).unwrap();
assert!(
@@ -285,14 +242,12 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.unwrap(),
"Can't bind mousewheel to actions with up and down states"
);
// A right-click on the armed capsule deselects without binding.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
s.run(&format!(r#"{ROW}3Key1Button:Click("RightButton")"#))
.unwrap();
assert!(!s.bind_capture_armed(), "right-click deselects");
// Hiding the window disarms a straggling capture (the OnHide hook — a locked-out client
// otherwise: the armed seam swallows all input with no window on screen).
// Hiding the window disarms: an armed capture with no window would swallow all input.
s.run(&format!("{ROW}3Key1Button:Click()")).unwrap();
assert!(s.bind_capture_armed());
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
@@ -307,8 +262,8 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
on_page(&mut s);
s.run(&format!("{ROW}1Header:Click()")).unwrap(); // expand Movement
s.take_keybind_requests();
// JUMP is Movement's 8th command → row 9 under the header. Unbind its Key 1: SPACE goes,
// NUMPAD0 slides into slot 1 (the 1.12 slot dance), and the change commits at once.
// JUMP is Movement's 8th command, row 9. Unbinding its Key 1 (SPACE) slides NUMPAD0 into
// slot 1, as 1.12's Unbind does (`Blizzard_BindingUI.xml:507-527`).
assert_eq!(
s.eval::<String>(&format!("return {ROW}9Description:GetText()"))
.unwrap(),
@@ -322,8 +277,8 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
)
.unwrap());
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
// LIVE-COMMIT is the law now (1008): bind G, close the window — the bind KEEPS (0997's
// Cancel/ESC revert died with the standalone window).
// Deviation: a bind saves at once, as the era panel commits, where 1.12 had Okay and Cancel;
// closing the window keeps it.
s.run(&format!("{ROW}9Key1Button:Click()")).unwrap();
s.run(r#"KeyBindings_OnHostKey("G")"#).unwrap();
assert_eq!(s.take_keybind_requests(), vec![KeybindRequest::Save(1)]);
@@ -333,8 +288,7 @@ fn unbind_reset_and_the_live_commit_replace_okay_cancel() {
"JUMP",
"closing keeps the live-committed bind"
);
// Reset To Default: the page's Defaults button behind the era confirm — JUMP's real
// defaults return and the reset itself commits.
// Defaults, behind a confirm, restores JUMP's default keys and saves.
on_page(&mut s);
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
@@ -354,15 +308,12 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
// 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).
// ESC's options rung hides the window; with binds already saved there is nothing to revert.
s.run("ToggleGameMenu()").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("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
@@ -370,8 +321,8 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
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.
// Unchecking deletes the set, so the box springs back until 1.12's confirm decides; Cancel
// keeps it.
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
assert!(
@@ -388,8 +339,7 @@ fn the_esc_ladder_closes_the_window_and_the_checkbox_switches_sets() {
s.run("StaticPopup1Button2:Click()").unwrap();
assert_eq!(s.current_binding_set(), 2);
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).
// Accept: the account set loads before the save, so it does not inherit the character binds.
s.run("BenillaOptionsFrameContainerBodyKeybindingsCharacterRowCheck:Click()")
.unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
@@ -409,12 +359,10 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
s.run(r#"BINDING_NAME_JUMP = "Jump""#).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).
// "jump" matches one binding and no CVar row.
s.run(r#"BenillaOptionsFrameSearchBox:SetText("jump")"#)
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
s.tick(0.0); // the deferred OnTextChanged drains here
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
@@ -431,7 +379,6 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
assert!(!s
.eval::<bool>("return BenillaOptionsFrameContainerBodyKeybindSearch2:IsVisible()")
.unwrap());
// The result row is LIVE: its capsule arms, the bind lands and commits, the row relabels.
s.run("BenillaOptionsFrameContainerBodyKeybindSearch1Key1Button:Click()")
.unwrap();
assert!(s.bind_capture_armed());
@@ -448,10 +395,9 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
.unwrap(),
"H"
);
// The head is the era redirect: clicking it ends the search on the Keybindings page.
s.run("BenillaOptionsFrameContainerBodySearchHeadKeybindings:Click()")
.unwrap();
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
s.tick(0.0); // the deferred OnTextChanged drains here
assert_eq!(
s.eval::<String>("return BenillaOptionsFrame.selectedCategory")
.unwrap(),
@@ -470,7 +416,7 @@ fn search_surfaces_bindings_as_live_rows_under_the_redirect_head() {
fn the_action_bar_abbreviation_is_the_refs_own_getbindingtext() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// ref UIParent.lua:1819 transcribed (UIParent.xml): one modifier abbreviates…
// Stock `GetBindingText` (`UIParent.lua:1819`): one modifier abbreviates…
assert_eq!(
s.eval::<String>(r#"return GetBindingText("SHIFT-2", "KEY_", 1)"#)
.unwrap(),
@@ -487,8 +433,8 @@ fn the_action_bar_abbreviation_is_the_refs_own_getbindingtext() {
.unwrap(),
"W"
);
// …two or more collapse to the ref's dot — including the CTRL-- oddity, whose second
// dash the ref's dash-counting loop counts as a second modifier (their quirk, pinned).
// …two or more collapse to a dot, `CTRL--` included: the dash-counting loop counts its second
// dash as a modifier.
assert_eq!(
s.eval::<String>(r#"return GetBindingText("CTRL-SHIFT-2", "KEY_", 1)"#)
.unwrap(),
@@ -516,19 +462,14 @@ fn the_action_bar_abbreviation_is_the_refs_own_getbindingtext() {
.unwrap());
}
/// The wheel and the bar's seat (the director's scuff report, same day 1008 landed). The wheel
/// is the QuestLog/Trainer lesson relearned: a spin bubbles up the PARENT chain (pointer.rs),
/// so a handler on the sibling faux frame never sees it — it lives on the page body, which is
/// mouse-enabled so a spin over a row's NAME (a plain-Frame area no child claims) is caught
/// too, not just the bubbles from capsule/header Buttons. The bar rides the gutter INSIDE the
/// body: the kit hangs it 6px right of the faux frame's edge, so the frame ends at the rows'
/// own -32 — anchoring it to body-right hung the bar on the window border.
/// A spin bubbles up the parent chain, so the wheel handler sits on the mouse-enabled page body,
/// which also catches a spin over a row's name. The bar sits in the gutter inside the body.
#[test]
fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
// Every section open: 100+ flat rows — the list overflows its 19 slots and the bar shows.
// Every section open: over 100 rows overflow the list's 19 slots.
s.run(
r#"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, true) end
KeyBindingsPage_Update()"#,
@@ -539,24 +480,13 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
.eval::<bool>(&format!("return {SF}ScrollBar:IsVisible()"))
.unwrap());
s.resolve();
// The page area's OWN scroll (B217) stays out of it: this page fills the body exactly, so the
// outer range is 0 and its bar never joins this one in the gutter the two share.
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
// **B217's property is the BAR, not the range, and since 1860 those differ.** The page scroll
// now reports a nonzero range here, and that is not a defect in either half: the chain's
// `FauxScrollFrameTemplate` carries a real `<ScrollChild>` which `FauxScrollFrame_Update` sizes
// to `numItems * valueStep` (~5275px with every section open), our old template had none, and
// the range is measured by unioning the scroll child's whole SUBTREE — the reference's own
// `0x786f80` recursion, which re-enters itself for each shown child frame with no clip or
// ScrollFrame exception (decision 1338). The reference would measure
// the same; it simply never nests a faux list inside a real-scroll page, which our options
// window is alone in doing.
//
// What B217 actually cleared — a second bar in the shared gutter — still holds, and that is
// what is asserted.
// Only the list's bar shows in the shared gutter. The outer page's range is not 0, as the
// reference would measure it: the stock faux list sizes its scroll child to
// `numItems * valueStep`, and the range unions the child's whole subtree (`0x786f80`).
assert!(
!s.eval::<bool>("return BenillaOptionsFrameContainerScrollBar:IsVisible()")
.unwrap(),
@@ -580,11 +510,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
bar_right <= body_right - 8.0,
"bar inset from the body edge ({bar_right} vs body {body_right})"
);
// …wearing its TROUGH: the recessed channel (the shared kit's, shown and hidden with the bar),
// which seats ITSELF on the bar — 31 wide against the bar's 16, hung 8 units left, and
// 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.
// The trough seats itself on the bar (`BenillaScrollTrough_Seat`): 31 wide, 8 left of it,
// 21 above and 20 below, which leaves 5 units over the up arrow and 4 under the down arrow.
const TROUGH: &str = "BenillaOptionsFrameContainerBodyKeybindingsScrollFrameScrollBarTrough";
let bar = format!("{SF}ScrollBar");
let g = |f: &str, m: &str| s.eval::<f64>(&format!("return {f}:{m}()")).unwrap();
@@ -604,7 +531,6 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
(g(&format!("{bar}ScrollDownButton"), "GetBottom") - g(TROUGH, "GetBottom") - 4.0).abs()
< 0.01
);
// A list that FITS shows neither.
let set_all = |s: &UiScript, open: bool| {
s.run(&format!(
"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, {}) end
@@ -623,7 +549,7 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
set_all(&s, true);
s.resolve();
// A spin over a row NAME — the dead zone when the handler lived on the sibling.
// A spin over a row's name, which no child frame claims.
let steer = label(&s, "BINDING_NAME_MOVEANDSTEER", "MOVEANDSTEER");
let quads = s.extract();
let (wx, wy) = quads
@@ -637,9 +563,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
.expect("a MOVEANDSTEER description quad");
s.mouse_wheel(wx, wy, -1.0);
assert!(s.errors().is_empty(), "wheel over a name: {:?}", s.errors());
// The spin REACHED the list, which is what this dead zone was about. The distance is the
// reference's half-bar page (`ScrollFrameTemplate_OnMouseWheel` moves `GetHeight()/2` pixels),
// not the single row our deleted kit stepped — 1860 re-pointed that everywhere it is asserted.
// A spin moves half the bar's height (`ScrollFrameTemplate_OnMouseWheel`,
// `UIPanelTemplates.lua:150-157`), more than a row.
let after_name = s
.eval::<f64>(&format!("return FauxScrollFrame_GetOffset({SF})"))
.unwrap();
@@ -647,9 +572,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
after_name > 1.0,
"a spin over a row NAME scrolled the list by a page, got {after_name}"
);
// …and over a CAPSULE (a Button: the bubble path, capsule → row → body). GetLeft-family
// reads are scale-LOCAL (the 1.12 contract; this window wears ERA_WINDOW_SCALE) — the
// pointer lives in screen px, so the aim converts through GetEffectiveScale.
// Over a capsule the spin bubbles through the row to the body. `GetLeft` answers in the
// frame's own scale and the pointer is in screen pixels, hence `GetEffectiveScale`.
let (cx, cy) = {
let k = s
.eval::<f64>(&format!("return {ROW}2Key1Button:GetEffectiveScale()"))
@@ -676,9 +600,8 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
after_capsule > after_name,
"a spin over a CAPSULE bubbles to the page too: {after_name} -> {after_capsule}"
);
// While a capsule is armed the wheel is a BIND, never a scroll (1.12's law) — in the app
// the host seam swallows the spin before the UI sees one; the Lua guard is the belt for
// a spin that reaches the page anyway.
// While a capsule is armed a spin binds, never scrolls; the page's guard catches a spin the
// host lets through.
s.run(&format!("{ROW}2Key1Button:Click()")).unwrap();
s.mouse_wheel(wx, wy, -1.0);
assert_eq!(
@@ -691,22 +614,15 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The pet lane reaches the page** (B218 / decision 1052). 1008 registered the two multibars
/// and stopped; 1.12's `BONUSACTIONBUTTON1-10` — "Secondary Action Button" in its own window —
/// never joined, so the pet bar was the one visible bar with no way to bind it.
///
/// Pinned on both sides of the seam, because the report is about the *page*: the registry carries
/// the ten rows under the ACTION BAR header with their byte-real `CTRL-1..CTRL-0` defaults (1.12
/// files them under l.121's header, having none of their own), and the page's own search — the
/// path a player actually finds a binding by — paints the row live with that key on its capsule.
/// The pet bar's bindings, 1.12's `BONUSACTIONBUTTON1-10` (`BonusActionBarFrame.lua:106-112`),
/// file under the action bar's header (`Bindings.xml:121`, `:321-390`) with `CTRL-1..CTRL-0`
/// defaults, and the page's search finds them.
#[test]
fn the_pet_lane_is_registered_under_the_action_bar_header() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
on_page(&mut s);
// Registry side: the whole lane, in Bindings.xml order right after the shapeshift block,
// sharing the action bar's category token.
let category = |s: &mut UiScript, name: &str| {
s.eval::<String>(&format!(
r#"for i = 1, GetNumBindings() do
@@ -728,9 +644,7 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
.unwrap();
assert_eq!(key, format!("CTRL-{}", i % 10), "{name}'s 1.12 default");
}
// …and it is a runOnUp pair like the action bar's own rows — which decides that a press
// delivers a release half, and NOT whether the row may wear a wheel chord (1295). It takes
// the wheel like any other command; the notch then runs both halves of the pair.
// `runOnUp` decides that a press also delivers the release, not whether the wheel may bind.
assert_eq!(
s.eval::<Option<u32>>(r#"return SetBinding("MOUSEWHEELUP", "BONUSACTIONBUTTON1")"#)
.unwrap(),
@@ -738,15 +652,13 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
"a press+release command takes the wheel — the refusal was ours, B265"
);
// Page side: search finds it and paints a live row with the capsule filled.
// 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.
// Search matches display names (the era's `AddSearchTags`), so the query is the row's label.
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)
s.tick(0.0); // the deferred OnTextChanged drains here
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
@@ -768,11 +680,7 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Every registry body is real Lua.** 167 rows carry a Lua chunk as a Rust string literal, and
/// nothing compiled them: a typo in one shipped as a command that errors on its first press, in a
/// file whose tests all live one layer up at the page. `loadstring` is the whole check — running
/// them needs the FrameXML the harness deliberately does not load — and it is exactly the check
/// that a hand-typed string wants.
/// `loadstring` on each command body: running one would need FrameXML this test does not load.
#[test]
fn every_command_body_compiles() {
use crate::bindings::commands::Kind;
File diff suppressed because it is too large Load Diff
+55 -186
View File
@@ -5,9 +5,7 @@ use benilla_ui::script::{
use super::test_ui::load_ui as load_xml;
/// A bare frame's own rect via its `QuadContent::Frame` entry (every frame emits one at its resolved
/// rect). The re-skinned loot window has no solid-colour fill (the UI-LootPanel slab is opaque), so
/// it's found by its own frame quad rather than a background texture.
/// The rect of the `QuadContent::Frame` quad sized `w` by `h`: every frame emits one.
fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Rect {
quads
.iter()
@@ -20,7 +18,6 @@ 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}"))
}
/// The centre of the first texture quad whose path contains `needle` (a row's icon), for clicking it.
fn icon_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
let r = quads
.iter()
@@ -32,7 +29,6 @@ fn icon_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)
}
/// The colour of the first text quad whose text equals `t`.
fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
quads.iter().find_map(|q| match &q.content {
QuadContent::Text {
@@ -83,11 +79,7 @@ fn coin_and_two_items() -> LootState {
}
}
/// The whole loot chain minus Bevy (decision 0084): LOOT_OPENED lands the window at the left slot,
/// the coin row (first) + two item rows render with quality-coloured text + a stack count, a coin
/// click and an item click each queue the right 1-based row pick, a LOOT_UPDATE with the coin row
/// looted hides ITS button in place (the items keep their rows — the fixed slot layout), and the
/// close button releases through OnHide → CloseLoot.
/// Open at the left slot, the coin row first, 1-based row picks, a looted row hidden in place.
#[test]
fn shipped_loot_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
@@ -106,7 +98,6 @@ fn shipped_loot_frame_drives_end_to_end() {
loot window, and this one is now the reference's"
);
// Hidden by default: no coin icon on screen, left slot empty.
s.resolve();
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
quads.iter().any(|q| {
@@ -119,12 +110,10 @@ fn shipped_loot_frame_drives_end_to_end() {
);
assert!(s.eval::<bool>("return GetLeftFrame() == nil").unwrap());
// The app's feed: a coin pile + two items.
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Shown at the left slot; three rows visible (coin + 2 items), row 4 hidden.
assert!(s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
let vis: (bool, bool, bool, bool) = s
.eval(
@@ -133,7 +122,6 @@ fn shipped_loot_frame_drives_end_to_end() {
)
.unwrap();
assert_eq!(vis, (true, true, true, false), "coin + 2 items, 4th hidden");
// Only 3 items ⇒ no pager.
assert!(!s
.eval::<bool>("return LootFrameDownButton:IsVisible()")
.unwrap());
@@ -141,7 +129,7 @@ fn shipped_loot_frame_drives_end_to_end() {
s.resolve();
let quads = s.extract();
// The slot anchor applied: top-left at (0, 664) — screen height 768 minus the left slot's 104.
// The left panel slot: top-left at (0, 664), the 768 screen less the slot's 104.
let win = frame_rect(&quads, 256.0, 256.0);
assert_eq!(
(win.left, win.top),
@@ -149,8 +137,7 @@ fn shipped_loot_frame_drives_end_to_end() {
"loot window landed at the left slot (TOPLEFT UIParent, 0, -104)"
);
// The single UI-LootPanel slab IS the window art (ref LootFrame.xml l.88): no size/anchors → it
// fills the 256×256 window, sampling its whole texture (no TexCoords).
// `UI-LootPanel` is the window art (LootFrame.xml:88): unsized, it fills the 256x256 window.
let panel = quads
.iter()
.find(|q| {
@@ -171,8 +158,7 @@ fn shipped_loot_frame_drives_end_to_end() {
"loot panel pinned to the window's TOPLEFT"
);
// The coin row is FIRST: its money text renders, and the uncommon item's text is green while the
// common item's is white (ITEM_QUALITY_COLORS).
// Row text wears `ITEM_QUALITY_COLORS`: uncommon (0.12, 1, 0), common white.
let has_text = |t: &str| {
quads
.iter()
@@ -192,9 +178,7 @@ fn shipped_loot_frame_drives_end_to_end() {
&& (white[2] - 1.0).abs() < 0.02,
"common item text is white, got {white:?}"
);
// The stack count "3" sits INSIDE the Wool Cloth row's icon, at the reference
// ItemButtonTemplate inset (BOTTOMRIGHT -5,+2 of the 37px icon) — the director's report was
// this count overshooting the icon's right border onto the name plate.
// The count sits at `ItemButtonTemplate`'s BOTTOMRIGHT (-5, 2) inset of the 37px icon.
let count_rect = quads
.iter()
.find_map(|q| match &q.content {
@@ -223,7 +207,6 @@ fn shipped_loot_frame_drives_end_to_end() {
icon_rect.bottom
);
// Click the coin row's icon → LootSlot(1); click the Wool Cloth row's icon → LootSlot(2).
let (cx, cy) = icon_center(&quads, "INV_Misc_Coin_01");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
@@ -237,16 +220,11 @@ fn shipped_loot_frame_drives_end_to_end() {
);
assert!(!s.take_loot_close());
// LOOT_UPDATE with the coin row LOOTED: its slot becomes a gap (`None`) — its button hides in
// place and the two items KEEP their rows (the reference's fixed slot layout; the director's
// report was these rows sliding up to fill the coin's spot).
// The looted coin row hides in place, off `LOOT_SLOT_CLEARED` alone, and the items keep their
// rows; `LOOT_UPDATE` is not a 1.12 event and reaches nothing.
let mut coin_looted = coin_and_two_items();
coin_looted.rows[0] = None;
s.set_loot(Some(coin_looted));
// The app's own pair, in the app's own order (`ui_loot`): the per-slot event first — the
// stock file hangs "hide the button in place" off it and only off it — then the summary
// repaint. Firing LOOT_UPDATE alone leaves the looted row drawn, which is what a client that
// sent only the summary would actually show.
s.fire_event(
"LOOT_SLOT_CLEARED",
vec![benilla_ui::script::ScriptValue::Int(1)],
@@ -264,7 +242,6 @@ fn shipped_loot_frame_drives_end_to_end() {
"the looted coin row hides in place — the items do not shift up"
);
// The close button hides the window → OnHide → CloseLoot() queues the release intent.
s.run("LootCloseButton:Click()").unwrap();
assert!(
s.take_loot_close(),
@@ -278,10 +255,7 @@ fn shipped_loot_frame_drives_end_to_end() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The LOOTWINDOWOPENEMPTY branch (LootFrame.lua LootFrame_OnShow, l.135-136): an empty loot roll
/// queues the empty-open kit on show; a normal (non-empty) loot open queues NO sound (that kit is
/// C-side, server-driven). DR 0086 masks the lootable flag for empty rolls so live empty windows are
/// rare — but the branch must exist, and this proves it fires the right kit and only then.
/// Only an empty loot plays a Lua sound, `LOOTWINDOWOPENEMPTY` (LootFrame.lua:135-136).
#[test]
fn loot_empty_roll_plays_the_empty_open_kit() {
benilla_formats::wow_data_or_skip!();
@@ -292,7 +266,6 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// A normal, non-empty loot open queues no sound (the normal open kit is C-side).
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -301,11 +274,9 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
"a non-empty loot open is silent (its open kit is C-side)"
);
// Close it (also silent — the loot-close kit is C-side).
s.fire_event("LOOT_CLOSED", vec![]);
assert!(s.take_sounds().is_empty(), "loot close is silent (C-side)");
// Re-open with an EMPTY roll: OnShow's numItems==0 fork queues exactly LOOTWINDOWOPENEMPTY.
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
@@ -320,10 +291,8 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
);
}
/// The fishing fork (LootFrame.lua LootFrame_OnShow l.137-140; decision 1086): a fishing loot open
/// plays "FISHING REEL IN" (SoundEntries "Fishing Reel in", kit 3407 — the name lookup is
/// case-insensitive on both sides) and swaps the portrait ring's skull for the FishingLoot-Icon;
/// the next ordinary open resets the skull (the ref re-stamps TargetDead at every show, l.133).
/// A fishing open plays "FISHING REEL IN" (kit 3407, "Fishing Reel in": the lookup ignores case)
/// and swaps the skull for `FishingLoot-Icon`; every show resets it (LootFrame.lua:134-140).
#[test]
fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
benilla_formats::wow_data_or_skip!();
@@ -359,7 +328,6 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
"…instead of the dead-target skull"
);
// Close, then an ordinary corpse loot: silent again, and the skull is back.
s.fire_event("LOOT_CLOSED", vec![]);
let _ = s.take_sounds();
s.set_loot(Some(coin_and_two_items()));
@@ -374,8 +342,7 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
assert!(!has_icon(&quads, "FishingLoot-Icon"));
}
/// Paging (LootFrame.lua l.70-73,105-118): 5 items ⇒ 3 rows + a Down pager on page 1, 2 rows + an Up
/// pager on page 2 — the real shipped XML driving the render, not just the arithmetic.
/// Past four rows the pager takes a button: five items page as 3 + 2 (LootFrame.lua:70-73,105-118).
#[test]
fn shipped_loot_frame_pages_five_items() {
benilla_formats::wow_data_or_skip!();
@@ -408,7 +375,6 @@ fn shipped_loot_frame_pages_five_items() {
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Page 1: 3 rows (the pager spends the 4th slot), Down shown, Up hidden.
let page1: (bool, bool, bool, bool) = s
.eval(
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
@@ -421,7 +387,6 @@ fn shipped_loot_frame_pages_five_items() {
.unwrap();
assert_eq!(pager1, (false, true), "page 1: Up hidden, Down shown");
// Page down → page 2: 2 rows, Up shown, Down hidden.
s.run("LootFrame_PageDown()").unwrap();
let page2: (bool, bool, bool) = s
.eval(
@@ -435,13 +400,8 @@ fn shipped_loot_frame_pages_five_items() {
.unwrap();
assert_eq!(pager2, (true, false), "page 2: Up shown, Down hidden");
// Back on page 1, loot all three of its rows: their buttons hide in place, and the emptied
// page advances to page 2 on its own — the ref's LOOT_SLOT_CLEARED tail (LootFrame.lua
// l.38-50: every button hidden + Down visible → PageDown).
//
// The events are the app's own, in the app's own order: one LOOT_SLOT_CLEARED per row that
// went away (`ui_loot`'s emitter), then the summary LOOT_UPDATE. The auto-advance hangs off
// the per-slot event and ONLY off it, which is why the app fires both.
// Loot out page 1: it pages down by itself, off `LOOT_SLOT_CLEARED` alone
// (LootFrame.lua:38-50); `LOOT_UPDATE` is not a 1.12 event and reaches nothing.
s.run("LootFrame_PageUp()").unwrap();
let mut cleared = rows;
cleared[0] = None;
@@ -471,9 +431,7 @@ fn shipped_loot_frame_pages_five_items() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Pin §4/§5: loot (pushable=7) open, then merchant (pushable=0) open → loot is pushed to the CENTER
/// slot rather than replaced, and merchant takes the left slot loot vacated — the real windows, not
/// the synthetic stand-in the merchant panel test uses.
/// Loot, then merchant (pushable 7 and 0, UIParent.lua:22,26): loot is pushed to the centre slot.
#[test]
fn shipped_loot_pushed_to_center_by_merchant() {
benilla_formats::wow_data_or_skip!();
@@ -483,11 +441,10 @@ fn shipped_loot_pushed_to_center_by_merchant() {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// Loot opens onto the empty left slot.
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -497,8 +454,6 @@ fn shipped_loot_pushed_to_center_by_merchant() {
"loot took the empty left slot"
);
// Merchant (pushable=0) opens: loot's pushable=7 outranks it, so loot is promoted to center and
// merchant takes the left spot.
s.set_merchant(Some(MerchantState {
items: vec![MerchantItem {
name: Some("Refreshing Spring Water".into()),
@@ -526,7 +481,6 @@ fn shipped_loot_pushed_to_center_by_merchant() {
.unwrap(),
"merchant took the left slot loot vacated"
);
// Both windows still visible, at their slots.
s.resolve();
let quads = s.extract();
let loot_center = frame_rect(&quads, 256.0, 256.0);
@@ -538,20 +492,9 @@ fn shipped_loot_pushed_to_center_by_merchant() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The party frame drew through the loot window** — the director's report, and the reason
/// decision 0597 exists.
///
/// Both own the top-left corner: `ShowUIPanel` plants the loot window at `UIParent TOPLEFT
/// (0,-104)` and `PartyMemberFrame1` sits just above it. Benilla had transcribed the party template
/// without the reference's `frameStrata="LOW"` (`PartyFrameTemplates.xml` l.194), so it inherited
/// the default MEDIUM — the panels' own stratum. Within one stratum the draw key is
/// `level`-then-insertion, and the party frame's art rides a nested `$parentTextureFrame` CHILD
/// (level 1) while the loot window's `UI-LootPanel` slab is a REGION of the window itself (level
/// 0). Level outranks insertion, so being shown later could never have lifted the window above it.
///
/// Asserted on the packed draw key itself, and it pins both halves of the fix: that the template's
/// strata reaches an `inherits=` instance at all, and that it cascades down to the nested child
/// frames (`SetFrameStrata 0x76a470` is a whole-subtree cascade).
/// The party template's `frameStrata="LOW"` (PartyFrameTemplates.xml:194) must reach an instance
/// and its nested art frame (`SetFrameStrata` `0x76a470` cascades over the subtree): in one
/// stratum that level-1 child draws over the window's level-0 art, whatever the show order.
#[test]
fn the_loot_window_draws_over_the_party_frames() {
benilla_formats::wow_data_or_skip!();
@@ -562,7 +505,7 @@ fn the_loot_window_draws_over_the_party_frames() {
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// The party frame up FIRST, the window second — the order that cannot be what saves it.
// The party frame first: showing the window later is not what lifts it.
s.eval::<()>("PartyMemberFrame1:Show()").unwrap();
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
@@ -580,8 +523,6 @@ fn the_loot_window_draws_over_the_party_frames() {
.unwrap_or_else(|| panic!("no quad for {needle}"))
.z
};
// The window's own background slab (a level-0 region) against the party art (a level-1 child
// frame's region) — the exact pair that inverted.
let loot_panel = z_of("UI-LootPanel");
let party_art = z_of("UI-PartyFrame");
assert!(
@@ -589,7 +530,7 @@ fn the_loot_window_draws_over_the_party_frames() {
"the loot window's background must draw OVER the party frame art: \
panel {loot_panel:#x} vs party {party_art:#x}"
);
// And the whole party frame is below the whole window, not just that one pair.
// The stratum is the draw key's top bits: in sync with `benilla_ui::order`'s `STRATUM_SHIFT`.
const STRATUM_SHIFT: u32 = 60;
assert!(
(party_art >> STRATUM_SHIFT) < (loot_panel >> STRATUM_SHIFT),
@@ -597,15 +538,8 @@ fn the_loot_window_draws_over_the_party_frames() {
);
}
/// The row click's modifier fork (ref `LootFrameItem_OnClick`, LootFrame.lua l.147-154): CTRL
/// 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 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.
/// Ctrl previews and shift posts the link (LootFrame.lua:147-154), and neither loots: the take is
/// the `LootButton`'s own click, armed by `SetSlot` (LootFrame.lua:94) and off under any modifier.
#[test]
fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
benilla_formats::wow_data_or_skip!();
@@ -616,7 +550,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
load_xml(&s, f);
}
for file in [
r"Interface\FrameXML\UIParent.xml", // UIParent + UIParent.lua, the reference's own (1988)
r"Interface\FrameXML\UIParent.xml", // UIParent and UIParent.lua
"Interface\\FrameXML\\LootFrame.xml",
"Interface\\FrameXML\\DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
@@ -632,7 +566,6 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
load_xml(&s, file);
}
// A coin row + one resolved item: the item's link is fed exactly as `ui_loot.rs` builds it.
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
@@ -665,7 +598,6 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
let (x, y) = icon_center(&quads, "INV_Fabric_Wool_01");
let (coin_x, coin_y) = icon_center(&quads, "INV_Misc_Coin_01");
// The control first, while nothing else has been opened: a plain click still loots row 2.
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert_eq!(
@@ -674,7 +606,6 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
"an unmodified click still loots the row"
);
// SHIFT with the chat edit box open → the link, and no loot.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
@@ -691,9 +622,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
"a shift-click must not also loot the row"
);
// The COIN row has no link at all, and a modified click on it must be inert rather than an
// error: our `EditBox:Insert` binding is typed `String` and raises on a nil, so the handler
// guards it (the reference's C Insert tolerates the nil its own callers hand it).
// The linkless coin row: `Insert(nil)` is a no-op, as the reference's `lua_tostring` makes it.
s.set_modifiers(true, false, false);
s.mouse_button(coin_x, coin_y, "LeftButton", true);
s.mouse_button(coin_x, coin_y, "LeftButton", false);
@@ -714,10 +643,8 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
"a shift-click on the coin row must not loot the money either"
);
// ALT → nothing at all, and in particular NOT a loot (decision 1067). No FrameXML file binds
// alt on a loot row; the suppression is the C `CLootButton::OnClick`'s own third gate
// (`0x41f8f0(2)` @ `0x4c1841`, VERIFIED at the bytes), which our Lua-side take has to
// reproduce. This is the arm nothing else in the suite would catch: before 1067, alt looted.
// Alt: nothing, and no loot. No FrameXML binds alt on a loot row; the suppression is
// `CLootButton::OnClick`'s third modifier gate (`0x41f8f0(2)` at `0x4c1856`).
s.set_modifiers(false, false, true);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
@@ -728,8 +655,8 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// CTRL → the dressing room wearing it, still no loot. Last, because opening the room takes the
// left UIPanel slot and moves the loot window (pushable 2 vs 7).
// Ctrl: the dressing room wearing it, and no loot. Last, because the room takes the left panel
// slot and moves the loot window (pushable 2 against 7).
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
@@ -746,15 +673,8 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Master loot, end to end through the shipped XML** (decision 1675).
///
/// The whole interaction in one pass: a row click stashes the four `LootFrame.selected*` fields
/// the dropdown reads, `OPEN_MASTER_LOOT_LIST` raises the menu anchored on that row, a candidate
/// row hands the item straight out when the item is below `MASTER_LOOT_THREHOLD`, and an at-or-
/// above-threshold item raises `CONFIRM_LOOT_DISTRIBUTION` first and only sends on accept.
///
/// The two thresholds matter: `MASTER_LOOT_THREHOLD` is 4 (epic), so an uncommon goes straight
/// out and an epic asks. The misspelling is the reference's own (`LootFrame.lua:3`).
/// Master loot: an item at or above `MASTER_LOOT_THREHOLD` (4, epic; the misspelling is the
/// reference's, LootFrame.lua:3) asks `CONFIRM_LOOT_DISTRIBUTION` before it is given.
#[test]
fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
benilla_formats::wow_data_or_skip!();
@@ -813,8 +733,7 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
"LootButton1",
"the dropdown anchors on the clicked row, not the window"
);
// The click still queues the pick — deciding whether it is a take or a master-loot open is
// the app's, off the wire slot type, exactly as the real client decides it in C.
// The pick still queues: the app chooses take or menu off the wire slot type, as 1.12 does.
assert_eq!(s.take_loot_picks(), vec![1]);
// ── The app answers a MASTER row with the event; the menu lists the candidates ─────────────
@@ -876,8 +795,7 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
"row 2 to candidate 1, only after the accept"
);
// A fresh row click hides a standing confirmation, so an accept can never land on a row the
// player has since clicked away from (the ref's own reason for the StaticPopup_Hide).
// A row click hides a standing confirmation (LootFrame.lua:156).
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
s.resolve();
click(&mut s, "DropDownList1Button1");
@@ -889,13 +807,8 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
);
}
/// **The raid arm of the master-loot menu** (decision 1675).
///
/// In a raid the candidate array is not the wire order: the client files each candidate into its
/// own subgroup's five-slot block, leaving holes. `GroupLootDropDown_Initialize` reads those holes
/// as raid-group membership — it walks `1..40` in blocks of five and keeps a "Group N" submenu only
/// where the block has an occupant. This drives the shipped XML with a hole-shaped list and asserts
/// the menu that comes out, which a densely-packed list could not produce.
/// In a raid each candidate sits in its subgroup's five-slot block, and the menu keeps a "Group N"
/// row only for an occupied block (LootFrame.lua:197-213).
#[test]
fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
benilla_formats::wow_data_or_skip!();
@@ -908,7 +821,7 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// A raid, so the dropdown takes its nested arm (GetNumRaidMembers() > 0 is the whole gate).
// A raid: `GetNumRaidMembers() > 0` is the nested arm's whole gate (LootFrame.lua:189).
s.set_party(PartyState {
raid: vec![RaidMemberInfo::default(); 7],
loot_method: "master".into(),
@@ -917,8 +830,7 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
..PartyState::default()
});
// Subgroup 1 holds slots 1-2, subgroup 3 holds slot 11 — everything else is a hole. That is
// the placement `SMSG_LOOT_MASTER_LIST`'s handler produces for a raid.
// Subgroup 1 holds slots 1-2 and subgroup 3 slot 11, the rest holes, as the client files them.
let mut candidates = vec![None; 12];
candidates[0] = Some("Thrall".to_string());
candidates[1] = Some("Cairne".to_string());
@@ -949,8 +861,6 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// A title row, then ONE row per occupied block — not one per candidate, and labelled by the
// block's own number rather than by position in the list.
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
@@ -975,11 +885,9 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
);
}
/// **The soulbind confirm's Lua half** (decision 1744, ref `StaticPopup.lua:601-611` +
/// `UIParent.lua:317-323` + `LootFrame.lua:53-54`). `LOOT_BIND_CONFIRM` raises `LOOT_BIND` with the
/// real `LOOT_NO_DROP` text and Okay/Cancel; the row rides on `dialog.data`, and Okay hands it back
/// through `LootSlot` — the continuation verb, which is the only thing that can complete a deferred
/// take. Cancel sends nothing at all, and `LOOT_CLOSED` takes the dialog down with the window.
/// `LOOT_BIND_CONFIRM` raises `LOOT_BIND` with the row on `dialog.data` (UIParent.lua:317-323);
/// Okay hands it back through `LootSlot`, the only verb that completes a deferred take
/// (StaticPopup.lua:601-611), and `LOOT_CLOSED` hides the dialog (LootFrame.lua:53-54).
#[test]
fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
benilla_formats::wow_data_or_skip!();
@@ -1028,7 +936,7 @@ fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
"the row rides on dialog.data, not in the text"
);
// Cancel: no continuation, and nothing reaches either queue.
// Button 2, Cancel: nothing reaches either queue.
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
assert!(
@@ -1037,7 +945,7 @@ fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
);
assert!(s.take_loot_picks().is_empty(), "and takes nothing either");
// Okay: the row goes back out through LootSlot, the continuation verb.
// Button 1, Okay: the row goes back out through `LootSlot`.
s.fire_event(
"LOOT_BIND_CONFIRM",
vec![benilla_ui::script::ScriptValue::Int(2)],
@@ -1050,8 +958,7 @@ fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
"the accept is a continuation, never a fresh take"
);
// The window closing takes an open dialog with it (ref LootFrame.lua l.53-54) — its row number
// would name a slot in the next corpse.
// Closing the window takes the dialog with it: its row would name a slot in the next corpse.
s.fire_event(
"LOOT_BIND_CONFIRM",
vec![benilla_ui::script::ScriptValue::Int(2)],
@@ -1065,9 +972,7 @@ fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The row click is `BenillaTakeLootSlot`, not `LootSlot` — a real mouse click on a row must land
/// in the TAKE queue, or every loot would be a continuation nothing is pending for and the window
/// would sit there doing nothing (decision 1744).
/// A row click is a take; `LootSlot` is only the bind-confirm continuation.
#[test]
fn a_row_click_takes_rather_than_continues() {
benilla_formats::wow_data_or_skip!();
@@ -1092,21 +997,8 @@ fn a_row_click_takes_rather_than_continues() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The state every loot on a cold item cache opens in** — the rows are on the wire, the item
/// templates are not back yet — driven through the STOCK `LootFrame.lua`. Decision 1805.
///
/// The hole this closes: every loot fixture in this file (and in `benilla-ui`'s own) handed the
/// window fully-resolved rows, so nothing in the suite ever ran `LootFrame_Update` over a row whose
/// template was still in flight. Our deleted `LootFrame.xml` could not have noticed either — it
/// carried an `ITEM_QUALITY_COLORS[quality] or ITEM_QUALITY_COLORS[1]` guard, the way
/// `GroupLootFrame.xml` and `AuctionFrame.xml` still do. The stock file has no guard: it does
/// `color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:82`) and dereferences `color.r` on the
/// next line, so a nil quality raised
/// `LootFrame.lua:85: attempt to index local 'color' (a nil value)` out of `LootFrame_OnShow` —
/// on the director's screen, on an ordinary corpse.
///
/// The window must therefore come up clean, paint the row, and paint it in the cache-miss colour —
/// `ITEM_QUALITY_COLORS[-1]`, the row `UIParent.lua`'s `for i = -1, 6` exists to provide.
/// A row awaiting its template: the app never opens on one (`ui_loot` waits), but stock
/// `LootFrame_Update` indexes `ITEM_QUALITY_COLORS[quality]` unguarded (LootFrame.lua:82-85).
#[test]
fn loot_row_awaiting_its_template_opens_clean() {
benilla_formats::wow_data_or_skip!();
@@ -1117,8 +1009,7 @@ fn loot_row_awaiting_its_template_opens_clean() {
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// One row, in flight: the wire gave us the icon (it rides the display id) and the stack size,
// and nothing else yet.
// One row in flight: the wire gives the icon (by display id) and the count, nothing else.
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
@@ -1145,8 +1036,7 @@ fn loot_row_awaiting_its_template_opens_clean() {
"the window opened and drew the row"
);
// The binding answered the reference's sentinels rather than nils, and the quality one indexes
// `ITEM_QUALITY_COLORS` — which is the whole of what `LootFrame.lua:82` needs to be true.
// The reference's sentinels, not nils; -1 is a row of `ITEM_QUALITY_COLORS` (UIParent.lua:66).
let (item, quantity, quality) = s
.eval::<(String, i64, i64)>("local _, i, n, q = GetLootSlotInfo(1)\nreturn i, n, q")
.unwrap();
@@ -1160,10 +1050,8 @@ fn loot_row_awaiting_its_template_opens_clean() {
"the cache-miss quality must be a real row of ITEM_QUALITY_COLORS"
);
// And line 85 — the line that raised — actually ran: the row text wears the cache-miss colour,
// read back off the FontString rather than off the table. `ITEM_QUALITY_COLORS[-1]` is Common
// (`GetItemQualityColor`'s clamp is unsigned, so -1 takes the same branch as 7-and-up), not the
// Poor it used to be here — 1805 corrects 1199 on that.
// Line 85 ran: the row text wears `ITEM_QUALITY_COLORS[-1]`, Common white, because
// `GetItemQualityColor`'s clamp is unsigned and -1 takes the 7-and-up branch.
let painted: (f64, f64, f64) = s
.eval("local r, g, b = LootButton1Text:GetTextColor()\nreturn r, g, b")
.unwrap();
@@ -1171,7 +1059,7 @@ fn loot_row_awaiting_its_template_opens_clean() {
.eval("local r, g, b = GetItemQualityColor(-1)\nreturn r, g, b")
.unwrap();
assert_eq!(miss, (1.0, 1.0, 1.0), "the cache-miss row is Common/white");
// (f32 region storage vs the f64 the binding computes — compare, don't equate.)
// f32 region storage against the binding's f64: compare within a tolerance.
assert!(
(painted.0 - miss.0).abs() < 1e-6
&& (painted.1 - miss.1).abs() < 1e-6
@@ -1179,15 +1067,8 @@ fn loot_row_awaiting_its_template_opens_clean() {
"LootFrame_Update:85 must paint the -1 colour, got {painted:?} want {miss:?}"
);
// The template lands and the window is reopened: the row reads its name in its own quality's
// colour, so the sentinel state poisons nothing downstream.
//
// It is a REOPEN and not a repaint on purpose. The app fires `LOOT_UPDATE` when an open
// window's content changes, and `LOOT_UPDATE` is not a 1.12 event — it appears nowhere in the
// reference's FrameXML and `LootFrame_OnLoad` does not register it — so nothing in the stock
// file listens. What the real client does when a template arrives mid-window is still open
// (the arrival callback `0x4c2ac0` is recorded as clearing the pending flag and nothing else);
// this test deliberately asserts only what is settled.
// The template lands and the window opens only now: the reference's item-cache callback
// (`0x4c2ac0`) fires `LOOT_OPENED` as the pending count falls to zero.
s.fire_event("LOOT_CLOSED", vec![]);
s.set_loot(Some(LootState {
fishing: false,
@@ -1215,16 +1096,8 @@ fn loot_row_awaiting_its_template_opens_clean() {
);
}
/// **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.
///
/// 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.
/// A `<LootButton>` loads as a `Button` (`CLootButton` keeps `CSimpleButton`'s `LoadXML`,
/// `0x7788c0`), so it wears `ItemButtonTemplate`'s state textures and lights under the cursor.
#[test]
fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
benilla_formats::wow_data_or_skip!();
@@ -1238,7 +1111,6 @@ fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
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
@@ -1255,7 +1127,6 @@ fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
"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()
@@ -1274,7 +1145,6 @@ fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
"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()
@@ -1294,7 +1164,6 @@ fn stock_loot_rows_wear_the_item_button_art_and_light_under_the_cursor() {
"the highlight covers the hovered row's icon square"
);
// And it leaves with the cursor.
super::test_ui::unhover(&mut s);
s.resolve();
assert!(
+48 -203
View File
@@ -1,35 +1,15 @@
//! 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
//! neighbourhood; NEW → pick an icon → OKAY actually creates a macro on the right tab; typing in
//! the body and closing the window commits it (the `MacroFrame_SaveMacro` line that makes the
//! whole editor work); the two tabs address the two index ranges; DELETE removes and re-selects;
//! and a macro button is a drag source that loads the cursor with the macro payload.
//!
//! The **saving** half is deliberately exercised through the window rather than the bindings: the
//! bindings' own round trip is `benilla_ui::script::macros`' unit tests, and what can only break
//! here is the wiring — a tab that forgets to save, an OKAY that creates on the wrong tab.
//! The stock `Blizzard_MacroUI` addon, the macro editor and its name/icon popup, driven against the
//! engine's macro table.
use benilla_ui::script::{CursorPayload, UiScript};
/// The window's real neighbourhood, in the manifest's own order.
/// Click the first macro in the list.
///
/// The reference selects **nothing** when the window opens: `MacroFrame_OnShow` calls only
/// `MacroFrame_Update`, and that function merely *highlights* whichever macro is already selected —
/// it never assigns `MacroFrame.selectedMacro`. Our retired file added a benilla-only
/// `BenillaMacroFrame_EnsureSelection` that picked the first one, and every test here leaned on it
/// without saying so (decision 1848). A player clicks; so do these.
/// Click the first macro: `MacroFrame_OnShow` only highlights a selection and never makes one
/// (`Blizzard_MacroUI.lua:19-22`, `:75`).
fn select_first(s: &UiScript) {
s.run("MacroButton1:Click()").unwrap();
}
/// [`harness`] with a chosen player name.
///
/// The character tab's label is built in its own `OnLoad` — `format(CHARACTER_SPECIFIC_MACROS,
/// UnitName("player"))` — so the name has to be in place BEFORE the load, not after it. That is
/// also why the name is how a test chooses the label's width (decision 1848).
/// [`harness`] with a chosen player name, which tab 2's label carries.
fn harness_named(player: &str) -> UiScript {
harness_with(player)
}
@@ -41,8 +21,7 @@ fn harness() -> UiScript {
fn harness_with(player: &str) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The global strings the file reads by name (the app runs the real `GlobalStrings.lua`; here
// only the handful this window formats need to exist).
// The global strings this window formats.
s.run(
r#"
CREATE_MACROS = "Create Macros"
@@ -66,16 +45,12 @@ fn harness_with(player: &str) -> UiScript {
"#,
)
.unwrap();
// **A synchronous measurer, because the app has one.** `PanelTemplates_TabResize` sizes a tab
// from its label's laid-out width at OnLoad, and `measured_wh` answers that inline only when a
// host font engine is installed — which `ui_script::extract` does (`AtlasMeasurer`). Without
// one the measure is pending, the width reads 0, and the stock file never re-runs TabResize
// because the reference's own measure is synchronous. This harness used to model the async
// round trip and lean on our retired file's OnUpdate re-check (decision 1848).
// Synchronous, like the app's `AtlasMeasurer`: `PanelTemplates_TabResize` measures the label
// once, in the tab's OnLoad, and a pending measure would size the tab off a width of 0.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
// `CHARACTER_SPECIFIC_MACROS` is a `%s` template the character tab formats
// `UnitName("player")` into, in its OnLoad — so the player exists before the load, not after.
// Before the load: tab 2's OnLoad formats `UnitName("player")` into its label
// (`Blizzard_MacroUI.xml:509`).
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -85,8 +60,6 @@ fn harness_with(player: &str) -> UiScript {
..Default::default()
}),
);
// Through the shared loader, because the window is off the chain now and a private disk-only
// reader cannot name it (decision 1848).
for file in [
r"Interface\FrameXML\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
@@ -96,28 +69,24 @@ fn harness_with(player: &str) -> UiScript {
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// The chain's `PanelTemplates_SelectTab` reaches for `GameTooltip` unguarded.
// `PanelTemplates_SelectTab` reads `GameTooltip` unguarded (`UIPanelTemplates.lua:130`).
"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
// drift that record names.
// ScrollTemplates before UIPanelTemplates, the manifest's order.
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// `ClassTrainerListScrollFrameTemplate` — the icon chooser's scroll frame inherits it.
// `ClassTrainerListScrollFrameTemplate`, which the icon chooser's scroll frame inherits.
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml", // stock `MacroFrame_OnShow`/`_OnHide` drive the micro button
r"Interface\FrameXML\MainMenuBarMicroButtons.xml", // the window's OnShow/OnHide call it
] {
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).
// A LoadOnDemand addon, seated off the chain and loaded by stock `MacroFrame_LoadUI`
// (`UIParent.lua:178`), as the app reaches it.
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.
// The icon chooser's list is the app's push.
s.set_macro_icons(vec![
"Interface\\Icons\\Ability_Ambush".into(),
"Interface\\Icons\\Ability_BackStab".into(),
@@ -126,14 +95,10 @@ fn harness_with(player: &str) -> UiScript {
s
}
/// Assert no script error has been collected, naming the step that produced it.
fn no_errors(s: &UiScript, step: &str) {
assert!(s.errors().is_empty(), "{step}: {:?}", s.errors());
}
/// The whole creation flow through the window's own buttons: NEW opens the popup, an icon click
/// selects, OKAY enables and creates. This is the path a player takes, and every step of it is
/// wiring this file owns.
#[test]
fn new_then_pick_an_icon_then_okay_creates_the_macro() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -179,7 +144,6 @@ fn new_then_pick_an_icon_then_okay_creates_the_macro() {
.unwrap(),
"OKAY closes the popup"
);
// …and the new macro is selected and shown in the detail pane.
assert_eq!(
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
.unwrap(),
@@ -187,9 +151,7 @@ fn new_then_pick_an_icon_then_okay_creates_the_macro() {
);
}
/// The body editor's commit path — the single line that makes the window an editor at all
/// (`MacroFrame_SaveMacro`, called from the tab switch, the list click, and the window's OnHide).
/// A regression here silently discards everything the player typed.
/// `MacroFrame_SaveMacro` commits the body from the tab switch, the list click and the OnHide.
#[test]
fn typing_a_body_and_closing_the_window_commits_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -199,8 +161,7 @@ fn typing_a_body_and_closing_the_window_commits_it() {
select_first(&s);
s.run(r#"MacroFrameText:SetText("/cast Ambush\n/say pew")"#)
.unwrap();
// The window's dirty flag comes from the box's `OnTextChanged`, which the drain owes until the
// next frame (decision 1831).
// The dirty flag comes from the box's `OnTextChanged`, which the drain delivers next frame.
s.tick(0.0);
no_errors(&s, "type");
assert!(
@@ -208,7 +169,6 @@ fn typing_a_body_and_closing_the_window_commits_it() {
.unwrap(),
"OnTextChanged marks the window dirty"
);
// The character counter is the ref's own MACROFRAME_CHAR_LIMIT fill.
assert_eq!(
s.eval::<String>("return MacroFrameCharLimitText:GetText()")
.unwrap(),
@@ -225,8 +185,8 @@ fn typing_a_body_and_closing_the_window_commits_it() {
);
}
/// The two tabs address the two index ranges, and switching tabs saves first. The 19 is the whole
/// point: `MacroFrame.macroBase` is 0 or MAX_MACROS, and every binding takes `macroBase + i`.
/// `MacroFrame.macroBase` is 0 or `MAX_MACROS` (18) and every binding takes `macroBase + i`, so the
/// character tab starts at 19 (`Blizzard_MacroUI.lua:1`, `:42`).
#[test]
fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -235,9 +195,9 @@ fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
// Type into the account macro, then switch tabs WITHOUT any explicit save.
// Type into the account macro, then switch tabs with no explicit save.
s.run(r#"MacroFrameText:SetText("/say account")"#).unwrap();
s.tick(0.0); // as above — the edit marks, the drain notifies (1831)
s.tick(0.0); // the edit marks, the drain notifies
s.run("MacroFrameTab2:Click()").unwrap();
no_errors(&s, "tab 2");
assert_eq!(
@@ -248,7 +208,6 @@ fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
);
assert_eq!(s.eval::<i64>("return MacroFrame.macroBase").unwrap(), 18);
// Creating on this tab lands at 19 (the character range's base + 1).
s.run("MacroNewButton:Click()").unwrap();
s.run(r#"MacroPopupEditBox:SetText("Char")"#).unwrap();
s.run("MacroPopupButton2:Click()").unwrap();
@@ -265,7 +224,6 @@ fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
"the character tab's first slot is index 19"
);
// …and back to tab 1 shows the account macro again.
s.run("MacroFrameTab1:Click()").unwrap();
assert_eq!(s.eval::<i64>("return MacroFrame.macroBase").unwrap(), 0);
assert_eq!(
@@ -275,8 +233,8 @@ fn the_character_tab_creates_in_the_second_index_range_and_switching_saves() {
);
}
/// DELETE removes the macro and leaves the window on a sane selection — the ref re-runs its own
/// OnLoad, which re-selects the first macro (or clears the detail pane when none is left).
/// DELETE re-runs `MacroFrame_OnLoad`, which selects the first macro or, with none left, nothing
/// (`Blizzard_MacroUI.xml:551-553`).
#[test]
fn delete_removes_the_macro_and_re_selects() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -293,7 +251,6 @@ fn delete_removes_the_macro_and_re_selects() {
.unwrap(),
(1, 0)
);
// The list closed its gap, so slot 1 is now the survivor and it is what's selected.
assert_eq!(s.eval::<String>("return GetMacroInfo(1)").unwrap(), "Two");
assert_eq!(
s.eval::<String>("return MacroFrameSelectedMacroName:GetText()")
@@ -301,7 +258,6 @@ fn delete_removes_the_macro_and_re_selects() {
"Two"
);
// Deleting the last one clears the detail pane rather than leaving a stale selection.
s.run("MacroDeleteButton:Click()").unwrap();
no_errors(&s, "delete last");
assert_eq!(
@@ -316,9 +272,6 @@ fn delete_removes_the_macro_and_re_selects() {
);
}
/// A macro button is a DRAG SOURCE: `OnDragStart` loads the cursor with the macro payload, which
/// `PlaceAction` then packs onto a bar slot under the MACRO tag. This is the only route a macro
/// reaches the action bar.
#[test]
fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -328,9 +281,8 @@ fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
// A REAL drag gesture — press, then move — the way `bag_tests` drives one. Calling
// `GetScript("OnDragStart")(button)` leaves `this` nil, and the stock handler reads it
// (decision 1848); our retired file's took the button as an argument.
// A real press-and-move: calling `GetScript("OnDragStart")` directly leaves `this` nil, and
// the stock handler reads it.
s.resolve();
let (bx, by): (f32, f32) = s.eval("return MacroButton1:GetCenter()").unwrap();
s.mouse_button(bx, by, "LeftButton", true);
@@ -347,14 +299,12 @@ fn dragging_a_macro_button_loads_the_cursor_with_the_macro_payload() {
("macro".to_string(), 1)
);
// Placing it on a bar slot packs the MACRO tag (0x40 << 24) with the macro index.
// A bar slot packs the MACRO tag (0x40 << 24) with the macro index.
let mut s = s;
s.run("PlaceAction(1)").unwrap();
assert_eq!(s.take_action_sets(), vec![(1, 0x4000_0000 | 1)]);
}
/// The icon chooser's grid: 20 buttons over the app-pushed list, the tail hidden rather than
/// blank, and a click marking the selection.
#[test]
fn the_icon_chooser_shows_the_pushed_list_and_hides_its_tail() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -391,38 +341,18 @@ fn the_icon_chooser_shows_the_pushed_list_and_hides_its_tail() {
);
}
/// **The two tabs fit inside the 384-wide window** — the director's own report (2026-08-05: "tab is
/// overflowing"), and the check 0983 never had.
///
/// 0983 dropped the reference's `PanelTemplates_TabResize(-15, …)` tuning as redundant with the
/// template's own +0 fit. It isn't: at +0 a tab is `text + 40`, and two of those plus the 65-unit
/// left inset run off a 384-wide plate — the more so on tab 2, whose label carries a player name and
/// so has no width anyone can know when the window is written. The reference's answer is the −15
/// padding on both and a 150 cap on tab 2; this pins both arms, including the cap actually engaging.
///
/// **Driven through a SYNCHRONOUS measurer, and that is a correction** (decision 1848). This used
/// to feed widths through the async round trip and lean on our retired file's OnUpdate re-check.
/// The stock window has no such re-check: its tabs call `PanelTemplates_TabResize` once, in their
/// own OnLoad, and the reference's measure is inline — so a client whose measure is pending at that
/// moment can never size its tabs at all. The app installs `AtlasMeasurer`, so this harness
/// installs one too; modelling the async path here was modelling a configuration the app does not
/// have.
///
/// The label widths therefore come from the text rather than being chosen, so the two arms are a
/// SHORT character name and a LONG one, and the cap is asserted against the label the measurer
/// actually produced.
/// Both tabs pass `PanelTemplates_TabResize` a -15 padding and tab 2, whose label carries the
/// player name, a 150 cap; a short and a long name run both arms of the cap.
#[test]
fn the_two_tabs_fit_inside_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
/// `sideWidths` — 2 × the tab's 16-unit end slice (TabButtonTemplate, UiPanels.xml).
/// `sideWidths`: twice `TabButtonTemplate`'s 16-unit end slice (`UIPanelTemplates.lua:41`).
const SIDES: f64 = 32.0;
/// The reference's own padding for this window's tabs (Blizzard_MacroUI.xml l.488/511).
/// The stock padding on both tabs (`Blizzard_MacroUI.xml:488`, `:511`).
const PAD: f64 = -15.0;
/// …and its cap on tab 2 (l.511).
/// The stock cap on tab 2 (`Blizzard_MacroUI.xml:511`).
const CAP: f64 = 150.0;
// A short name and a long one. `CHARACTER_SPECIFIC_MACROS` formats the name into tab 2's
// label, so the name is how the label's width is chosen.
for (name, cap_should_bind) in [("Probe", false), ("Bartholomewthelongnamed", true)] {
let mut s = harness_named(name);
s.run("ShowMacroFrame()").unwrap();
@@ -462,8 +392,7 @@ fn the_two_tabs_fit_inside_the_window() {
"the clamp may tighten the reference's cap, never loosen it"
);
// The property the clamp exists for, checked outright: the tab ends inside the plate the
// player can actually see, not merely inside the 384-unit frame rect.
// The tab ends inside the plate the player sees, not merely inside the 384-unit frame.
let tab2_right: f64 = s
.eval("return MacroFrameTab2:GetLeft() + MacroFrameTab2:GetWidth()")
.unwrap();
@@ -479,12 +408,6 @@ fn the_two_tabs_fit_inside_the_window() {
}
}
/// **The icon chooser's scroll bar sits ON the popup's plate**, not off its right edge over the
/// world. 0983 sized the faux scroll frame to the icon grid (252×152) instead of transcribing the
/// reference's rect, which pushed the bar — anchored 6 units right of the frame — to popup x
/// 282..298, i.e. straddling the 297-wide plate's edge.
///
/// The frame is invisible, so its rect has exactly one observable consequence and this is it.
#[test]
fn the_icon_choosers_scroll_bar_sits_on_the_popup_plate() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -501,8 +424,8 @@ fn the_icon_choosers_scroll_bar_sits_on_the_popup_plate() {
bar_left >= plate_left && bar_right <= plate_right,
"scroll bar {bar_left}..{bar_right} is outside the popup plate {plate_left}..{plate_right}"
);
// The reference's own seat: 6 units right of a scroll frame whose right edge is 39 in from the
// popup's — so 264..280 of 297. Pinned as a number so a future re-anchor has to mean it.
// The stock seat: 6 right of a scroll frame whose right edge is 39 in from the 297-wide popup's
// (`UIPanelTemplates.xml:172`, `Blizzard_MacroUI.xml:766`), so 264..280.
assert_eq!(
(bar_left - plate_left, bar_right - plate_left),
(264.0, 280.0)
@@ -510,27 +433,10 @@ fn the_icon_choosers_scroll_bar_sits_on_the_popup_plate() {
no_errors(&s, "popup scroll bar");
}
// `pump_measures` stood here: it answered the measure round-trip every frame so a fit that
// OSCILLATED would show. Nothing oscillates now and nothing can — this harness installs a
// synchronous measurer, because the app does (`AtlasMeasurer`), and the stock tabs size themselves
// once in their own OnLoad with no re-check to converge. Decision 1848.
/// **The tab row is stable and inside the window, frame after frame** — the director's repro
/// (2026-08-05: a 9-character character name, "tab is still slightly overlapping"), and the check
/// the first fix did not have.
///
/// `the_two_tabs_fit_inside_the_window` feeds one measure and ticks once, which cannot catch this:
/// the defect is a FEEDBACK LOOP. The fit sets a width on the label; that changed the label's box;
/// and `GetStringWidth` used to report the box rather than the string, so the next frame refit off
/// a smaller number, uncapped, un-set the box, re-measured wider, capped again — a tab that changed
/// width every single frame and could be photographed at any point in the cycle.
#[test]
fn the_tab_row_settles_and_stays_inside_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
// The name is chosen at construction, because the stock tab builds its label in its own
// OnLoad; and there is no measure round trip to pump any more (decision 1848). The loop stays:
// "it does not change every frame" is still the property, it is just satisfied on the first
// pass now rather than after a convergence.
// The widths must hold across frames, not just on the first.
let mut s = harness_named("Onehunter");
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
@@ -554,11 +460,8 @@ 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, 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.
// 344, not 384: this window's art stops 40 units short of its frame rect. The stock padding
// and cap alone keep the row inside it.
assert!(
last.2 <= 344.0,
"tab 2 ends at {} of a window whose plate stops at 344: {widths:?}",
@@ -567,22 +470,10 @@ fn the_tab_row_settles_and_stays_inside_the_window() {
no_errors(&s, "tab settle");
}
/// **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.
/// Tab 2's `150` cap (`Blizzard_MacroUI.xml:511`) holds the row inside the drawn edge for any name.
#[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 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)] {
let mut s = harness_named(name);
s.run("ShowMacroFrame()").unwrap();
@@ -604,26 +495,11 @@ fn no_character_name_can_push_the_tab_row_off_the_window() {
}
}
/// **The hover highlight IS the tab, at every width and from the first frame** — the director's
/// report on the landed 1002 build: in one screenshot the glow stood proud of tab 1 and fell well
/// short of tab 2.
///
/// Both halves were the same defect, and it is 1002's own read-back trap in the one line 1002 did
/// not fix: the old kit sized the highlight from the label and then capped it at `tab:GetWidth()`,
/// which serves the last RESOLVED rect — inside a settle, the template's pre-fit 115. So every tab
/// in the client wore a 129-unit highlight regardless of its own width, and the settle's change-gate
/// latched it there. The fix is structural (the highlight anchors to the tab's two side edges), so
/// this test asserts the property that makes the whole class of bug impossible: **no frame, no
/// label, no clamp can put the glow at a width other than its tab's.**
#[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 a 40-character one — all three must
// hold the same property.
// A name under the stock cap, one over it, and a 40-character one, checked on every frame.
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
// highlight tracking its tab on EVERY frame is the property, not just on the first.
let mut s = harness_named(name);
s.run("ShowMacroFrame()").unwrap();
select_first(&s);
@@ -638,24 +514,11 @@ fn the_tab_highlight_is_exactly_its_tab() {
{tab}HighlightTexture:GetLeft(), {tab}HighlightTexture:GetRight()"
))
.unwrap();
// **The two tabs carry DIFFERENT highlight widths, and that is the reference's,
// not a loader bug.** Exactly one `<OnLoad>` runs — the most-derived one; the
// template's is installed and then *destroyed*, because `SetScript` releases the
// slot's single ref before it even looks at the new body (`0x7025ec`). So a tab
// gets whichever formula ITS OWN
// body ends with:
//
// * tab 2's body is just `PanelTemplates_TabResize(-15, nil, nil, 150)`, whose
// own last statement sets the highlight to `tabWidth` — so highlight == width;
// * tab 1's body RE-STATES `TabButtonTemplate`'s second line verbatim
// (`HighlightTexture:SetWidth(this:GetTextWidth() + 31)`) after calling
// TabResize — so its highlight is independent of its width, and 14 wider.
//
// That re-statement idiom is only necessary if the template's handler does not
// run, and the reference uses it deliberately: `FriendsFrame.xml:610`/`:899` copy
// both template lines while `:626`/`:881` declare none — a controlled pair in one
// file. Our retired transcription made the two tabs agree; the migration reverts
// that (decision 1848).
// The widths differ, as in the reference: only the most-derived `<OnLoad>` runs,
// since `SetScript` releases the slot's one ref before reading the new body
// (`0x7025ec`). Tab 2's ends in `PanelTemplates_TabResize`, which sets the
// highlight to the tab width; tab 1's then sets it to `GetTextWidth() + 31`, 14
// wider than the tab (`Blizzard_MacroUI.xml:488-489`, `:510-511`).
let label: f64 = s
.eval(&format!("return {tab}Text:GetStringWidth()"))
.unwrap();
@@ -671,11 +534,8 @@ fn the_tab_highlight_is_exactly_its_tab() {
hr - hl,
tr - tl
);
// …and seated where the reference seats it: `TabButtonTemplate` anchors the
// highlight `BOTTOM` with a `(2, -8)` offset, so it is **CENTRED** on the tab and
// nudged 2 right — not left-aligned at `tl + 2`, which is what this asserted while
// the template was ours (decision 1848). With the overhang above, centring is what
// keeps the extra 14 units split evenly rather than all trailing off one end.
// `TabButtonTemplate` anchors the highlight `BOTTOM` at `(2, -8)`: centred on the
// tab, 2 right (`UIPanelTemplates.xml:395-397`).
assert_eq!(
(hl + hr) / 2.0,
(tl + tr) / 2.0 + 2.0,
@@ -687,21 +547,6 @@ fn the_tab_highlight_is_exactly_its_tab() {
}
}
/// 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!();
+74 -282
View File
@@ -1,26 +1,6 @@
//! **The in-game interface** — its manifest, and the boot split that loads it in two phases.
//!
//! The *how* of loading any interface lives in [`super::addons`]: an [`Addon`] is a name, a parsed
//! `.toc`, and a source its files come from (decision 1184). What is specific to the default UI,
//! and therefore still here, is the **seam at index 0** — see [`load_default_ui`] — and the
//! **two sources one manifest names**, see [`load_manifest`].
//!
//! The manifest itself is not here either. It is [`MANIFEST`] — `assets/ui/benilla.toc`, an
//! ordinary addon manifest read by the ordinary `.toc` parser ([`benilla_ui::toc`]), exactly as a
//! third-party addon's is (decision 1178). Until then it was a hand-ordered `&[&str]` in this file,
//! which meant our own interface loaded by a private door and the addon path was untested by
//! construction.
//!
//! ## One ordered list, two stores (decision 1751)
//!
//! The end state for this interface is the stock 1.12 FrameXML run off the player's own install;
//! `assets/ui` is scaffolding that retires file by file ([`super::reference_ui`], whose header is
//! the rule). A manifest entry carrying a **path** is sourced off the chain, a **bare filename** is
//! one we ship — so the migration of a window is one line changing in `benilla.toc` plus the
//! deletion of our copy, and the manifest stays the single ordered truth of what loads when. That
//! ordering is the point: stock `ContainerFrame.xml` inherits four templates our earlier files
//! declare, so "source the reference first" — the only order a Lua-only mechanism could express —
//! is not a position it can load at.
//! The in-game interface: its manifest, [`MANIFEST`], and the boot split that loads it in two
//! phases. An entry with a path is the reference's own file off the player's patch chain, a bare
//! filename is one we ship, and the manifest's order is the load order across both.
use bevy::prelude::*;
@@ -29,21 +9,16 @@ use benilla_ui::script::UiScript;
use super::addons::Addon;
use super::reference_ui;
/// The built-in interface's manifest, relative to `assets/ui`. Its `## Interface:`/`## Title:`
/// directives are what `GetAddOnInfo` will read once the AddOn API lands (1178 step 4); nothing
/// consumes them yet.
/// The built-in interface's manifest, relative to `assets/ui`.
pub(super) const MANIFEST: &str = "benilla.toc";
/// The manifest's file list, in load order — a convenience over [`Addon::builtin`] for the tests
/// and the content sweep, which want the names rather than a loader. **Both stores**: filter with
/// [`reference_ui::is_chain_entry`] for one or the other.
/// The manifest's entries in load order, from both stores.
#[cfg(test)]
pub(super) fn manifest_files() -> Vec<String> {
Addon::builtin().toc.files
}
/// The manifest's entries that name a file **we ship** — everything [`reference_ui`] does not
/// source off the player's install. This is what a check about `assets/ui` wants.
/// The manifest's entries that name a file we ship rather than one off the player's install.
#[cfg(test)]
pub(super) fn shipped_manifest_files() -> Vec<String> {
manifest_files()
@@ -52,15 +27,8 @@ pub(super) fn shipped_manifest_files() -> Vec<String> {
.collect()
}
/// 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 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
/// bars' OnShow/OnHide. Every frame the table names exists by the time this runs, so this is that
/// load-time application; the stance bar's show/hide handles the rest at runtime.
/// Runs `UIParent_ManageFramePositions` after the manifest has loaded, then installs the load-time
/// repairs below. Never after `Fonts.xml` alone: `UIParent.lua` defines the pass.
fn bootstrap_positions(script: &UiScript) -> Vec<String> {
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
@@ -81,40 +49,11 @@ fn bootstrap_positions(script: &UiScript) -> Vec<String> {
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.
/// Deviation: the stock `FCF_ChatTabFadeFinished` (`FloatingChatFrame.lua:989-992`) nils `oldAlpha`
/// even under a live hover, so leaving and re-entering within `CHAT_FRAME_FADE_TIME` (a flick to
/// the scroll buttons, outside the hover box, does it) stops the plate fading in (l.873) for the
/// rest of the session; this keeps `oldAlpha` while `hover` is set. Redefined, not wrapped, so a
/// `ReloadUI` re-run cannot stack.
pub(super) fn install_chat_plate_guard(script: &UiScript) -> Result<(), String> {
script.run(CHAT_PLATE_GUARD).map_err(|e| e.to_string())
}
@@ -130,47 +69,21 @@ if FCF_ChatTabFadeFinished then
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
/// pitch and the debuff row's anchor come from `BuffButtons_UpdatePositions`, which it never calls.
/// In 1.12 that is applied by `UIOptionsFrame.lua`, the file that owns the setting; we have no
/// counterpart to it (our row lives on `OptionsFrame.xml`, whose `applyFunc` fires on a CHANGE),
/// so without this the bar ships laid out for durations-OFF while the setting says on — a 10px
/// pitch error on every buff and a debuff row anchored to the wrong frame.
///
/// Here rather than in a FrameXML file for the same two reasons as the durability hook above:
/// `assets/ui` does not grow (1779), and this is our stand-in for a file the reference has and we
/// do not, which makes Rust the durable home.
///
/// Guarded, because the font-registry-only load has no buff bar.
/// Runs `BuffButtons_UpdatePositions` at load, which `BuffFrame_OnLoad` never calls: until the
/// stock `UIOptionsFrame.lua`'s `VARIABLES_LOADED` arm runs it (l.206), the second buff row and
/// the debuff row keep `BuffFrame.xml`'s anchors whatever `SHOW_BUFF_DURATIONS` says.
pub(super) fn apply_buff_durations(script: &UiScript) -> Result<(), String> {
script
.run("if BuffButtons_UpdatePositions then BuffButtons_UpdatePositions() end")
.map_err(|e| e.to_string())
}
/// **What a screen-size change re-runs** — called from `extract::tick_script`'s resize arm, the
/// frame [`UiScript::set_screen_size`] reports a change on. Anchors follow the new screen rect by
/// themselves; what does not is a size or seat somebody COMPUTED from the old one.
///
/// The bottom-stack manage pass (decision 1499): the open-bag stack starts a fresh column when
/// the current one would run off the top, and that decision is made from `GetScreenHeight()` at
/// layout time. Without this, dragging the window smaller leaves the bag columns wrapped for the
/// old height until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
/// which is in-game UI, and the resize arm also runs on the glue screens.
///
/// **And the three full-screen quads the stock files size once** — a stated repair of a reference
/// gap, in the same posture as [`install_durability_reseat`]: nothing of Blizzard's is edited.
/// `WorldMapFrame_OnLoad` sizes `BlackoutWorld` and `CinematicFrame_OnLoad` sizes
/// `UpperBlackBar`/`LowerBlackBar` from `GetScreenWidth()`/`GetScreenHeight()` at load, and no
/// stock handler touches them again (no `DISPLAY_SIZE_CHANGED` listener) — the reference could
/// afford that because its screen changed only across a restart. 2242 fixed the load edge; this
/// is the other edge: after a window resize or a `uiScale` change the blackout stopped short of
/// the new screen and the world showed through beside the map. [`FULLSCREEN_QUADS_RESEAT`] is the
/// two OnLoads' own arithmetic re-run, so a quad after a resize is exactly what a fresh load at
/// the new size would have made. A stateless chunk rather than an installed hook, so a `ReloadUI`
/// has nothing to re-install and nothing can stack.
/// What a screen-size change re-runs, from `extract::tick_script`'s resize arm: anchors follow the
/// screen, but not a size or seat computed from the old one. The managed pass re-wraps the open
/// bags, whose columns `updateContainerFrameAnchors` lays out by `GetScreenHeight()`.
/// Deviation: [`FULLSCREEN_QUADS_RESEAT`] re-sizes the full-screen quads, which the stock files
/// size only in their OnLoads, because a resize or `uiScale` change here keeps the loaded UI and
/// the blackout would stop short of the new screen.
pub(super) fn on_screen_resized(script: &UiScript) {
let _ = script.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
if let Err(e) = script.run(FULLSCREEN_QUADS_RESEAT) {
@@ -178,11 +91,10 @@ pub(super) fn on_screen_resized(script: &UiScript) {
}
}
/// `WorldMapFrame.lua` `WorldMapFrame_OnLoad` l.19-28 and `CinematicFrame.lua`
/// `CinematicFrame_OnLoad` l.5-19, verbatim in their arithmetic, each existence-guarded (the
/// glue screens have neither). One addition to the letterbox: the stock OnLoad does nothing below
/// 4:3, which leaves the bars at `CinematicFrame.xml`'s declared `1024 x 128` — so a resize to
/// below 4:3 puts that declared size back, rather than keeping a wide screen's recompute.
/// The sizing arithmetic of `WorldMapFrame_OnLoad` (`WorldMapFrame.lua:19-28`) and
/// `CinematicFrame_OnLoad` (`CinematicFrame.lua:6-21`), each existence-guarded: the glue screens
/// have neither. Below 4:3 the bars go back to `CinematicFrame.xml`'s declared 1024 x 128, where
/// the stock OnLoad leaves them.
const FULLSCREEN_QUADS_RESEAT: &str = r#"
if BlackoutWorld then
local width = GetScreenWidth()
@@ -213,27 +125,11 @@ if UpperBlackBar and LowerBlackBar then
end
"#;
/// **A stated divergence from the reference, installed rather than edited in** (1751 window 4).
///
/// The reference seats `DurabilityFrame` 20 further in when one of its three side glyphs is up
/// (`UIParent.lua` l.1759-1768), and recomputes that only when something calls
/// `UIParent_ManageFramePositions`. Stock `DurabilityFrame.xml` calls it from `OnShow`/`OnHide`
/// only — so the case where the frame is **already shown** and a side glyph *then* appears leaves
/// the seat stale, and the shield glyph's ~17-unit overhang hangs off the screen edge until some
/// unrelated frame happens to trigger the next pass. That is the reference's own bug and the
/// director caught it on their screen; it is not ours to reproduce faithfully.
///
/// So the fix rides on the frame's `OnEvent` — which is exactly the recompute the glyphs change on
/// — and it is installed **here** rather than written into anything of Blizzard's. Two reasons for
/// 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` 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
/// set for the dispatch, so calling it through the captured reference is the same call.
/// Deviation: re-runs `UIParent_ManageFramePositions` after `DurabilityFrame`'s own OnEvent. The
/// pass seats the frame 20 further in while a side glyph shows (`UIParent.lua:1759-1768`), but the
/// stock frame runs it only from OnShow/OnHide, so a glyph appearing while the frame is shown
/// leaves the shield's overhang off the screen edge. Latched, so a `ReloadUI` cannot stack it; the
/// stock handler runs first, unchanged, on the `this`/`event`/`arg1` already set for the dispatch.
pub(super) fn install_durability_reseat(script: &UiScript) -> Result<(), String> {
script.run(DURABILITY_RESEAT).map_err(|e| e.to_string())
}
@@ -249,24 +145,12 @@ if DurabilityFrame and not DurabilityFrame.benillaReseat then
end
"#;
/// **The UI load's sound-suppression bracket — the one site both load paths go through.**
///
/// `CGGameUI::Initialize 0x48fbf0` brackets ITSELF in the counted suppression scope —
/// `0x48fbfa call 0x458f50` on entry, `0x49016d call 0x458f60` on exit — across the TOC walk,
/// Bindings.xml, the AddOns, the saved variables and the world-enter cascade it calls at
/// `0x490168` (`PLAYER_LOGIN`), so both of its callers (login `0x48f681` and `/reloadui`
/// `0x495669`) load without a sound. The only reader of that depth is `PlaySoundByName
/// 0x458030`, which drops the call outright.
///
/// This is the mechanism, not a workaround for one noisy handler: stock `TargetFrame_OnHide`
/// really does fire at load (the frame ships shown and its OnLoad hides it) and really does call
/// `PlaySound("INTERFACESOUND_LOSTTARGETUNIT")`. The engine throws it away. Decision 1033 reached
/// the right rule from the director's ear; this is the binary agreeing.
///
/// One function rather than a push/pop pair at each caller because the production edge
/// ([`super::load_ingame_ui_on_world_entry`]) spent its life unbracketed while the tests' whole-
/// manifest load was — every `/reload` played the lost-target click. Generic over the borrow so
/// the `&UiScript` test loader and the owning production edge share it.
/// Runs a UI load in the counted sound-suppression scope, as `CGGameUI::Initialize` (`0x48fbf0`,
/// called at login `0x48f681` and `/reloadui` `0x495669`) runs its own: enter (`0x458f50`) at
/// `0x48fbfa`, leave (`0x458f60`) at `0x49016d`, and `PlaySoundByName` (`0x458030`) drops every
/// call in between. `PLAYER_LOGIN` fires inside it only on `/reloadui` (`0x490168`); a fresh login
/// fires it from the player's create (`0x5deb60` to `0x4908c0`), a cascade with its own scope
/// (`0x4908d5` to `0x490a56`). The stock `TargetFrame_OnHide` at load is one sound it swallows.
pub(super) fn silenced_ui_load<S: std::borrow::Borrow<UiScript>, R>(
script: &mut S,
body: impl FnOnce(&mut S) -> R,
@@ -277,52 +161,22 @@ pub(super) fn silenced_ui_load<S: std::borrow::Borrow<UiScript>, R>(
out
}
/// Load benilla's own default UI — every file [`MANIFEST`] names — through the engine-free loader.
/// This is our content (MIT/Apache), committed and **compiled into the binary**
/// ([`super::content`], decision 1175); a dev build still prefers the copy on disk, so editing a
/// FrameXML file costs no recompile. Textures (`Interface\…`) still resolve at render through the
/// MPQ `sprite_texture` path; the loader only needs the XML/Lua text.
///
/// Returns every loader error, tagged `"<Addon>/<file>: <error>"` — the app ignores the value (each
/// is already logged as it happens) and [`shipped_xml_tests`] asserts it empty. Before that
/// assertion a broken entry — a bad file name, a frame that collides with a later window's, a
/// template referenced before its definer — reached a real run with nothing but a log line. Capture
/// runs cannot cover it either: they skip this function entirely unless `WOW_CAPTURE_UI=1`.
///
/// **Split across the boot boundary (1051).** `Fonts.xml` — the manifest's first entry, zero frames
/// materialized — is the font-object registry the glyph atlas bakes its plan from, and our native
/// glue screens share that one atlas, so it must exist before the login screen. Everything after it
/// is in-game UI and loads at world entry ([`load_ingame_ui`]). This whole-manifest entry point
/// stays for the tests, which assert over the complete shipped set — production now loads in two
/// phases, so production has no caller for the whole-manifest form — the tests do, and so does
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
/// Loads every [`MANIFEST`] entry at once, then the load-time repairs. Production splits this
/// load at boot ([`load_font_registry`], [`load_ingame_ui`]), so the callers are the tests and
/// [`crate::addon_harness`]. Returns every failure; a loader error is tagged
/// `"<Addon>/<file>: <error>"`.
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.
// The client's CVar table first: the stock `UIOptionsFrame.xml` reads CVars in its OnLoads,
// and a bare `UiScript::new()` has only `benilla-ui`'s own. A re-register only refreshes
// defaults.
script.register_cvars(crate::cvars::registered_pairs());
// Silent, the way the client's own load is — see [`silenced_ui_load`].
let mut failures = silenced_ui_load(&mut &*script, |script| {
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
failures.extend(bootstrap_positions(script));
failures
});
// **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.
// A raise one call below the loader's own dispatch (an OnLoad that shows a frame whose OnShow
// raises) lands in the VM's error list, not the loader's report; it is a load failure too.
failures.extend(
script
.errors()
@@ -332,13 +186,8 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
failures
}
/// Load a slice of [`MANIFEST`] entries, **each from its own store** (decision 1751).
///
/// A bare filename is a file we ship and comes from [`Addon::builtin`]; a path is the reference's
/// own file and comes off the player's installed chain ([`reference_ui`]). The dispatch is
/// 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.
///
/// Loads a slice of [`MANIFEST`] entries, each from its own store and one at a time, so the
/// manifest's order is the load order across both.
fn load_manifest(script: &UiScript, files: &[String]) -> Vec<String> {
let builtin = Addon::builtin();
let reference = reference_ui::addon(
@@ -360,12 +209,10 @@ fn load_manifest(script: &UiScript, files: &[String]) -> Vec<String> {
failures
}
/// The font-object registry alone (`Fonts.xml`), loaded at `Startup` — see [`load_default_ui`].
///
/// Verified lossless for the atlas: the full manifest and this file alone both yield the **same 19
/// distinct `(font, height, outline)` combinations**. The three font objects defined outside it
/// (`GameFontNormalMed1` 13, `OptionsFontHighlightMedium` 14, `OptionsFontHighlightHuge` 20) are
/// un-outlined and their heights are already declared here, so they add nothing to the bake plan.
/// The font-object registry alone, `Fonts.xml`, loaded at `Startup`.
/// Deviation: it loads before the login screen because benilla's glue screens share its glyph
/// atlas; the reference's glue has its own `GlueFonts.xml`. The font objects declared outside it
/// (FRIZQT 13, 14 and 20, un-outlined) repeat combinations it has, so the atlas plan loses nothing.
pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
load_manifest(
script,
@@ -373,48 +220,29 @@ pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
)
}
/// The in-game UI — everything after the font registry — loaded on entering the world, and then
/// **every third-party addon** ([`super::addons`], decision 1184).
/// The in-game UI, every entry after `Fonts.xml`, then every third-party addon, on entering the
/// world. The reference loads both in `CGGameUI::Initialize` (`0x48fbf0`), reached only from world
/// entry (`0x401570` from `0x46c236`), its addons at `0x4900a3` (`0x51f600`).
///
/// The reference does the same at `CGGameUI::Initialize 0x48fbf0`, reached only from world entry
/// (`0x401570` ← `0x46c236`), and loads its addons from that same function (`0x4900a3` →
/// `0x51f600`); its glue screens run GlueXML with their own `GlueFonts.xml` registry, which is why
/// the reference has no equivalent of our shared-atlas coupling (1051).
///
/// Addons load **after** the built-in interface, not interleaved with it: an addon may reference
/// our templates and globals (that is the point of 1178's seam), and nothing of ours may depend on
/// an addon.
///
/// `identity` is `(realm, character)`, which names this character's AddOn enable-state file — the
/// reference keys `AddOns.txt` per character too — and `roster` is every character on that realm's
/// list, which is the enable store's node set (decision 2311: an addon this character has no row
/// for is resolved from what the *other* characters said, never from a bare "enabled"). `None`
/// with an empty roster is the no-pick case: every addon falls to its own `## DefaultState`.
///
/// `version_check` is the persisted `checkAddonVersion` — the *Load out of date AddOns* toggle,
/// inverted — resolved by the caller because at load time this VM's own CVar table does not
/// exist yet (registration is a per-VM `Update` seed, decision 1291); the persisted value is the
/// truth the reference's live read would land on, since the session edge folds the dying VM's
/// table into it before any rebuild reads it.
/// `identity`, `(realm, character)`, names the character's AddOn enable-state file, as the
/// reference keys `AddOns.txt` per character; `roster`, the realm's characters, decides an addon
/// this one has no row for. `version_check` is the persisted `checkAddonVersion`, since this VM
/// has no CVar table yet at load.
pub(crate) fn load_ingame_ui(
script: &mut UiScript,
identity: Option<&(String, String)>,
roster: &[String],
version_check: bool,
) -> Vec<String> {
// The whole load edge runs bounded (decision 1306): the reference files sourced off the
// player's own chain, our builtin, and every addon (which re-arms per addon in
// `load_third_party`). A chunk that never returns fails as a load error instead of freezing
// the client on the loading screen; the caller disarms once the edge is done
// (`lifecycle::load_ingame_ui_on_world_entry`), so the session's steady state runs unhooked.
// Bounded, addons included: a chunk that never returns fails as a load error instead of
// freezing the loading screen. The caller disarms the budget once the edge is done.
script.set_instruction_budget(super::addons::LOAD_INSTRUCTION_BUDGET);
let mut failures = load_manifest(
script,
Addon::builtin().toc.files.get(1..).unwrap_or_default(),
);
failures.extend(bootstrap_positions(script));
// `&mut` from here down: each addon's `ADDON_LOADED` fires as that addon finishes, which is
// the reference's own interleaving (`0x51f5ad`, per addon) rather than a batch at the end.
// Each addon's `ADDON_LOADED` fires as that addon finishes, as the reference does (`0x51f5ad`).
failures.extend(super::addons::load_third_party(
script,
identity,
@@ -428,10 +256,8 @@ pub(crate) fn load_ingame_ui(
mod tests {
use super::*;
/// The manifest parses as a `.toc`, declares the build it targets, and splits where the loader
/// splits it. `Fonts.xml` first is not tidiness: [`load_font_registry`] takes entry 0 and
/// [`load_ingame_ui`] takes the rest, so a reordering here silently moves a real file across
/// the boot boundary (1051) — into the glue screens' phase, or out of the atlas bake plan.
/// `Fonts.xml` must stay entry 0: [`load_font_registry`] loads entry 0 at startup and
/// [`load_ingame_ui`] the rest.
#[test]
fn the_manifest_is_a_toc_that_starts_with_the_font_registry() {
let toc = Addon::builtin().toc;
@@ -444,12 +270,7 @@ mod tests {
);
}
/// The manifest and `assets/ui` describe the same interface, both ways.
///
/// An entry naming a file we do not ship is a log line per entry and an empty screen (what
/// `content::tests::every_manifest_entry_is_compiled_in` catches). The other direction is the
/// one nothing caught before: a FrameXML file added to `assets/ui` and never listed here is
/// simply never loaded, and the symptom is a window that does not exist rather than an error.
/// A shipped `.xml` the manifest does not list never loads, with no error to say so.
#[test]
fn the_manifest_lists_every_shipped_file_and_nothing_else() {
let mut listed = shipped_manifest_files();
@@ -462,13 +283,8 @@ mod tests {
assert_eq!(listed, shipped);
}
/// **The shipped tree is FLAT, and that is what makes the two stores tellable apart.**
///
/// [`reference_ui::is_chain_entry`] decides where a manifest entry comes from by asking
/// whether it carries a path separator (1751). That is only decidable while every file we
/// ship is a bare name — the day somebody adds `assets/ui/templates/Foo.xml`, its manifest
/// entry would be read as a chain path, and the symptom would be a window that silently does
/// not exist rather than an error. This is that day's failing test.
/// [`reference_ui::is_chain_entry`] reads a path separator as a chain entry, so a shipped file
/// in a subdirectory would be looked for on the player's install instead.
#[test]
fn every_file_we_ship_is_a_bare_name_so_a_path_can_only_mean_the_chain() {
for name in super::super::content::shipped_files() {
@@ -479,10 +295,7 @@ mod tests {
}
}
/// Every `Interface\…` entry the manifest names is really in the 1.12 chain, and is really
/// **not** something we also ship under that basename.
///
/// Skips without client data, like every other test that reads the install.
/// Every chain entry the manifest names is in the player's 1.12 patch chain.
#[test]
fn every_chain_entry_resolves_off_the_players_install() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -498,27 +311,8 @@ mod tests {
}
}
/// **Nothing may declare `parent="UIParent"` before `UIParent.xml` has loaded.**
///
/// A parent name is resolved at LOAD, and a name that does not exist yet is not an error: the
/// loader warns and silently falls back to the enclosing frame. So this ordering mistake does
/// 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: 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.
/// The stock pass (`UIParent.lua:1592-1775`) indexes these frames unguarded, so a file that
/// runs it at load must follow every file it reads, as the reference's toc orders them.
#[test]
fn every_load_time_runner_of_the_managed_pass_follows_what_it_reads() {
let files = manifest_files();
@@ -552,14 +346,14 @@ mod tests {
"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.
// The shapeshift arm (l.1705-1732): the stance bar's file declares these and also runs
// the pass, so it must precede the other runner.
(
"ShapeshiftBarLeft / Middle / Right",
"BonusActionBarFrame.xml",
),
];
// Who runs it at LOAD: an OnLoad, or the OnShow of a frame its OnLoad shows.
// Who runs it at load: an OnLoad, or the OnShow of a frame its OnLoad shows.
const RUNNERS: &[(&str, &str)] = &[
(
"ShapeshiftBar_OnLoad → Show → OnShow",
@@ -593,9 +387,7 @@ mod tests {
.expect("the manifest lists UIParent.xml");
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
for early in &files[..at] {
// A chain entry is the player's file, not one on this machine — and the reference's
// own load order is what this test exists to preserve, so it cannot be the thing that
// violates it. `every_chain_entry_resolves_off_the_players_install` covers those.
// A chain entry is a stock file off the player's install, not one on disk here.
if reference_ui::is_chain_entry(early) {
continue;
}
@@ -1,8 +1,4 @@
//! 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.
//! The stock merchant window (`MerchantFrame.xml`), engine-only, fed a synthetic stock and purse.
use benilla_ui::script::{
ContainerState, DressUpIntent, ExtractedQuad, ItemStatsHead, MerchantItem, MerchantState,
@@ -11,10 +7,8 @@ use benilla_ui::script::{
use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI};
/// Put a bag carrying `num_slots` empty slots in bag `id` — the fixture the three bag tests below
/// need, and one they did NOT need before 1751's swap. The reference's `OpenBag` opens nothing for
/// a container whose `GetContainerNumSlots` is 0 (ContainerFrame.lua l.144-165); our five
/// hand-authored windows showed either way, so these tests used to name no container at all.
/// Put a bag of `num_slots` empty slots in bag `id`: the stock `OpenBag` opens nothing for a
/// container with no slots (`ContainerFrame.lua:152-153`).
fn equip_bag(s: &mut UiScript, id: i64, name: &str, num_slots: u32) {
s.set_container(
id,
@@ -26,8 +20,7 @@ fn equip_bag(s: &mut UiScript, id: i64, name: &str, num_slots: u32) {
);
}
/// Find a bare frame's own rect via its `QuadContent::Frame` entry (every frame emits one, at its
/// resolved rect, whether or not it paints anything itself — `UiScript::extract`'s doc).
/// The rect of the first bare-frame quad sized `w` by `h`; every frame emits one.
fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Rect {
quads
.iter()
@@ -40,35 +33,24 @@ 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 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),
/// both rows' icons + a price + the money line rendering, a row click queuing the right buy
/// intent, and MERCHANT_CLOSED hiding it through HideUIPanel, vacating the left slot.
/// Two items and a purse: `MERCHANT_SHOW` opens the window in the left UIPanel slot, the rows and
/// coins paint, a right-click buys, and `MERCHANT_CLOSED` hides it and vacates the slot.
#[test]
fn shipped_merchant_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The named virtual Font objects the re-skinned rows/title inherit through — loaded first at
// runtime (ui_script's shipped list) so `inherits="GameFontNormalSmall"` resolves here too.
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
// The STOCK file's frame census (1751). Ours materialized 63 — window, twelve rows, close, the
// three-slot coin kit's 39 slots, the buyback slot, and the repair/page/tab pairs. The reference's
// shape is different in kind, not just in count: every row is an inert container PLUS a
// `$parentItemButton` that carries `ItemButtonTemplate`'s own children, and the prices are
// `SmallMoneyFrameTemplate` instances rather than our `<prefix>Coin1..3` chain. A count is a
// fingerprint of a file; this one is the reference's.
// The census: every row is a container plus an `ItemButtonTemplate` button, and every price a
// `SmallMoneyFrameTemplate` (`MerchantFrame.xml:4-115`).
assert_eq!(
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"),
90,
"the stock file's own shape — see the census note above"
);
// Hidden by default: no vendor icon on screen.
s.resolve();
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
quads.iter().any(|q| {
@@ -80,7 +62,6 @@ fn shipped_merchant_frame_drives_end_to_end() {
"merchant window starts hidden"
);
// The app's feed: a purse + a two-item stock (one unlimited, one finite).
s.set_money(12_345); // 1g 23s 45c
s.set_merchant(Some(MerchantState {
items: vec![
@@ -112,8 +93,6 @@ fn shipped_merchant_frame_drives_end_to_end() {
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The window is shown (ShowUIPanel put it on the left slot), both row icons rendered, a price
// + the purse line painted.
assert!(s.eval::<bool>("return MerchantFrame:IsVisible()").unwrap());
s.resolve();
let quads = s.extract();
@@ -127,9 +106,6 @@ fn shipped_merchant_frame_drives_end_to_end() {
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(x), .. } if x == t))
};
// Prices + purse now render as coin icons (Interface\MoneyFrame\UI-MoneyIcons) + numbers, the
// real SmallMoneyFrame look, not "Xg Ys Zc" text. Row 1 costs 25c → the number "25" + a copper
// coin; the purse (12345 = 1g 23s 45c) shows "1"/"23"/"45" over gold/silver/copper coins.
assert!(has_icon(&quads, "UI-MoneyIcons"), "coin icons render");
assert!(has_text("25"), "row 1 price shows the copper count '25'");
assert!(
@@ -137,9 +113,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
"purse shows its silver/copper counts"
);
// Item 4: every one of the 10 slots always renders its plate art — the empty-slot socket + the
// dark label plate — whether the row is filled or not. The 2 filled rows keep the socket
// full-bright; the 8 empty rows dim it to 0.4 (ref MerchantFrame.lua l.108/115).
// A filled row's socket is bright, an empty one's 0.4 (`MerchantFrame.lua:108`, `:115`).
let socket_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
@@ -151,16 +125,13 @@ fn shipped_merchant_frame_drives_end_to_end() {
_ => None,
})
.collect();
// 10 merchant rows + the buyback slot (rows 11/12 are hidden on the merchant tab).
// Rows 11 and 12 are hidden on the merchant tab (`MerchantFrame.lua:181-182`).
assert_eq!(
socket_colors.len(),
11,
"10 merchant rows + the buyback slot render their socket plate"
);
// THREE, not two, and the third is the buyback slot. Our file dimmed an empty buyback socket
// with the empty rows; the reference's `MerchantFrame_UpdateBuybackInfo` does not run the
// empty-row arm on it at all, so it keeps the bright plate whether or not a sold item is
// there. Same census, one socket moved sides.
// Three bright: the merchant page never tints the buyback slot (`MerchantFrame.lua:133-152`).
assert_eq!(
socket_colors.iter().filter(|c| c[0] > 0.9).count(),
3,
@@ -186,10 +157,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
"10 row label plates + the buyback slot's render"
);
// The slot anchor actually applied: the window's rect top-left sits at (0, 664) — screen
// height 768 minus the left slot's 104px drop (pin §4's extract-rect assertion). The re-skinned
// window has no solid-colour fill (the real quadrant art is opaque), so it's found by its own
// 384×512 frame quad rather than a background texture.
// `SetLeftFrame` seats the window at TOPLEFT 0, -104 (`UIParent.lua:818`): top at 768 - 104.
let win = frame_rect(&quads, 384.0, 512.0);
assert_eq!(
(win.left, win.top),
@@ -197,9 +165,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
"merchant window landed at the left slot (TOPLEFT UIParent, 0, -104)"
);
// The four quadrant slabs ARE the window art (ref-MerchantFrame.xml l.146-176): 256-wide left
// halves, 128-wide right halves, each 256 tall, pinned to their corner, each sampling its whole
// texture (no TexCoords — the ref uses those only on the bottom-border/repair art we omit).
// The four quadrants, each pinned to its corner, whole texture (`MerchantFrame.xml:146-177`).
let quad_of = |needle: &str| {
quads
.iter()
@@ -273,8 +239,6 @@ fn shipped_merchant_frame_drives_end_to_end() {
.unwrap_or_else(|| panic!("no text quad for {t:?}"))
};
// The icon sits at the row-left as a ~37px square — NOT stretched across the whole row (the
// smeared-merchant regression this whole change fixes).
let icon_rect = tex_rect("INV_Drink_18");
assert!(
(icon_rect.width() - 37.0).abs() < 0.5 && (icon_rect.height() - 37.0).abs() < 0.5,
@@ -283,12 +247,9 @@ fn shipped_merchant_frame_drives_end_to_end() {
icon_rect.height()
);
// Name (upper) and price coins (lower) stack the real name-over-money way (ref l.34/99-106): the
// name FontString's box sits above the price number, so their rects don't overlap. (The engine
// quad carries the full name string — word wrapping into lines is the app-side ui_text pass.)
// The name sits above the price (`MerchantFrame.xml:34-45`, `:99-106`); rects are y-up.
let name_rect = text_rect("Refreshing Spring Water");
let price_rect = text_rect("25");
// Rects are y-up (top > bottom): the name's interval sits entirely above the price's.
let overlap = name_rect.left < price_rect.right
&& price_rect.left < name_rect.right
&& name_rect.bottom < price_rect.top
@@ -302,8 +263,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
"name {name_rect:?} sits above price {price_rect:?}"
);
// RIGHT-click row 1 → BuyMerchantItem(1); LEFT-click does NOT buy (pickup pending the cursor
// arc). The icon center lies inside the row button.
// A right-click buys; a plain left-click is `PickupMerchantItem` (`MerchantFrame.lua:329`).
let (cx, cy) = (
(icon_rect.left + icon_rect.right) * 0.5,
(icon_rect.bottom + icon_rect.top) * 0.5,
@@ -318,8 +278,6 @@ fn shipped_merchant_frame_drives_end_to_end() {
s.mouse_button(cx, cy, "RightButton", false);
assert_eq!(s.take_merchant_buys(), vec![(1, 1)]);
// MERCHANT_CLOSED hides the window through HideUIPanel — the icons go away and the left slot
// vacates.
s.fire_event("MERCHANT_CLOSED", vec![]);
s.resolve();
assert!(
@@ -333,10 +291,8 @@ fn shipped_merchant_frame_drives_end_to_end() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The vendor window's open/close kits — the window-sound convention (decision 0090). The real
/// MerchantFrame.xml frame Scripts play igCharacterInfoOpen on OnShow (l.721) and igCharacterInfoClose
/// on OnHide (l.714); MERCHANT_SHOW → ShowUIPanel → Show() fires OnShow, MERCHANT_CLOSED → HideUIPanel
/// → Hide() fires OnHide. Nothing queues at load (the frame is authored hidden="true").
/// The window's OnShow plays `igCharacterInfoOpen` and its OnHide `igCharacterInfoClose`
/// (`MerchantFrame.xml:721`, `:714`); nothing plays at load, where the frame starts hidden.
#[test]
fn merchant_show_hide_plays_open_and_close_kits() {
benilla_formats::wow_data_or_skip!();
@@ -345,10 +301,9 @@ 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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
assert!(
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
@@ -373,29 +328,17 @@ fn merchant_show_hide_plays_open_and_close_kits() {
);
}
/// With the bags loaded, opening the vendor also opens them (the real MerchantFrame_OnShow →
/// OpenBackpack, decision 0095; ALL equipped bags since decision 0561 — here only the backpack is
/// carried), so the backpack kit plays ALONGSIDE the panel kit — the "two sounds go together" the
/// director heard. On close, CloseBackpack hides the bag it opened, so both close kits play. The
/// bag window's own OnShow/OnHide fire first (OpenBackpack shows it before the panel sound), so the
/// backpack kit leads each pair.
///
/// **What 1751's swap changed, and what it did not.** The behaviour is the same and the sound
/// pairs are unchanged; the window is now the reference's own recycled `ContainerFrame` rather than
/// `BenillaBagFrame`, so the open assertion asks `IsBagOpen(0)` instead of naming a frame, and the
/// two bag kits come from `ContainerFrame_OnShow`/`_OnHide`'s own `PlaySound` calls
/// (ContainerFrame.lua l.140 / l.120). The seeded backpack is new and load-bearing — see
/// [`equip_bag`].
/// `MerchantFrame_OnShow` calls `OpenBackpack` before the window's own sound
/// (`MerchantFrame.lua:35`), so the bag's kit (`ContainerFrame.lua:140`, `:120`) leads each pair.
#[test]
fn vendor_open_opens_the_backpack_and_layers_the_sound() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// `BAG_UI` already carries GameTooltip.xml, so the app's tooltip-before-merchant order holds.
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -428,17 +371,8 @@ fn vendor_open_opens_the_backpack_and_layers_the_sound() {
assert!(!bag_open(&s, 0), "…and the window goes with it");
}
/// A backpack the player already had open is NOT closed when the vendor closes: opening the vendor
/// over an already-open bag plays only the panel kit, and closing plays only the panel close kit —
/// the bag stays.
///
/// The guard is a **was-open memory**, and 1751 left two spellings of it in the tree: the
/// reference's own single `ContainerFrame1.backpackWasOpen` flag (ContainerFrame.lua l.187-202) and
/// `UiPanels.xml`'s bag-id-keyed `BENILLA_BAG_WAS_OPEN`, which 0561's all-bags `OpenBackpack` needs
/// because the windows are recycled now (frame N is a different bag every time it opens). Whichever
/// body the manifest leaves live, they spell bag 0's case identically — so this test pins the
/// PROPERTY and stays honest either way. Which one is actually live is the next test's subject,
/// not this one's.
/// A bag the player already had open stays open when the vendor closes: the was-open memory, per
/// bag in `BENILLA_BAG_WAS_OPEN` as in the stock `backpackWasOpen` (`ContainerFrame.lua:198-201`).
#[test]
fn vendor_leaves_an_already_open_backpack_alone() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -447,13 +381,12 @@ 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, "ScrollTemplates.xml"); // our scroll kits
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);
// Open the bag first (the 'B' toggle), then the vendor over it.
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0), "fixture: the player's own bag is up first");
let _ = s.take_sounds();
@@ -477,18 +410,9 @@ fn vendor_leaves_an_already_open_backpack_alone() {
);
}
/// The all-bags divergence (decision 0561, director's call): the vendor opens EVERY equipped bag
/// with it — not only the backpack like the ref's OpenBackpack — and closes every bag it opened.
/// Backpack + one equipped bag in slot 2 (bags 1/3/4 unequipped → no window): MERCHANT_SHOW opens
/// both windows, MERCHANT_CLOSED closes both.
///
/// This is also the test that decides WHOSE `OpenBackpack` is live. 1751 keeps 0561 by shadowing:
/// `UiPanels.xml` redefines `OpenBackpack`/`CloseBackpack`/`CloseAllBags` over the sourced
/// `ContainerFrame.lua`'s, and its body now loops the reference's `OpenBag`/`CloseBag` rather than
/// five windows by name — so the "unequipped slot" arm below is the reference's own `size > 0` test
/// rather than ours. A shadow only wins if it loads LATER (`reference_ui`'s rule: order decides,
/// the manifest is the order), so this test is exactly the falsifier for that ordering: with the
/// reference's own body live, bag 2 never opens and only the backpack does.
/// Deviation: the vendor opens every equipped bag, not only the backpack as the stock
/// `OpenBackpack` does, so every bag is up to sell from. `ContainerFrameAdapters.xml` replaces the
/// verb after the stock file loads; with the stock body live, bag 2 would not open.
#[test]
fn vendor_opens_and_closes_all_equipped_bags() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -497,7 +421,7 @@ 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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(0);
@@ -522,12 +446,9 @@ fn vendor_opens_and_closes_all_equipped_bags() {
);
}
/// Switching vendors (right-click vendor B while vendor A's window is open) is a real close+open —
/// the client's ShowUIPanel early-returns when the frame is visible, so the open kit only re-plays
/// after a hide (decision 0096). The feed fires MERCHANT_CLOSED then MERCHANT_SHOW; this drives that
/// exact sequence over the shipped XML and asserts BOTH the close-then-open kit order AND that the
/// MERCHANT_CLOSED's OnHide queued a close intent — the intent the feed then consumes so the drain
/// doesn't clear the vendor it just re-opened to.
/// Switching vendors is a close then an open, since `ShowUIPanel` returns early on a visible frame
/// (`UIParent.lua:650-652`). The close's OnHide queues a `CloseMerchant` the feed must consume, or
/// the drain would clear the vendor just reopened.
#[test]
fn merchant_switch_plays_close_then_open_and_queues_the_consumable_close() {
benilla_formats::wow_data_or_skip!();
@@ -536,17 +457,15 @@ 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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// Vendor A open.
s.set_money(0);
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![]);
let _ = s.take_sounds();
let _ = s.take_merchant_close();
// The switch to vendor B — the feed's close+open sequence.
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_CLOSED", vec![]);
s.fire_event("MERCHANT_SHOW", vec![]);
@@ -566,32 +485,20 @@ fn merchant_switch_plays_close_then_open_and_queues_the_consumable_close() {
);
}
/// The vendor hover chain over the shipped XML (MerchantFrame + GameTooltip): the row highlight is
/// scoped to the 37px icon (the real ItemButtonTemplate's own-button glow — never the whole 153px
/// row), the tooltip anchors at the icon's TOPRIGHT (the real client's owner is the 37px
/// ItemButton; ours walks the ANCHOR_RIGHT offset back), and `SetMerchantItem` renders the real
/// item-tooltip stat head — quality-coloured name, slot|type, damage|speed + dps, armor, block —
/// with no buy-price line (the price is on the row). Leaving the row hides it all.
/// Hovering a row's icon: the icon button's own 37px glow, a tooltip it owns at `ANCHOR_RIGHT`
/// (`MerchantFrame.xml:64`), and the item's stat head with no buy-price line.
#[test]
fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
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.
// The stock tooltip sizes from its lines, so the harness needs a measurer.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
for f in super::test_ui::MERCHANT_UI {
load_xml(&s, f);
}
// The quality→colour table is `ITEM_QUALITY_COLORS`, and Fonts.xml is its home — this used
// to load the whole loot window for `BENILLA_LOOT_QUALITY_COLORS`, an alias our own
// LootFrame.xml set to that same table. 1751 took that window to the chain and the alias went
// with it (GameTooltip.xml reads the real name now), so the dependency is gone. At runtime
// 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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_merchant(Some(MerchantState {
@@ -666,7 +573,6 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(x), .. } if x == t))
};
// Before any hover: no highlight, no tooltip lines.
let quads = s.extract();
assert!(
!quads.iter().any(|q| {
@@ -678,15 +584,8 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
assert!(!has_text(&quads, "Main Hand"), "no tooltip before hover");
let sword_icon = rect_of_tex(&quads, "INV_Sword_04");
// Hover the ICON, and that is the whole change 1751 made here. Our `MerchantFrame.xml` made
// the WHOLE ROW take the mouse — this hovered 60px right of the icon, over the name plate,
// and expected the tooltip. The reference splits every row into an inert container plus a
// `$parentItemButton` the size of the icon, which takes the mouse on its behalf; the twelve
// `MerchantItem<N>` entries in the flag gate's KNOWN list said exactly that, and they retired
// with our file.
//
// So on the reference's window, hovering a vendor row's NAME shows nothing. That is not a
// regression to fix — it is what the client does.
// Hover the icon: a row is an inert container plus an icon-sized `$parentItemButton` that
// takes the mouse (`MerchantFrame.xml:49`), so hovering the name shows nothing, as in 1.12.
let (hx, hy) = (
(sword_icon.left + sword_icon.right) * 0.5,
(sword_icon.bottom + sword_icon.top) * 0.5,
@@ -708,8 +607,6 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
"highlight sits on the icon, not the row"
);
// The tooltip: the real item stat head, name coloured by quality (uncommon green — the
// BENILLA_LOOT_QUALITY_COLORS[2] the loot window shares).
for line in [
"Vendor Blade",
"Main Hand",
@@ -719,16 +616,10 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
] {
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.
// No type word: this is the stat-head fallback of a hover whose full template is in flight,
// and the word is `ItemSubClass.dbc`'s display name, which only the app resolves.
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.
// Two "Vendor Blade" quads: the row's gold name and the tooltip's uncommon-green header.
let name_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
@@ -754,11 +645,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
"the tooltip carries no buy-price line"
);
// Anchored at the icon: the tooltip's BOTTOMLEFT sits on the icon's TOPRIGHT (the −116 offset
// walks ANCHOR_RIGHT from the 153px row back to the 37px icon). The corner (an anchor-law
// fact, independent of the frame's own size) is what's under test — not the size itself, so
// read it straight off the real global by name (GameTooltip is the frame's actual Lua name
// since decision 0274) instead of hunting quads by a guessed frame size.
// `ANCHOR_RIGHT`: the tooltip's BOTTOMLEFT on the owner's TOPRIGHT, the owner the icon button.
let (tip_left, tip_bottom): (f32, f32) = s
.eval("return GameTooltip:GetLeft(), GameTooltip:GetBottom()")
.unwrap();
@@ -768,7 +655,6 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
"tooltip BOTTOMLEFT sits on the icon's TOPRIGHT"
);
// Hover row 2 (the shield): armor/block lines, the slot|type pair, row 1's lines gone.
let shield_icon = rect_of_tex(&quads, "INV_Shield_09");
s.mouse_move(
(shield_icon.left + shield_icon.right) * 0.5,
@@ -790,7 +676,6 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
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.
s.mouse_move(1000.0, 10.0);
s.resolve();
let quads = s.extract();
@@ -805,27 +690,8 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── `money_display_shrinks_to_content_and_stays_flush` RETIRED (1751) ────────────────────────
//
// It pinned `BenillaMoney_Set`'s geometry inside OUR `MerchantFrame.xml`: price groups left-flush
// at `rowLeft + 46`, the purse right-flush at window-right −53, denominations packed with the real
// 4px `MONEY_BUTTON_SPACING`. The 46 was a director call — 2px of breathing room off the plate
// border where the reference quotes 44 — and it was a fact about a file we no longer ship.
//
// The reference's vendor rows use `SmallMoneyFrameTemplate` and `MoneyFrame_Update`, a different
// kit with its own geometry. **The rules the test protected are not lost**: the shrink, the
// collapse and the per-type `showSmallerCoins`/`fixedWidth` behaviour are `money_frame_tests.rs`'s
// subject, against the kit that now draws these rows. What is gone is the merchant-specific pixel
// geometry, which had nothing left to describe.
//
// **The vendor window's coin columns therefore look different now** — the reference's spacing, not
// our approved 2px indent. Said here rather than left inside a changed constant.
/// The merchant/buyback tab pair drives the two pages (ref MerchantFrame_Update l.57-64 +
/// UpdateMerchantInfo/UpdateBuybackInfo): the merchant page shows the single most-recent buyback
/// slot + the repair pair (enabled iff there's damage to pay for); the buyback tab retitles the
/// window, fills the rows from GetBuybackItemInfo, and hides every merchant-only piece. Clicks
/// queue the BuybackItem/RepairAllItems intents the app drains.
/// The two tabs (`MerchantFrame.lua:57-64`): the merchant page has the buyback slot and repair
/// pair; the buyback page retitles the window and fills the rows from `GetBuybackItemInfo`.
#[test]
fn merchant_tabs_drive_buyback_page_and_repair_pair() {
benilla_formats::wow_data_or_skip!();
@@ -834,7 +700,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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
s.set_money(500);
@@ -863,7 +729,7 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
}],
buyback: vec![
buyback_item("Bandit Cloak", 116),
buyback_item("Cracked Sword", 20), // most recent sale — the merchant page's slot
buyback_item("Cracked Sword", 20), // the latest sale: the merchant page's slot
],
can_repair: true,
repair_all_cost: 76,
@@ -874,8 +740,7 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
);
assert!(s.errors().is_empty(), "show errors: {:?}", s.errors());
// Merchant page: the buyback slot shows the MOST RECENT sale; the repair pair is up and the
// all-button enabled (cost 76 > 0); the buyback page art is down.
// Merchant page: the latest sale in the buyback slot (`MerchantFrame.lua:133`), repair up.
assert!(s
.eval::<bool>("return MerchantBuyBackItemName:GetText() == 'Cracked Sword'")
.unwrap());
@@ -891,19 +756,14 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
)
.unwrap());
// The slot click buys back the most recent sale (slot 2 of 2).
// The stock row is a container plus a `$parentItemButton` that takes the mouse on its
// behalf — the split the retired flag-gate entries used to record as our divergence.
// The slot's item button buys back the latest sale, slot 2 of 2.
s.run("MerchantBuyBackItemItemButton:Click()").unwrap();
assert_eq!(s.take_merchant_buybacks(), vec![2]);
// Repair-all queues its intent.
s.run("MerchantRepairAllButton:Click()").unwrap();
assert!(s.take_repair_all());
s.take_sounds();
// Tab 2: the buyback page — retitled, rows off GetBuybackItemInfo, merchant-only pieces down,
// buyback art up, tab 2 selected (its Active slices showing).
s.run("MerchantFrameTab2:Click()").unwrap();
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
assert!(s
@@ -929,12 +789,11 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
)
.unwrap());
// A row click on the buyback page buys that slot back.
// A buyback-page row click is `BuybackItem` (`MerchantFrame.lua:360`).
s.run("MerchantItem1ItemButton:Click(\"LeftButton\")")
.unwrap();
assert_eq!(s.take_merchant_buybacks(), vec![1]);
// Back to tab 1: the merchant row returns, the buyback art drops.
s.run("MerchantFrameTab1:Click()").unwrap();
assert!(s
.eval::<bool>(
@@ -945,22 +804,12 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 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.
/// The tabs fit their labels once, in `<OnShow>` (`CharacterFrameTemplates.xml:77-80`), so the
/// measurer must answer synchronously, as the app's does.
#[test]
fn merchant_tabs_fit_their_labels() {
benilla_formats::wow_data_or_skip!();
/// `2 * $parentLeft:GetWidth()` — the template's two 20-unit end slices.
/// `2 * $parentLeft:GetWidth()` (`UIPanelTemplates.lua:41`): the two 20-unit end slices.
const SIDES: f64 = 40.0;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -974,7 +823,6 @@ fn merchant_tabs_fit_their_labels() {
s.fire_event("MERCHANT_SHOW", vec![ScriptValue::Str("Vendor".into())]);
s.resolve();
// Tab width = text + 2×20 end slices; the middle slices carry exactly the text width.
let (l1, l2, w1, w2, m1, m2): (f64, f64, f64, f64, f64, f64) = s
.eval(
"return MerchantFrameTab1Text:GetStringWidth(), MerchantFrameTab2Text:GetStringWidth(), \
@@ -992,10 +840,8 @@ fn merchant_tabs_fit_their_labels() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Hovering a buy-tab row arms the vendor **Buy** cursor (the grayed **UnableBuy** when you can't
/// afford the row), swaps to **Inspect** while Ctrl is held, and clears on leave — the whole reason
/// this window exists to a first-time visitor. Drives the stock row button's own `<OnEnter>` +
/// the frame's `OnUpdate` (a `tick`) exactly as the app does, since the coin is re-armed per frame.
/// A hovered row arms the Buy cursor (UnableBuy when unaffordable, Inspect with ctrl held), which
/// the frame's OnUpdate re-arms every frame (`MerchantFrame.xml:726-741`); leaving clears it.
#[test]
fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
benilla_formats::wow_data_or_skip!();
@@ -1006,7 +852,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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// A purse of 50c: row 1 (25c) is affordable, row 2 (100c) is not.
@@ -1042,9 +888,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
s.resolve();
assert_eq!(s.ui_cursor(), None, "no override before any hover");
// Row 1 hover: OnEnter remembers the row, the frame's OnUpdate arms the coin — affordable → Buy.
// The stock `<OnEnter>` is inline on the row button, so it is fired the way the engine
// fires it: `this` bound, then the compiled handler.
// The row button's inline `<OnEnter>`, fired as the engine fires it: `this` bound first.
s.run("this = MerchantItem1ItemButton MerchantItem1ItemButton:GetScript(\"OnEnter\")() this = nil")
.unwrap();
s.tick(0.016);
@@ -1055,7 +899,6 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
"the coin arms over an affordable vendor item"
);
// Ctrl held over the same row → the Inspect magnifier (the OnUpdate's Ctrl leg).
s.set_modifiers(false, true, false);
s.tick(0.016);
assert_eq!(
@@ -1065,13 +908,12 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
);
s.set_modifiers(false, false, false);
// Leaving clears the override (OnLeave ResetCursor + the itemHover poll stops).
// OnLeave resets the cursor and clears `itemHover` (`MerchantFrame.xml:87-91`).
s.run("this = MerchantItem1ItemButton MerchantItem1ItemButton:GetScript(\"OnLeave\")() this = nil")
.unwrap();
s.tick(0.016);
assert_eq!(s.ui_cursor(), None, "leaving the row resets the cursor");
// Row 2 hover: 100c against a 50c purse → the grayed UnableBuy.
s.run("this = MerchantItem2ItemButton MerchantItem2ItemButton:GetScript(\"OnEnter\")() this = nil")
.unwrap();
s.tick(0.016);
@@ -1081,18 +923,14 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
"an unaffordable vendor item grays the coin"
);
// Closing the window drops the override even without an OnLeave (walked out of range, panel
// displaced): the frame OnHide ResetCursors.
// Closing resets it with no OnLeave, from `MerchantFrame_OnHide` (`MerchantFrame.lua:54`).
s.fire_event("MERCHANT_CLOSED", vec![]);
s.tick(0.016);
assert_eq!(s.ui_cursor(), None, "closing the window resets the cursor");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Reproduce the director's report (2026-07-22): the trade RECIPIENT (read-only, partner) gold shows
/// "..." even for a single digit, while the byte-identical merchant purse renders fine. Drives the real
/// TradeFrame recipient coin trio through extract() and asserts the number quad is the digit, not the
/// ellipsis. If this FAILS, the bug is structural and reproduced here; if it PASSES, it is live-only.
/// The trade partner's money (`TradeRecipientMoneyFrame`) shows a single digit, not "...".
#[test]
fn trade_recipient_money_renders_the_digit_not_ellipsis() {
benilla_formats::wow_data_or_skip!();
@@ -1101,10 +939,9 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() {
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"); // 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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // read by the stock bag-slot click
load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.lua"); // for TradeFrame.xml's OnLoad
load_xml(&s, "Interface\\FrameXML\\MoneyInputFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TradeFrame.xml");
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
@@ -1144,14 +981,9 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() {
);
}
/// The vendor row's LEFT-button modifier fork (ref `MerchantItemButton_OnClick`,
/// MerchantFrame.lua l.301-306): CTRL previews the item in the dressing room (decision 1060), SHIFT
/// posts its link into an open chat edit box (decision 1059) — both over `GetMerchantItemLink`, the
/// binding this arc added. Neither may buy: this window's click *is* a purchase, so a modified click
/// that fell through would spend the player's money.
///
/// The controls that must not change: a plain RIGHT-click still buys, and (the reference's own
/// right-button guard, l.332-333) a CTRL-held right-click buys nothing at all.
/// The row's left-click fork (`MerchantFrame.lua:301-306`): ctrl dresses up the item and shift
/// posts its link, both through `GetMerchantItemLink`, and neither buys; a right-click buys unless
/// ctrl is held (`MerchantFrame.lua:332-333`).
#[test]
fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
benilla_formats::wow_data_or_skip!();
@@ -1162,8 +994,8 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
load_xml(&s, f);
}
for file in [
r"Interface\FrameXML\UIParent.xml", // UIParent + UIParent.lua, the reference's own (1988)
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
r"Interface\FrameXML\UIParent.xml", // UIParent and UIParent.lua
"ScrollTemplates.xml", // our scroll kits
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
@@ -1171,8 +1003,8 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
"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)
// Not the panel templates or the dialogs, which MERCHANT_UI has: loading the stock money
// kit's frames twice trips its global-named update.
"Interface\\FrameXML\\FloatingChatFrame.xml",
] {
load_xml(&s, file);
@@ -1188,7 +1020,7 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
num_available: -1,
item_id: 159,
stats: None,
// Fed exactly as `ui_merchant` builds it off the row's template answer.
// As `ui_merchant` builds it from the row's item template.
link: Some(WATER_LINK.into()),
max_stack: Some(1),
}],
@@ -1211,7 +1043,6 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
(icon.bottom + icon.top) * 0.5,
);
// The control first: an unmodified right-click still buys row 1.
s.mouse_button(x, y, "RightButton", true);
s.mouse_button(x, y, "RightButton", false);
assert_eq!(
@@ -1220,7 +1051,6 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
"an unmodified right-click still buys"
);
// The reference's right-button guard (l.332-333): CTRL-held, a right-click does nothing.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "RightButton", true);
s.mouse_button(x, y, "RightButton", false);
@@ -1230,7 +1060,6 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
"a ctrl-held right-click must not buy (ref l.332-333)"
);
// SHIFT + LEFT with the chat edit box open → the link, no purchase.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
@@ -1247,8 +1076,7 @@ fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
"a shift-click must not also buy"
);
// CTRL + LEFT → the dressing room wearing it, no purchase. Last: opening the room takes a
// UIPanel slot and can move the vendor window.
// Ctrl last: the dressing room takes a UIPanel slot and can move the vendor window.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
@@ -1,18 +1,7 @@
//! 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 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.
//! The eight stock micro buttons, loaded behind `MainMenuBarArtFrame`, which they anchor to.
use benilla_ui::script::{QuadContent, ScriptValue, TexCoords, UiScript, UnitState};
/// The eight, in row order.
const ROW: [&str; 8] = [
"CharacterMicroButton",
"SpellbookMicroButton",
@@ -24,9 +13,7 @@ const ROW: [&str; 8] = [
"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.
/// At 1024 wide the 1024-wide bar sits at x 0, so every stock offset is a screen coordinate.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -37,9 +24,8 @@ fn harness() -> UiScript {
"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…
// The labels each button's OnLoad reads, through `TEXT()`.
"Interface\\FrameXML\\GlobalStrings.lua",
// …through `TEXT()`.
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
@@ -55,7 +41,6 @@ fn harness() -> UiScript {
s
}
/// 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",
@@ -73,22 +58,15 @@ fn left_of(s: &UiScript, name: &str) -> f64 {
.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).
/// `CharacterMicroButton` sits at the art frame's `BOTTOMLEFT` +(552, 2), each button 29x58,
/// chained at -3 for a 26 px stride; `UpdateTalentButton` re-seats the quest log at -2
/// (`MainMenuBarMicroButtons.lua:139`), so past its first pass at 10+ the tail sits 1 px right.
#[test]
fn the_micro_row_sits_where_the_reference_puts_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.resolve();
// 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!(
@@ -104,7 +82,6 @@ fn the_micro_row_sits_where_the_reference_puts_it() {
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) {
@@ -117,22 +94,19 @@ fn the_micro_row_sits_where_the_reference_puts_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The `HitRectInsets top="18"` header: the micro-button art fills only the lower ~40 of the 58,
/// and the empty top must stay transparent to the mouse — otherwise the row eats hover over the
/// bar's XP strip and the sliver of world above it.
/// `HitRectInsets top="18"` (`MainMenuBarMicroButtons.xml:8`): the art fills the lower 40 of 58.
#[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();
s.resolve();
// Mid-button horizontally (552 + 14), inside the art band (y 2..42) ⇒ captures.
// Mid-button (552 + 14); the art band is y 2..42, the inset header y 42..60.
assert_eq!(
s.hit_test_name(566.0, 20.0).as_deref(),
Some("CharacterMicroButton"),
"the art band takes the mouse"
);
// 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"),
@@ -141,11 +115,8 @@ 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: `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`).
/// `MicroButtonPortrait` samples the `"player"` portrait through a crop window, so the 18x25
/// region shows a face; pushing swaps the window and dims it (`MainMenuBarMicroButtons.lua:109`).
#[test]
fn the_character_button_carries_the_player_portrait_through_the_reference_crop() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -201,9 +172,8 @@ fn the_character_button_carries_the_player_portrait_through_the_reference_crop()
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `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.
/// Under level 10 `UpdateTalentButton` hides the talent button and seats the quest log on its
/// anchor (`MainMenuBarMicroButtons.lua:133`).
#[test]
fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -216,7 +186,7 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
.unwrap(),
"no talents before 10"
);
// The quest log button takes the talent button's own seat — slot 3, not slot 4.
// The quest log takes the talent button's seat: slot 3, not 4.
assert_eq!(left_of(&s, "QuestLogMicroButton"), 552.0 + 26.0 * 2.0);
assert_eq!(
left_of(&s, "HelpMicroButton"),
@@ -239,22 +209,13 @@ fn the_talent_button_appears_at_level_ten_and_the_row_closes_up_below_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 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.
/// The template's `OnEnter` (`MainMenuBarMicroButtons.xml:12`) reads `this`, so the hover goes
/// through the engine; a bound label gains its key on `UPDATE_BINDINGS`.
#[test]
fn every_micro_button_hovers_with_its_reference_explanation() {
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\")")
@@ -270,7 +231,7 @@ fn every_micro_button_hovers_with_its_reference_explanation() {
"CHARACTER_BUTTON",
"NEWBIE_TOOLTIP_CHARACTER",
),
// The spellbook's own OnEnter (l.79-87) picks its label by `PlayerHasSpells()`.
// The spellbook's own `OnEnter` (`MainMenuBarMicroButtons.xml:79`) picks its label.
(
"SpellbookMicroButton",
"PlayerHasSpells() and SPELLBOOK_ABILITIES_BUTTON or ABILITYBOOK_BUTTON",
@@ -331,8 +292,6 @@ fn every_micro_button_hovers_with_its_reference_explanation() {
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()")
@@ -348,18 +307,14 @@ fn every_micro_button_hovers_with_its_reference_explanation() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 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.
/// The whole manifest, since `UpdateMicroButtons` reads every panel. A ding pulses the talent
/// button for 60 s unless the character sheet is open (`MainMenuBarMicroButtons.lua:120`).
#[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.
// A player exists before the manifest loads; the sheet formats its race and class.
s.set_unit(
"player",
Some(UnitState {
@@ -376,8 +331,7 @@ fn a_micro_button_pushes_while_its_panel_is_open_and_the_talent_button_pulses_on
..Default::default()
}),
);
// 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.
// The app runs GlobalStrings ahead of the manifest; the ding's chat lines format through it.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
assert!(super::load_default_ui(&s).is_empty());
s.resolve();
@@ -393,7 +347,7 @@ fn a_micro_button_pushes_while_its_panel_is_open_and_the_talent_button_pulses_on
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.
// The button's `OnClick` (`MainMenuBarMicroButtons.xml:49`); the sheet's `OnShow` calls back.
s.run("ToggleCharacter(\"PaperDollFrame\")").unwrap();
assert_eq!(
state(&s, "CharacterMicroButton"),
@@ -409,15 +363,12 @@ fn a_micro_button_pushes_while_its_panel_is_open_and_the_talent_button_pulses_on
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).
// The nine args `ui_unit` fires: level, health, power, talent points, five stats.
s.fire_event(
"PLAYER_LEVEL_UP",
vec![
+45 -101
View File
@@ -1,7 +1,5 @@
//! 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.
//! The stock `Minimap.xml` driven engine-only. Indoor and outdoor zoom are independent levels, so
//! the +/- buttons must re-sync when a WMO transition switches between them.
use benilla_ui::script::UiScript;
@@ -17,13 +15,12 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Host globals the cluster's OnLoad/clicks lean on that a bare engine doesn't install.
// Host globals the cluster calls that a bare engine does not install.
s.run("function GetMinimapZoneText() return '' end")
.unwrap();
s.run("function PlaySound() end").unwrap();
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.
// `GameTooltip` before the cluster, as shipped: `Minimap_Update` touches it from `OnLoad` on.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
@@ -33,20 +30,18 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
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).
// The zoom CVar defaults to 3, a middle level, so both buttons start enabled.
assert_eq!(s.eval::<u8>("return Minimap:GetZoom()").unwrap(), 3);
assert!(enabled(&s, "MinimapZoomIn"));
assert!(enabled(&s, "MinimapZoomOut"));
// Outdoors, zoom fully in (index 5 = max): the click handler greys ZoomIn.
// Outdoors at 5, the top level: `MINIMAP_UPDATE_ZOOM` greys ZoomIn (`Minimap.lua:56-64`).
s.run("Minimap:SetZoom(5)").unwrap();
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert!(!enabled(&s, "MinimapZoomIn"), "at max zoom ZoomIn disables");
assert!(enabled(&s, "MinimapZoomOut"));
// Step INSIDE: the API now routes to the indoor index, still at its own untouched default 3.
// The app fires MINIMAP_UPDATE_ZOOM on the transition; the cluster must re-sync to the new level.
// Indoors reads the indoor level, still 3; the app fires `MINIMAP_UPDATE_ZOOM` on the switch.
s.set_minimap_inside(true);
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert_eq!(
@@ -60,7 +55,6 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
);
assert!(enabled(&s, "MinimapZoomOut"));
// Indoors, zoom fully out (index 0 = widest): ZoomOut greys.
s.run("Minimap:SetZoom(0)").unwrap();
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert!(
@@ -69,8 +63,6 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
);
assert!(enabled(&s, "MinimapZoomIn"));
// Step back OUTSIDE: the outdoor index is exactly where we left it (5 = max), so ZoomIn is
// greyed again and the indoor min-zoom greying of ZoomOut is gone.
s.set_minimap_inside(false);
s.fire_event("MINIMAP_UPDATE_ZOOM", vec![]);
assert_eq!(s.eval::<u8>("return Minimap:GetZoom()").unwrap(), 5);
@@ -78,10 +70,8 @@ fn minimap_zoom_buttons_resync_when_switching_inside_and_outside() {
assert!(enabled(&s, "MinimapZoomOut"));
}
/// The tracking icon (ref-Minimap.xml l.109-174, transcribed verbatim): hidden at load, and the
/// verbatim OnEvent follows `GetTrackingTexture()` across `PLAYER_AURAS_CHANGED` — the event the
/// aura feed fires beside `UNIT_AURA` on every display-state change (`ui_aura`). Show with a
/// tracking state pushed, hide when it clears.
/// Stock `MiniMapTrackingFrame` (`Minimap.xml:109-174`) follows `GetTrackingTexture()` on
/// `PLAYER_AURAS_CHANGED`, which the aura feed fires beside `UNIT_AURA`.
#[test]
fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -108,7 +98,7 @@ fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
};
assert!(!vis(&s), "no tracking at load — the frame starts hidden");
// The feed pushes a tracking aura (a miner's Find Minerals) and fires the rebuild event.
// A tracking aura: Find Minerals, spell 2580.
s.set_tracking(Some(TrackingState {
spell_id: 2580,
name: Some("Find Minerals".into()),
@@ -118,14 +108,12 @@ fn tracking_frame_follows_get_tracking_texture_across_player_auras_changed() {
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(vis(&s), "a live tracking texture shows the icon");
// Tracking cancelled/expired: the same event path hides it.
s.set_tracking(None);
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(!vis(&s), "no tracking texture hides the frame again");
}
/// A session with the minimap cluster + the time-of-day indicator (`GameTime.xml`) loaded, the
/// game clock parked at `hour:minute` — the shape `crate::minimap::feed_game_time` pushes.
/// The cluster and the stock `GameTime.xml`, the clock at `hour:minute` as the app pushes it.
fn game_time_session(hour: u32, minute: u32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -136,16 +124,13 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript {
"__benilla_game_hour = {hour}; __benilla_game_minute = {minute}"
))
.unwrap();
// The player's own strings and locale rather than a hand-typed copy of three of them: the
// window is the reference's file since 1751 window 5, and `TIME_TWENTYFOURHOURS` and
// `TwentyFourHourTime` are exactly what it reads. `Localization.xml` only DEFINES
// `LocalizeFrames`; the reference calls it from `UIParent_OnEvent`'s VARIABLES_LOADED arm, and
// this session has no UIParent, so it calls it directly.
// `GameTime.lua` reads `TwentyFourHourTime`, which `LocalizeFrames` sets; the reference calls
// that on `VARIABLES_LOADED` (`UIParent.lua:231-232`), and this session has no UIParent.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Localization.xml");
s.run("LocalizeFrames()").unwrap();
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT()`, which the reference's own tooltip and label formatting passes every string through.
// `TEXT()`, which the tooltip formatting goes through.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
@@ -159,32 +144,24 @@ fn game_time_session(hour: u32, minute: u32) -> UiScript {
s
}
/// `GameTimeTexture`'s current texcoord window, as the `(left, right, top, bottom)` rect this
/// file reasons in.
///
/// `GetTexCoord` answers EIGHT values since decision 1840 — `ULx, ULy, LLx, LLy, URx, URy, LRx,
/// LRy` — so the rect is positions 1, 5, 2, 4. Folded here rather than at each call site because
/// the window really is axis-aligned and every assertion below is about its edges.
/// `GameTimeTexture`'s texcoords as `(left, right, top, bottom)`: `GetTexCoord` answers eight
/// values, `ULx, ULy, LLx, LLy, URx, URy, LRx, LRy`.
fn tod_window(s: &UiScript) -> (f64, f64, f64, f64) {
let (ulx, uly, _, lly, urx, ..): (f64, f64, f64, f64, f64, f64, f64, f64) =
s.eval("return GameTimeTexture:GetTexCoord()").unwrap();
(ulx, urx, uly, lly)
}
/// `GameTimeFrame_Update`'s law, exactly: the 50-px window over the 128×64 UI-TOD-Indicator
/// sits on the LEFT half (the sun) through the game day and slides +0.5 to the RIGHT half (the
/// moon) outside it — night is before 5:30 AM or from 9:00 PM, boundaries included exactly as
/// the ref compares (`< DAWN or >= DUSK`).
/// Stock `GameTimeFrame_Update` slides the 50px window +0.5 onto the moon when
/// `time < DAWN or time >= DUSK`, 5:30 and 21:00 (`GameTime.lua:2-18`).
#[test]
fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = game_time_session(10, 30);
// OnLoad seeded `timeOfDay = 0` and 10:30 ≠ 0, so the very first update already seated the
// window — no OnUpdate tick needed for the initial state.
// `OnLoad` seeds `timeOfDay = 0`, so its own update already seats the window.
let day = (0.0, 50.0 / 128.0, 0.0, 50.0 / 64.0);
assert_eq!(tod_window(&s), day, "mid-morning shows the sun half");
// 21:00 exactly is night (`>= DUSK`): the OnUpdate re-read slides the window +0.5.
s.run("__benilla_game_hour = 21; __benilla_game_minute = 0")
.unwrap();
s.tick(0.016);
@@ -194,7 +171,6 @@ fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() {
"9:00 PM sharp is the moon half"
);
// 5:29 is still night; 5:30 exactly is day (`< DAWN`).
s.run("__benilla_game_hour = 5; __benilla_game_minute = 29")
.unwrap();
s.tick(0.016);
@@ -204,18 +180,15 @@ fn game_time_frame_slides_the_sun_moon_window_on_the_game_clock() {
assert_eq!(tod_window(&s), day, "5:30 AM sharp flips to the sun");
}
/// Hovering the indicator through the REAL pointer path (hit-test → OnEnter) shows the game-time
/// tooltip, live-updates it while owned (the `IsOwned` refresh branch), and hides it on leave.
/// This also pins the two loader-side pieces this frame leans on: the `<Scripts>` walker's
/// mouse auto-enable (the frame declares no enableMouse, like the reference — without the law the
/// hit-test never captures) and the `<HitRectInsets>` hull.
/// `GameTimeFrame` declares no `enableMouse`: its `<Scripts>` enable the mouse, and its
/// `<HitRectInsets>` trim the hit rect (`GameTime.xml:15-17`).
#[test]
fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = game_time_session(21, 7);
s.resolve();
// Hover the middle of the frame's hit rect: the resolved rect inset by (l=6, r=0, t=5, b=10).
// The middle of the hit rect, inset by l=6, r=0, t=5, b=10.
let (l, r, t, b) = (
s.eval::<f32>("return GameTimeFrame:GetLeft()").unwrap(),
s.eval::<f32>("return GameTimeFrame:GetRight()").unwrap(),
@@ -228,20 +201,19 @@ fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"OnEnter owns the tooltip (the Scripts-walker auto-enable capturing at {x},{y})"
);
// TwentyFourHourTime = 1 (enGB LocalizeFrames, per GameTime.xml's header): 21:07, not 9:07 PM.
// The enGB `LocalizeFrames` sets `TwentyFourHourTime = 1` (`Localization.lua:20`): 21:07.
let text = |s: &UiScript| {
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap()
};
assert_eq!(text(&s), "21:07");
// The minute ticks while hovered: GameTimeFrame_Update's IsOwned branch refreshes in place.
// While owned, `GameTimeFrame_Update` refreshes the tooltip (`GameTime.lua:20-22`).
s.run("__benilla_game_minute = 8").unwrap();
s.tick(0.016);
assert_eq!(text(&s), "21:08", "the owned tooltip follows the clock");
// Leave: a point past the left inset — outside the hit hull but still inside the raw 50×50
// rect — must ALSO leave; the insets are part of the reference geometry.
// Inside the raw 50x50 rect but in the left inset: not hoverable.
s.mouse_move(l + 2.0, y);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
@@ -249,12 +221,7 @@ fn hovering_the_indicator_shows_and_live_updates_the_game_time_tooltip() {
);
}
/// **The ping's click path, driven by a real mouse event** (decision 1596).
///
/// The point of the test is the *path*, not the arithmetic: `Minimap_OnClick` is only reached if
/// the widget is mouse-enabled, hit-tests, and its `OnMouseUp` fires — and what it parks has to be
/// centre-relative UI units, because that is the contract the app's conversion is written against.
/// Calling `Minimap_OnClick()` from `s.run` would prove none of it (1234 §2).
/// A real click reaches `Minimap_OnClick` via `OnMouseUp` and parks centre-relative UI units.
#[test]
fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -282,12 +249,10 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
.unwrap();
assert!(cx > 0.0 && cy > 0.0, "the widget resolved: ({cx}, {cy})");
// 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. 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.
// 20 right and 12 up, inside the 70-unit disc; stock `Minimap_OnClick` first adds
// `CURSOR_OFFSET_X/Y`, -7 and -9 (`Minimap.lua:3-4`, `:133-134`).
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
@@ -295,19 +260,15 @@ fn a_click_on_the_minimap_parks_a_centre_relative_ping_request() {
.expect("OnMouseUp → Minimap_OnClick → PingLocation");
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);
// A click that misses the widget entirely never reaches the handler.
s.mouse_button(10.0, 10.0, "LeftButton", true);
s.mouse_button(10.0, 10.0, "LeftButton", false);
assert_eq!(s.take_minimap_ping_request(), None, "off-widget is no ping");
}
/// `Minimap:GetPingPosition()` answers **two numbers always** (the reference recomputes them
/// from statics nothing clears — `0x4eefd0`), 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.
/// The reference answers from statics nothing clears (`0x4eefd0`), and stock `Minimap_OnUpdate`
/// uses the pair with no nil test; here it is the app's last publish, `(0, 0)` before any.
#[test]
fn get_ping_position_answers_two_numbers_always() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -344,9 +305,8 @@ fn get_ping_position_answers_two_numbers_always() {
"{x} {y}"
);
// 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).
// `MINIMAP_PING` shows the frame for 5 s, re-seated from `GetPingPosition` each frame, then
// fades over 0.5 s (`Minimap.lua:1-2`, `:68-82`).
assert!(
!s.eval::<bool>("return MiniMapPing:IsVisible()").unwrap(),
"hidden until a ping"
@@ -382,9 +342,7 @@ fn get_ping_position_answers_two_numbers_always() {
);
}
/// 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.
/// Stock `MiniMapMeetingStoneFrame` follows `IsInMeetingStoneQueue()` on `MEETINGSTONE_CHANGED`.
#[test]
fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -446,16 +404,8 @@ fn the_meeting_stone_icon_follows_the_queue_across_meetingstone_changed() {
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 reference'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.
/// A shown ping pane publishes one tile request and no quad; once the renderer hands back a cell,
/// the cell draws every frame in the overlay lane, even when nothing in the interface moves.
#[test]
fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
use bevy::prelude::*;
@@ -482,8 +432,8 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
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.
// The file's facts, as `benilla-extract m2seq` reads them; the `OnLoad`'s `SetSequence(0)`
// waited for them and replays now.
const PING: &str = r"Interface\MiniMap\Ping\MinimapPing.mdx";
let seq = |anim_id, duration_ms, looping| SequenceFacts {
anim_id,
@@ -525,7 +475,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
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.
// The pass's handover: 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>();
@@ -545,10 +495,8 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
},
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.
// The app's order: extract, then compose after `sync_tiles`, whose cells this harness hands
// over by hand; the lane is cleared between frames, as `clear_ui_overlays` does.
app.add_systems(
Update,
(
@@ -585,7 +533,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
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".
// `scale="0.4"`: 1280 * 0.4 = 512 px per model unit.
assert!(
(req.px_per_unit - 512.0).abs() < 1e-3,
"{}",
@@ -603,8 +551,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
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.
// The pane's rect in the quad pass's space (y-down window px), and its own alpha.
assert!(
(req.rect.width() - 50.0).abs() < 1e-3 && (req.rect.height() - 50.0).abs() < 1e-3,
"{:?}",
@@ -614,8 +561,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
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.
// The renderer hands a cell back and nothing else changes; the next frame still draws it.
{
let atlas = app
.world_mut()
@@ -642,7 +588,7 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
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.
// The lane is re-emitted every frame, not on change.
next_frame(&mut app);
assert_eq!(
premultiplied(&app).len(),
@@ -650,11 +596,9 @@ fn a_shown_ping_pane_asks_for_a_tile_and_draws_its_cell() {
"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.
// `Minimap_OnUpdate` re-seats the frame from `GetPingPosition()` (`Minimap.lua:74`).
script.set_minimap_ping((-0.125, 0.25));
ping_at(&mut script, -0.125, 0.25);
}
@@ -1,12 +1,5 @@
//! The breath / fatigue bars against the reference's own `MirrorTimer.xml`/`.lua`, executed off
//! the player's chain since 1751's seventh window (decision 0874 is the arc): a bar per running
//! timer, the timer's own colour and caption, the value integrating `scale * elapsed` between
//! packets, and the frame released back to the pool on STOP.
//!
//! Two halves, and the second is the load-bearing one (the cast bar's lesson, `cast_tests`): the
//! state tests read the Lua back, which is blind to what actually *paints*. The
//! [`draw list`](UiScript::extract) tests below are the ones that would catch a bar that is shown,
//! correctly valued, and invisible.
//! The breath and fatigue bars: stock `MirrorTimer.lua`, read back from Lua and from the draw
//! list, since a bar can be shown, correctly valued and still paint nothing.
use benilla_ui::script::{ExtractedQuad, QuadContent, ScriptValue, UiScript};
@@ -16,13 +9,10 @@ fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
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.
// `UIParent_OnEvent`'s `MIRROR_TIMER_START` arm calls `MirrorTimer_Show` (`UIParent.lua:374`).
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.
// `MirrorTimer_Show` bounds its free-bar search by `STATICPOPUP_NUMDIALOGS`
// (`MirrorTimer.lua:34`); without StaticPopup it finds no free bar.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
@@ -35,8 +25,8 @@ fn harness() -> UiScript {
s
}
/// The app's own `MIRROR_TIMER_START` arg list (`ui_mirror::feed_mirror_timers`): the client's
/// timer name, milliseconds, milliseconds, the signed rate, the paused flag, the caption.
/// `MIRROR_TIMER_START` as `ui_mirror::feed_mirror_timers` fires it: the timer name, ms left,
/// ms total, the signed rate, the paused flag, the caption.
fn start(
s: &mut UiScript,
name: &str,
@@ -82,21 +72,19 @@ fn bar_color(s: &UiScript, frame: &str) -> (f64, f64, f64) {
.unwrap()
}
/// `Option`, not `String`: a blank caption reads back **nil**, because `FontString:GetText
/// 0x79d690` substitutes nil for an empty string (decision 2110).
/// A blank caption reads nil: `FontString:GetText` (`0x79d690`) returns nil for an empty string.
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 (`tick_script` then `paint_script`): OnUpdate, resolve, then the draw list.
/// One app tick in order (`tick_script`, then `paint_script`): OnUpdate, resolve, draw list.
fn frame(s: &mut UiScript, dt: f32) -> Vec<ExtractedQuad> {
s.tick(dt);
s.resolve();
s.extract()
}
/// The quad drawn from `Interface\...\<leaf>`, if any.
fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQuad> {
quads.iter().find(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } => p.ends_with(leaf),
@@ -104,7 +92,6 @@ fn tex_quad<'a>(quads: &'a [ExtractedQuad], leaf: &str) -> Option<&'a ExtractedQ
})
}
/// Every bar starts hidden — no timer, no chrome on screen.
#[test]
fn the_three_timers_load_hidden() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -118,8 +105,6 @@ fn the_three_timers_load_hidden() {
);
}
/// A breath timer: the first free frame takes it, blue, captioned, scaled to the full span, and
/// seated at the value the server named.
#[test]
fn breath_takes_the_first_bar_in_the_reference_blue() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -129,7 +114,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").as_deref(), Some("Breath"));
// Milliseconds on the wire, seconds in the bar — the reference's `/1000` on both.
// Milliseconds on the wire, seconds in the bar (`MirrorTimer.lua:47`).
assert_eq!(bar_value(&s, "MirrorTimer1"), 45.0);
assert_eq!(bar_max(&s, "MirrorTimer1"), 60.0);
// MirrorTimerColors["BREATH"]
@@ -140,8 +125,7 @@ fn breath_takes_the_first_bar_in_the_reference_blue() {
);
}
/// Fatigue is a different timer type, a different colour and a different caption — and the
/// client's key for it is `EXHAUSTION`, not the server's `FATIGUE`.
/// The client's key for fatigue is `EXHAUSTION`, not the server's `FATIGUE`.
#[test]
fn fatigue_is_the_reference_yellow_under_its_own_caption() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -157,8 +141,7 @@ fn fatigue_is_the_reference_yellow_under_its_own_caption() {
);
}
/// The whole client-side motion: the frame integrates `scale * elapsed` per OnUpdate. At the
/// reference's draining rate that is one bar-second per real second.
/// The frame integrates `scale * elapsed` per OnUpdate (`MirrorTimer.lua:106`).
#[test]
fn the_bar_drains_at_the_servers_signed_rate() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -174,8 +157,7 @@ fn the_bar_drains_at_the_servers_signed_rate() {
"5 s at scale -1 should land near 40, got {after}"
);
// Surfacing: the server re-sends the same timer with the +10 refill rate (there is no update
// opcode). The bar must reverse, ten times faster than it drained.
// Surfacing re-sends the timer as a START with the +10 refill rate; there is no update opcode.
start(&mut s, "BREATH", 40_000, 60_000, 10, "Breath");
for _ in 0..20 {
frame(&mut s, 0.05); // 1 s
@@ -187,7 +169,6 @@ fn the_bar_drains_at_the_servers_signed_rate() {
);
}
/// A paused timer holds its value — the frozen state the server sends when it stops the clock.
#[test]
fn a_paused_timer_holds_its_value() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -212,18 +193,9 @@ fn a_paused_timer_holds_its_value() {
"frozen: no integration at all"
);
// **And `MIRROR_TIMER_PAUSE` cannot release it, because the reference's own branch is
// bugged.** `MirrorTimerFrame_OnEvent` reads `arg1` as the timer NAME (`arg1 ~= this.timer`)
// and then, two lines later, as a number (`arg1 > 0`) — comparing a string to a number, which
// raises in Lua 5.0. So the handler dies before it can clear `paused` and the bar stays
// frozen.
//
// Ours read the flag from `arg2` until 1751's seventh window, which is what both branches
// plainly intend; the swap made the reference's body the live one and this pins what it
// actually does. It is unreachable in play: vmangos refuses to send that packet and
// substitutes a full START, saying so in `Player::SendMirrorTimers` — which is presumably why
// the bug survived 1.12 at all. Repairing it is a decision someone can make in the adapters;
// it is not the default, and it is not silent.
// The stock PAUSE branch reads `arg1` as the timer name, then compares it with 0
// (`MirrorTimer.lua:88`), which raises, so the bar stays frozen. vmangos never sends PAUSE: it
// resends a START (`Player.cpp:933`).
s.fire_event(
"MIRROR_TIMER_PAUSE",
vec![ScriptValue::Str("BREATH".into()), ScriptValue::Int(0)],
@@ -243,9 +215,6 @@ fn a_paused_timer_holds_its_value() {
);
}
/// Two timers at once (deep water: fatigue *and* breath) stack onto separate frames, each keeping
/// its own colour — and a re-STATE of one lands back on the frame already holding it rather than
/// consuming a third.
#[test]
fn two_timers_stack_and_restate_in_place() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -258,14 +227,12 @@ fn two_timers_stack_and_restate_in_place() {
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");
assert!(!shown(&s, "MirrorTimer3"), "re-state must not take a frame");
assert_eq!(bar_value(&s, "MirrorTimer2"), 30.0);
assert_eq!(bar_value(&s, "MirrorTimer1"), 60.0, "fatigue untouched");
}
/// STOP hides only the named timer and hands its frame back to the pool.
#[test]
fn stop_hides_that_timer_and_frees_its_frame() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -280,7 +247,6 @@ fn stop_hides_that_timer_and_frees_its_frame() {
assert!(!shown(&s, "MirrorTimer1"), "fatigue's frame released");
assert!(shown(&s, "MirrorTimer2"), "breath untouched");
// The freed frame is reusable: a fresh timer takes it back.
start(&mut s, "FEIGNDEATH", 10_000, 10_000, -1, "");
assert!(shown(&s, "MirrorTimer1"));
assert_eq!(
@@ -290,8 +256,6 @@ fn stop_hides_that_timer_and_frees_its_frame() {
);
}
/// Entering the world clears every bar — a worldport must not leave a stale breath bar behind
/// (the reference's `PLAYER_ENTERING_WORLD` arm).
#[test]
fn entering_the_world_clears_every_bar() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -305,8 +269,6 @@ fn entering_the_world_clears_every_bar() {
}
}
/// A STOP for a timer that is not running must be inert — not an error, and not a frame hidden
/// out from under a different timer.
#[test]
fn a_stop_for_an_idle_timer_touches_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -320,8 +282,6 @@ fn a_stop_for_an_idle_timer_touches_nothing() {
assert_eq!(bar_value(&s, "MirrorTimer1"), 45.0);
}
/// The paint half. A running timer must actually put its chrome and its fill on screen — the
/// state tests above all pass on a bar that draws nothing.
#[test]
fn a_running_timer_paints_its_chrome_and_fill() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -335,7 +295,7 @@ fn a_running_timer_paints_its_chrome_and_fill() {
let fill = tex_quad(&quads, "UI-StatusBar").expect("the status-bar fill");
let fill_w = fill.rect.expect("the fill resolves a rect").width();
// 45 of 60 seconds over the reference's 195 px bar.
// 45 of 60 seconds over the stock 195 px bar.
let expected = 195.0 * 45.0 / 60.0;
assert!(
(fill_w - expected).abs() < 2.0,
@@ -351,7 +311,6 @@ fn a_running_timer_paints_its_chrome_and_fill() {
);
}
/// …and a stopped timer takes all of it off screen again.
#[test]
fn a_stopped_timer_paints_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -365,14 +324,8 @@ fn a_stopped_timer_paints_nothing() {
assert!(tex_quad(&quads, "UI-StatusBar").is_none());
}
/// The director's report (2026-08-02): *"the z of the bar is wrong, its overlaying the border"* —
/// the blue fill was painting over the border art AND over the caption, leaving "Breath"
/// unreadable.
///
/// The reference's own construction says the fill belongs underneath: the border texture and the
/// caption are OVERLAY regions of the timer **frame**, and the fill belongs to a **child**
/// StatusBar whose only OnLoad is `SetFrameLevel(GetFrameLevel() - 1)`. Both must draw after the
/// fill in the painter's order — i.e. strictly greater `z`.
/// The border and caption are `OVERLAY` regions of the timer frame, and the fill a child
/// StatusBar whose OnLoad drops its frame level by one, so both draw over the fill.
#[test]
fn the_border_and_caption_draw_over_the_fill() {
let _data = benilla_formats::wow_data_or_skip!();
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,5 @@
//! 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).
//!
//! 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`);
//! the denomination split; `MoneyFrame_Update`'s by-name entry point over a STATIC frame; and
//! `SetMoneyFrameColor` recolouring the digits and not the icons.
//! The stock `MoneyFrame` kit, driven as an addon drives it: a frame on each template, a type set
//! and switched, the coins read back.
use benilla_ui::script::{QuadContent, UiScript};
@@ -21,9 +10,7 @@ fn harness(money: u64) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_money(money);
// The digit advances the app feeds once per atlas scale. Fed here so the width arithmetic
// 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`.
// A flat 8 px per digit, so every expected width reads as `digits * 8 + icon`.
s.set_text_measurer(Box::new(super::FixedWidthFont(8.0)));
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml"); // `message`, the kit's own error path
@@ -51,7 +38,7 @@ fn harness(money: u64) -> UiScript {
s
}
/// The three coins' text, gold → silver → copper.
/// The three coins' text: gold, silver, copper.
fn coins(s: &UiScript, frame: &str) -> (String, String, String) {
let one = |d: &str| {
s.eval::<String>(&format!("return {frame}{d}ButtonText:GetText() or ''"))
@@ -60,7 +47,7 @@ fn coins(s: &UiScript, frame: &str) -> (String, String, String) {
(one("Gold"), one("Silver"), one("Copper"))
}
/// Which of the three coins are showing, gold → silver → copper.
/// Which coins show: gold, silver, copper.
fn shown(s: &UiScript, frame: &str) -> (bool, bool, bool) {
let one = |d: &str| {
s.eval::<bool>(&format!("return {frame}{d}Button:IsShown()"))
@@ -69,9 +56,7 @@ fn shown(s: &UiScript, frame: &str) -> (bool, bool, bool) {
(one("Gold"), one("Silver"), one("Copper"))
}
/// A frame on either template loads as type PLAYER showing the purse, split into denominations —
/// the reference's `MoneyFrame_OnLoad` / `SmallMoneyFrame_OnLoad`, both of which end in
/// `MoneyFrame_SetType("PLAYER")`.
/// Both `MoneyFrame_OnLoad` and `SmallMoneyFrame_OnLoad` end in `MoneyFrame_SetType("PLAYER")`.
#[test]
fn a_money_frame_loads_as_the_player_purse_and_splits_the_denominations() {
benilla_formats::wow_data_or_skip!();
@@ -93,21 +78,16 @@ fn a_money_frame_loads_as_the_player_purse_and_splits_the_denominations() {
12_345,
"the frame remembers what it is displaying (ref l.212)"
);
// `small` is what picks the 13px coin over the 19px one — the only difference between the two
// OnLoads, and the reason both templates exist.
// `small`, the two OnLoads' only difference, picks the 13 px coin over the 19 px one.
assert_eq!(s.eval::<i64>("return TestPurse.small").unwrap(), 1);
assert!(s.eval::<bool>("return TestBigPurse.small == nil").unwrap());
}
/// **`MoneyFrame_SetType` switching a frame between types** — the verb 11 corpus addons call, and
/// the one that has to rewire three things at once: where the number comes from, whether the coins
/// take the mouse (`canPickup`), and how they collapse.
#[test]
fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
benilla_formats::wow_data_or_skip!();
let s = harness(12_345);
// PLAYER: canPickup, so all three coins are mouse-enabled.
for d in ["Gold", "Silver", "Copper"] {
assert!(
s.eval::<bool>(&format!("return TestPurse{d}Button:IsMouseEnabled()"))
@@ -116,7 +96,6 @@ fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
);
}
// → STATIC: the number now comes from the frame's own staticMoney, and the coins go inert.
s.run("TestPurse.staticMoney = 7 this = TestPurse MoneyFrame_SetType(\"STATIC\") this = nil")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -132,10 +111,9 @@ fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
"STATIC has no canPickup, so {d} does not take the mouse"
);
}
// STATIC collapses and does NOT showSmallerCoins: 7 copper is copper alone.
// STATIC collapses without `showSmallerCoins`: 7 copper is copper alone.
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
// → back to PLAYER: the purse returns, and so does the mouse.
s.run("this = TestPurse MoneyFrame_SetType(\"PLAYER\") this = nil")
.unwrap();
assert_eq!(
@@ -148,9 +126,6 @@ fn set_type_rewires_the_source_the_mouse_and_the_collapse() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **collapse rule**, which is the whole reason `MoneyTypeInfo` has fields beyond `UpdateFunc`.
/// PLAYER carries `showSmallerCoins = "Backpack"`, so a round 5 gold still shows all three coins;
/// STATIC does not, so the same amount is gold alone.
#[test]
fn showsmallercoins_is_what_keeps_the_zero_coins_visible() {
benilla_formats::wow_data_or_skip!();
@@ -173,16 +148,13 @@ fn showsmallercoins_is_what_keeps_the_zero_coins_visible() {
"STATIC collapses with no showSmallerCoins — 5g is gold alone (ref l.32-38)"
);
// A pure-copper amount under a collapsing type: the leading zeros go, the copper stays.
// Under a collapsing type, a copper-only amount drops the leading zero coins.
s.run("TestPurse.staticMoney = 42 this = TestPurse MoneyFrame_UpdateMoney() this = nil")
.unwrap();
assert_eq!(shown(&s, "TestPurse"), (false, false, true));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `MoneyFrame_Update(frameName, money)` — the family's one **by-name** entry point, and how an
/// addon paints a STATIC frame it just filled without going through an event. It is also where the
/// frame's own width is recomputed from the coins that survived the collapse.
#[test]
fn update_paints_a_static_frame_by_name_and_resizes_it() {
benilla_formats::wow_data_or_skip!();
@@ -199,16 +171,9 @@ fn update_paints_a_static_frame_by_name_and_resizes_it() {
20_304
);
// Every coin is its digits + a 13px icon (MONEY_ICON_WIDTH_SMALL, since this frame is `small`),
// and the frame is the surviving coins packed with the -4px MONEY_BUTTON_SPACING gap:
// 13 (base) + 21 + 21 + 21 - (-4) - (-4) = 84, each 21 being one 8px digit plus the icon
// (2g 3s 4c is a single digit per denomination).
//
// **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. 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.
// A coin is one 8 px digit plus the 13 px small icon, 21; the frame packs three with the -4
// spacing: 13 + 21 * 3 + 4 * 2 = 84. The text measure answers inside the Lua call, so this
// holds on the first pass.
assert_eq!(
s.eval::<f64>("return TestPurseGoldButton:GetWidth()")
.unwrap(),
@@ -222,14 +187,11 @@ fn update_paints_a_static_frame_by_name_and_resizes_it() {
assert_eq!(s.eval::<f64>("return TestPurse:GetWidth()").unwrap(), 34.0);
}
/// The large template uses the 19px icon and its own spacing — the same painter, the other size
/// constants (`MONEY_ICON_WIDTH` / `MONEY_BUTTON_SPACING`), selected purely by `this.small`.
/// `this.small` unset picks `MONEY_ICON_WIDTH` (19) and `MONEY_BUTTON_SPACING` (-4).
#[test]
fn the_large_template_measures_with_the_nineteen_pixel_icon() {
benilla_formats::wow_data_or_skip!();
let s = harness(20_304);
// One 8px digit + the 19px icon — the same digit sum as the small template, over the other
// icon constant. That the two differ ONLY by the icon is what this test is for.
assert_eq!(
s.eval::<f64>("return TestBigPurseGoldButton:GetWidth()")
.unwrap(),
@@ -248,10 +210,7 @@ fn the_large_template_measures_with_the_nineteen_pixel_icon() {
assert_eq!(s.eval::<i64>("return COPPER_PER_GOLD").unwrap(), 10_000);
}
/// An unknown type is refused rather than half-applied — the reference's `MoneyFrame_SetType`
/// guard (l.144-147). Its `message()` diagnostic is BasicControls.xml's, which we have not
/// transcribed, so the call is guarded; what must hold either way is that the frame keeps the type
/// it had.
/// `MoneyFrame_SetType` returns on an unknown type before touching anything (`MoneyFrame.lua:144`).
#[test]
fn an_unknown_money_type_leaves_the_frame_on_the_one_it_had() {
benilla_formats::wow_data_or_skip!();
@@ -270,22 +229,18 @@ fn an_unknown_money_type_leaves_the_frame_on_the_one_it_had() {
);
}
/// `SetMoneyFrameColor` recolours the **digits**, not the coin icons — how the reference reddens a
/// price you cannot afford (l.348-356). Read off the extracted quads, which is where "the digits
/// and not the icons" is actually observable.
/// `SetMoneyFrameColor` sets the three buttons' text colour only (`MoneyFrame.lua:348`).
#[test]
fn set_money_frame_color_recolours_the_digits_and_not_the_icons() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(12_345); // 1g 23s 45c — three distinguishable digit strings
let mut s = harness(12_345); // 1g 23s 45c: three distinct digit strings
s.run("SetMoneyFrameColor(\"TestPurse\", 1.0, 0.1, 0.1)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
// Both frames show the same purse, so there are six digit quads; exactly the three belonging to
// the frame we named are red — which also pins that `SetMoneyFrameColor` is scoped by name and
// does not reach a sibling money frame.
// Both frames show the purse, so six digit quads; only the named frame's three turn red.
let digit_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
@@ -307,8 +262,6 @@ fn set_money_frame_color_recolours_the_digits_and_not_the_icons() {
"only TestPurse's digits reddened, got {digit_colors:?}"
);
// The coin icons keep their own texture untinted — the half of the reference's contract that a
// "recolour the whole frame" shortcut would break.
let icon_colors: Vec<[f32; 4]> = quads
.iter()
.filter_map(|q| match &q.content {
@@ -329,9 +282,7 @@ 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 send-mail rows reading the amounts
/// their engine getters hold (`GetSendMailMoney`/`GetSendMailCOD`, built by 1962).
/// Addons read `MoneyTypeInfo` directly; the send-mail rows read their engine getters.
#[test]
fn the_money_type_table_carries_all_seven_reference_types() {
benilla_formats::wow_data_or_skip!();
@@ -353,7 +304,6 @@ fn the_money_type_table_carries_all_seven_reference_types() {
"MoneyTypeInfo[\"{t}\"] with an UpdateFunc"
);
}
// 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"
@@ -372,22 +322,14 @@ fn the_money_type_table_carries_all_seven_reference_types() {
}
}
/// **`MoneyInputFrame.lua` is `EditBox:SetNumber`'s only caller on the whole chain**, and the verb
/// did not exist until decision 1831 — so the three-box amount editor could not put a number in a
/// box at all. This drives the reference's own file: split an amount, read it back, round-trip.
///
/// The boxes are `numeric="true"`, which is why the value→text law matters here specifically: a
/// non-digit abandons the insert wholesale and leaves the box EMPTY. Every value this path passes
/// is a non-negative integer, so `%.14g` yields plain digits and the gate never trips — but the
/// zero case below is the one that would show if it ever did.
/// `MoneyInputFrame.lua` is the only stock caller of `EditBox:SetNumber`.
#[test]
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, "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.lua"); // COPPER_PER_GOLD and COPPER_PER_SILVER
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");
@@ -420,9 +362,8 @@ fn the_chains_money_input_frame_splits_an_amount_across_its_three_boxes() {
"the amount round-trips back out of the three boxes"
);
// Zero is the reference's quiet case: `GetNumber()` on an empty box is already 0, so the
// equality test short-circuits and `SetNumber` is never reached — the boxes stay EMPTY rather
// than reading "0", and the total still comes back 0.
// An empty box's `GetNumber()` is already 0, so `SetNumber` is skipped and the boxes stay
// empty (`MoneyInputFrame.lua:46`).
s.run("MoneyInputFrame_ResetMoney(TestAmount)").unwrap();
s.run("MoneyInputFrame_SetCopper(TestAmount, 0)").unwrap();
for box_name in ["Gold", "Silver", "Copper"] {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,40 +1,12 @@
//! 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
//! `CreateFrame(kind, name, parent, "SomeTemplate")`, which decision 1203 made work and which
//! `addon_harness` then ranked — 69 corpus call sites on `UICheckButtonTemplate` alone, 185 sites
//! in total naming templates we had never declared. So every test here goes in through
//! `CreateFrame`'s fourth argument, from Lua, with a caller-chosen name, because that is the entire
//! surface these files exist to serve.
//!
//! What they guard, in the order the harness ranked them:
//!
//! - **The name.** `CreateFrame("CheckButton", "MyCheck", UIParent, "UICheckButtonTemplate")` must
//! publish `MyCheckText`, never `UICheckButtonTemplateText` — `getglobal(this:GetName().."Text")`
//! is the next line every one of those 69 sites writes. 1203 pinned the *mechanism*; this pins it
//! through the real templates, including the two-level `$parent` case
//! (`MyScrollScrollBarScrollUpButton`).
//! - **The art.** Each state texture in those files carries the reference's `inherits=` *and* the
//! art that `inherits=` resolves to, because our loader does not expand `inherits=` in
//! state-texture position (the deviation is stated at the head of `UIPanelTemplates.xml`). The
//! extract assertions below are that deviation's falsifier: strip the inline `file=` back to the
//! reference's `inherits=`-only form and every checkbox and panel button in the corpus goes
//! invisible with nothing erroring.
//! - **The behaviour.** `SetChecked`/`GetChecked` across a click, the close button hiding its
//! parent, the edit box round-tripping text, the scroll frame's range reaching its bar.
//! - **The names are 1.12's.** The last test re-reads both files against
//! `reference/1.12-globals.tsv` — a template we invent under a plausible-looking name is a name
//! an addon can never have meant.
//! The stock shared widget kit (`UIPanelTemplates.xml`, `OptionsFrameTemplates.xml`,
//! `UIOptionsFrame.xml`'s check button, the tab template) driven as an addon drives it: through
//! `CreateFrame`'s template argument, from Lua, under the caller's own name.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// 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.
/// The files under test on the manifest prefix they sit on, in manifest order.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -53,11 +25,8 @@ fn harness() -> UiScript {
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
r"Interface\FrameXML\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.
// `UIOptionsCheckButtonTemplate`'s home (`UIOptionsFrame.xml:6`), after the dropdown kit
// its window uses.
r"Interface\FrameXML\UIDropDownMenu.xml",
r"Interface\FrameXML\UIOptionsFrame.xml",
] {
@@ -79,12 +48,8 @@ fn drawn_textures(s: &mut UiScript) -> Vec<String> {
.collect()
}
/// **The test this whole file exists for.** An addon's own line, verbatim, and then the global it
/// reads back on the next line.
///
/// The failure this pins is not "no checkbox": it is a checkbox whose label region is called
/// `UICheckButtonTemplateText`, so `getglobal(this:GetName().."Text")` is nil, so the addon's
/// `SetText` dies — with the `CreateFrame` itself having succeeded and returned an object.
/// The label publishes as `MyCheckText`, the global an addon reads next through
/// `getglobal(this:GetName().."Text")`.
#[test]
fn a_check_button_from_the_template_names_its_label_after_the_caller() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -104,7 +69,6 @@ fn a_check_button_from_the_template_names_its_label_after_the_caller() {
"and never under the template's"
);
// The 69-call-site idiom, end to end.
s.run(r#"getglobal("MyCheckText"):SetText("Show my thing")"#)
.unwrap();
assert_eq!(
@@ -121,12 +85,6 @@ fn a_check_button_from_the_template_names_its_label_after_the_caller() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The checkbox **paints**, and it paints the reference's art.
///
/// This is the falsifier for `UIPanelTemplates.xml`'s stated deviation: the reference declares its
/// state textures with `inherits=` alone, our loader does not expand `inherits=` there, and a
/// transcription that copied the reference literally would create a frame with no state textures at
/// all — no error, no warning, 69 invisible checkboxes.
#[test]
fn the_templated_check_button_draws_the_reference_checkbox_art() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -167,8 +125,6 @@ fn the_templated_check_button_draws_the_reference_checkbox_art() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A click toggles, and the addon's `OnClick` sees the NEW state — the widget contract every
/// options panel in the corpus is written against (`if this:GetChecked() then …`).
#[test]
fn a_templated_check_button_toggles_before_its_on_click_runs() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -204,9 +160,8 @@ fn a_templated_check_button_toggles_before_its_on_click_runs() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UIPanelButtonTemplate` — 19 corpus call sites, and the shared original of nine private copies
/// in our own `assets/ui`. Its label is a `<ButtonText>` rather than a layer FontString, so the
/// addon idiom is `btn:SetText(...)` and the published global is still `$parentText`.
/// Its face is `<NormalTexture inherits="UIPanelButtonUpTexture"/>` (`UIPanelTemplates.xml:23`),
/// which the loader expands, and its `<ButtonText>` still publishes as `$parentText`.
#[test]
fn a_panel_button_from_the_template_labels_and_paints() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -238,9 +193,7 @@ fn a_panel_button_from_the_template_labels_and_paints() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `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
/// the chain's `UIParent.xml`.
/// Its OnClick is `HideUIPanel(this:GetParent())` (`UIPanelTemplates.xml:96-98`).
#[test]
fn the_templated_close_button_hides_the_frame_it_sits_on() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -269,9 +222,8 @@ fn the_templated_close_button_hides_the_frame_it_sits_on() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `InputBoxTemplate` — 5 call sites across 3 corpus addons. Its three border slices are `<Layers>`
/// regions (so they publish by name), and its direct-child `<FontString>` is the box's *text*
/// region, not a layer.
/// Its border slices are named `<Layers>` regions; its direct-child `<FontString>` is the box's
/// text, not a layer (`UIPanelTemplates.xml:226-278`).
#[test]
fn an_input_box_from_the_template_carries_its_border_and_takes_text() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -302,23 +254,10 @@ fn an_input_box_from_the_template_carries_its_border_and_takes_text() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UIPanelScrollFrameTemplate` — 6 call sites across 2 corpus addons, and the template with the
/// deepest naming problem: its scroll bar's arrow buttons are `$parentScrollUpButton` inside
/// `$parentScrollBar`, so a caller named `MyScroll` must reach
/// `MyScrollScrollBarScrollUpButton` through **two** levels of `$parent` — which is precisely what
/// `ScrollFrame_OnLoad` getglobals on the way past.
///
/// The `UpdateScrollChildRect` leg is also the falsifier for the `<ThumbTexture>` publication:
/// `ScrollFrame_OnScrollRangeChanged` reaches the thumb through
/// `getglobal(bar:GetName().."ThumbTexture")`, which resolves only because our loader publishes a
/// named `<ThumbTexture>` as a global. If that publication regressed, this call would raise on a
/// nil index and `s.errors()` would not be empty.
///
/// (This used to claim the opposite — that we diverged to `GetThumbTexture()` "because our loader
/// never publishes a named `<ThumbTexture>`", and called it "this file's one Lua divergence". It
/// was already false when written: the XML has used `getglobal` throughout, at HEAD and now.
/// Corrected rather than deleted, because a doc comment asserting a divergence that does not exist
/// is exactly how someone later introduces a real one by "restoring" it.)
/// The arrows sit two `$parent` levels deep (`MyScrollScrollBarScrollUpButton`), where
/// `ScrollFrame_OnLoad` looks them up, and `ScrollFrame_OnScrollRangeChanged` reaches the thumb
/// by `getglobal(bar:GetName().."ThumbTexture")` (`UIPanelTemplates.lua:244-283`), so a named
/// `<ThumbTexture>` must publish as a global.
#[test]
fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -342,7 +281,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
"MyScroll{child} — the caller's name won through every $parent level"
);
}
// ScrollFrame_OnLoad ran off the template: empty range, both arrows greyed, offset 0.
// `ScrollFrame_OnLoad` ran: empty range, arrows disabled, offset 0.
assert_eq!(
s.eval::<(f64, f64)>("return MyScrollScrollBar:GetMinMaxValues()")
.unwrap(),
@@ -353,8 +292,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
.unwrap());
assert_eq!(s.eval::<i64>("return MyScroll.offset").unwrap(), 0);
// A scroll child taller than the window, then the range change the reference's own handler
// consumes — the leg that touches the thumb.
// A child taller than the frame changes the range, and the handler touches the thumb.
s.run(
r#"MyScrollChild = CreateFrame("Frame", "MyScrollChild", MyScroll)
MyScrollChild:SetWidth(290) MyScrollChild:SetHeight(192)
@@ -380,7 +318,7 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
"there is somewhere to scroll to, so the down arrow woke"
);
// And the frame's own <OnVerticalScroll> seats the bar and re-enables the up arrow.
// The frame's `<OnVerticalScroll>` seats the bar and re-enables the up arrow.
s.run("MyScroll:SetVerticalScroll(48)").unwrap();
assert_eq!(
s.eval::<f64>("return MyScrollScrollBar:GetValue()")
@@ -393,10 +331,9 @@ fn a_scroll_frame_from_the_template_wires_its_bar_two_parents_deep() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `UIOptionsCheckButtonTemplate` — 8 call sites, all in one addon's options panel, and a
/// two-generation chain: it overrides the size of `OptionsCheckButtonTemplate`, which adds the hit
/// rect and the click sound to `UICheckButtonTemplate`. The chain has to resolve through
/// `CreateFrame`'s fourth argument the same way it resolves through XML `inherits=`.
/// Three links: `UIOptionsCheckButtonTemplate`'s size (`UIOptionsFrame.xml:6-10`) over
/// `OptionsCheckButtonTemplate`'s hit rect and click sound (`OptionsFrameTemplates.xml:40-51`) over
/// `UICheckButtonTemplate`.
#[test]
fn the_options_check_button_resolves_its_whole_inheritance_chain() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -426,7 +363,7 @@ fn the_options_check_button_resolves_its_whole_inheritance_chain() {
"and UICheckButtonTemplate's label, the ROOT link — still named after the caller"
);
// The middle link's OnClick is the reference's option-toggle sound pair.
// The middle link's OnClick plays the option toggle sounds.
let _ = s.take_sounds();
s.run("MyOpt:Click()").unwrap();
assert!(
@@ -436,30 +373,8 @@ fn the_options_check_button_resolves_its_whole_inheritance_chain() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// **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`.**
///
/// The reference opens with `if ( tab ) then tabName = tab:GetName(); else tabName =
/// this:GetName(); tab = this; end`. Ours had only the first half, so `tab:GetName()` on a nil
/// threw — and nothing in our tree noticed, because every caller we wrote passed a tab explicitly.
/// The stock files do not: `Blizzard_MacroUI.xml`'s two tabs each call
/// `PanelTemplates_TabResize(0)` from their own `OnLoad` and nothing else, so both threw at load
/// and the window came up with unsized tabs. Decision 1835.
///
/// Driven through a real `OnLoad` rather than a direct call, because `this` is exactly what is
/// under test: a plain function call would not set it.
/// `PanelTemplates_TabResize`'s `tab` defaults to `this` (`UIPanelTemplates.lua:33-38`), and stock
/// tabs call it without one from `OnLoad` or `OnShow`; a real `OnLoad` is what sets `this`.
#[test]
fn tab_resize_falls_back_to_this_when_no_tab_is_passed() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -481,44 +396,22 @@ fn tab_resize_falls_back_to_this_when_no_tab_is_passed() {
report.errors
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// It sized the tab it was never handed: wider than the bare label, because the end slices are
// added on top.
let width = s.eval::<f64>("return ProbeTab:GetWidth()").unwrap();
assert!(width > 0.0, "the tab got a width, got {width}");
}
/// **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).
///
/// 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.
///
/// 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.
///
/// **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.
/// The stock tab fits its text once, in `<OnShow>` (`CharacterFrameTemplates.xml:77-80`), so the
/// text must measure synchronously. Its highlight is anchored 10 in from each side (`:94-107`),
/// which leaves both `SetWidth` calls on it inert, and nothing caps a tab at its window's edge.
#[test]
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.
/// `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)));
// 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.
// The highlight has no `<Size>`, so its height comes from its art, which is 128x32
// (`UI-Character-Tab-Highlight.blp`); a VM with no engine needs this probe for it.
s.set_texture_size_probe(Box::new(|_| Some((128, 32))));
let doc = benilla_ui::framexml::parse(
r#"<Ui>
@@ -540,7 +433,7 @@ fn the_stock_tab_fits_its_text_on_the_first_show() {
let report = benilla_ui::loader::load_in(&s, &doc, "test", &|_: &str| None);
assert!(report.errors.is_empty(), "load: {:?}", report.errors);
// The first show, and nothing else: no tick, no measure round trip to pump.
// One show, no tick.
s.run("ProbeWindow:Show()").unwrap();
assert!(
s.errors().is_empty(),
@@ -560,7 +453,6 @@ fn the_stock_tab_fits_its_text_on_the_first_show() {
);
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);
}
@@ -571,8 +463,7 @@ fn the_stock_tab_fits_its_text_on_the_first_show() {
"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.
// Lit, so the highlight joins the resolved tree.
s.run("ProbeTab:LockHighlight()").unwrap();
s.resolve();
let (tl, tr, hl, hr) = s
@@ -587,8 +478,7 @@ fn the_stock_tab_fits_its_text_on_the_first_show() {
"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.
// No clamp: the long label runs past the window's right edge, as in 1.12.
let right = s.eval::<f64>("return ProbeWindow:GetRight()").unwrap();
assert!(
tl + width > right,
+93 -330
View File
@@ -3,21 +3,12 @@ use benilla_ui::script::{
QuadContent, SoundRequest, UiScript,
};
/// Load one manifest entry into `s`, panicking on any loader error and returning the frame count
/// 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`
/// cannot name it. [`super::test_ui::load_ui`] knows both stores by the manifest's own rule, so a
/// caller that names a chain entry has to open with `wow_data_or_skip!` (only the bank test below
/// does).
/// Each test loads stock `UIParent.xml` first: its `ShowUIPanel`, `HideUIPanel` and
/// `UIPanelWindows` are the panel manager every window here seats through. A missing template only
/// warns under this loader, so a list without `UIPanelTemplates.xml` loses scroll bars silently.
use super::test_ui::load_ui as load_xml;
/// Find a bare frame's own rect via its `QuadContent::Frame` entry (every frame emits one, at its
/// resolved rect, whether or not it paints anything itself — `UiScript::extract`'s doc). Used for
/// the synthetic pushable=7 loot marker, which has no visual layers of its own.
/// The rect of the frame sized `w` by `h`, from the `QuadContent::Frame` quad every frame emits.
fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Rect {
quads
.iter()
@@ -30,50 +21,33 @@ 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 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,
/// TOPLEFT UIParent 0,-104 — pin §4's rect assertion), the greeting + option rows rendering, a
/// coded row disabled, a row click queuing the right select intent, and a close through
/// HideUIPanel vacating the left slot. Quest-row rendering (decision 0088 §3, the shared row pool)
/// gets its own dedicated test below (`shipped_gossip_frame_renders_quest_rows_above_options`).
/// Stock `GossipFrame.xml` with a two-option menu: `GOSSIP_SHOW` seats it at the left slot, a row
/// click queues its select, and the close button vacates the slot.
#[test]
fn shipped_gossip_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);
// A measurer: `GossipResize` reads `GetTextHeight()` on the line after `SetText`, so a bare
// VM sizes every row from the previous frame's box. See
// `shipped_gossip_rows_grow_to_their_wrapped_labels` for why this is a harness gap and not an
// engine one.
// `GossipResize` reads `GetTextHeight()` right after `SetText`, so the harness installs the
// synchronous measurer the app always has; without one a row sizes from last frame's text.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.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, "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, "ScrollTemplates.xml"); // our scroll kits
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
// real GossipGreetingText ref l.241 / GossipFrameNpcNameText ref l.170) — not their own frames.
// 43 frames, 32 of them the row pool that quests and options share (`NUMGOSSIPBUTTONS`).
assert_eq!(
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml"),
43,
"the stock file's own shape (1751) — ours materialized 41"
);
// Hidden by default: no gossip icon on screen.
s.resolve();
let vendor_icon = |quads: &[ExtractedQuad]| {
quads.iter().any(|q| {
@@ -87,7 +61,6 @@ fn shipped_gossip_frame_drives_end_to_end() {
"left slot empty before any panel opens"
);
// The app's feed: a two-option menu (a vendor option + a coded petition option).
s.set_gossip(Some(GossipMenu {
greeting: "Greetings, traveler. How may I help you?".into(),
quests: Vec::new(),
@@ -107,26 +80,14 @@ fn shipped_gossip_frame_drives_end_to_end() {
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The window is shown (ShowUIPanel put it on the left slot), the greeting painted, the vendor
// row's icon rendered.
assert!(s.eval::<bool>("return GossipFrame:IsVisible()").unwrap());
assert_eq!(
s.eval::<String>("return GossipGreetingText:GetText()")
.unwrap(),
"Greetings, traveler. How may I help you?"
);
// Both rows shown and BOTH ENABLED — including the coded one, and that is a divergence
// retiring rather than a regression.
//
// Our `GossipFrame.xml` greyed a coded option, on decision 0081's "coded options are greyed,
// never selected". **1.12 does no such thing**: `GossipFrameOptionsUpdate` (ref l.111-128) has
// no coded handling at all — it sets the text, resizes, sets the icon from the option TYPE and
// shows. There is no `Disable()` anywhere in the file.
//
// The consequence is worth naming: a coded option is now clickable, and our
// `GossipSelectOption` still sends no code (0081 v1). The server sees a select without the
// code it asked for. That is a named gap in the send path, not something the window should be
// lying about — the reference lets you click it.
// The coded row is enabled too: `GossipFrameOptionsUpdate` has no coded-option handling
// (`GossipFrame.lua:111-128`). A coded option is sent without a code; that path is not built.
let states: (bool, bool, bool, bool) = s
.eval(
"return GossipTitleButton1:IsVisible(), GossipTitleButton1:IsEnabled() ~= 0,\n\
@@ -142,10 +103,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
s.resolve();
let quads = s.extract();
// The slot anchor actually applied: the window's rect top-left sits at (0, 664) — screen
// height 768 minus the left slot's 104px drop (pin §4's extract-rect assertion). The re-skinned
// window has no solid-colour fill (the parchment quadrants are opaque), so it's found by its own
// 384×512 frame quad rather than a background texture.
// The left slot is TOPLEFT 0,-104 (`UIParent.lua:818`), so the top edge is 768 - 104.
let win = frame_rect(&quads, 384.0, 512.0);
assert_eq!(
(win.left, win.top),
@@ -153,9 +111,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
"gossip window landed at the left slot (TOPLEFT UIParent, 0, -104)"
);
// The four QuestGreeting quadrant slabs ARE the parchment art (ref-GossipFrame.xml l.13-44):
// 256-wide left halves, 128-wide right halves, each 256 tall, pinned to their corner, each
// sampling its whole texture (no TexCoords).
// The parchment is four corner quadrants, 256 and 128 wide, 256 tall (`GossipFrame.xml:13-44`).
let quad_rect = |needle: &str, w: f32, h: f32| {
let q = quads
.iter()
@@ -201,18 +157,13 @@ fn shipped_gossip_frame_drives_end_to_end() {
let icon_rect = quads
.iter()
.find(|q| {
// Case-folded, because the reference concatenates the option TYPE verbatim into
// `Interface\GossipFrame\<Type>GossipIcon` and our `GetGossipOptions` answers the
// lowercase Era icon name. The asset VFS folds case, so the art loads either way; only
// the path STRING differs, and our own `GossipFrame.xml` used to capitalise it before
// building the path. Whether 1.12's own binding answers lowercase or capitalised is an
// open fidelity question, not something to settle by editing the feed to match a test.
// Case-folded: the 1.12 client answers the type in lowercase (table `0x84b7ac`), and
// `GossipFrame.lua:123` builds the path from it verbatim.
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.to_ascii_lowercase().contains("vendorgossipicon"))
})
.and_then(|q| q.rect)
.expect("vendor option icon visible after GOSSIP_SHOW");
// The label text renders too.
assert!(
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Text { text: Some(t), .. }
@@ -221,7 +172,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
"option label shows"
);
// Click option 1 (the icon center lies inside its row button) → SelectGossipOption(1) queues.
// The icon's centre lies inside row 1's button.
let (cx, cy) = (
(icon_rect.left + icon_rect.right) * 0.5,
(icon_rect.bottom + icon_rect.top) * 0.5,
@@ -231,8 +182,7 @@ fn shipped_gossip_frame_drives_end_to_end() {
assert_eq!(s.take_gossip_selects(), vec![1]);
assert!(!s.take_gossip_close());
// The close button queues a close intent (the app clears state; here we drive the hide too)
// through HideUIPanel, which vacates the left slot.
// The close button hides the window; its OnHide calls `CloseGossip()` (`GossipFrame.xml:455`).
s.run("GossipFrameCloseButton:Click()").unwrap();
assert!(s.take_gossip_close());
assert!(!s.eval::<bool>("return GossipFrame:IsVisible()").unwrap());
@@ -243,13 +193,8 @@ fn shipped_gossip_frame_drives_end_to_end() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Decision 0088 §3's deferred item, now drawn: `GossipMenu.quests` render as rows on the SAME
/// shared pool the option rows already used, quest rows filling first (ref-GossipFrame.lua
/// l.24-29/63-128 — `GossipFrameAvailableQuestsUpdate`/`ActiveQuestsUpdate` before
/// `OptionsUpdate`), option rows landing directly below them via the one static anchor chain
/// (no runtime repositioning). One active quest + one available quest + one option: rows 1/2 carry
/// the quest titles, row 3 the option, row 4+ stay hidden, and clicking a quest row queues the
/// right 1-based position on `take_gossip_quest_selects` (`benilla-ui` `script/gossip.rs`).
/// Quest rows fill the shared pool first, available before active, then the options
/// (`GossipFrame.lua:24-29`), all on one static anchor chain.
#[test]
fn shipped_gossip_frame_renders_quest_rows_above_options() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -258,12 +203,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -295,9 +235,6 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Rows 1-2 carry the quest titles (active first, matching the order the menu supplied them —
// benilla's seam already flattens available/active into one ordered list, `ui_gossip`), row 3
// the option, row 4+ hidden.
let (r1_text, r1_vis, r2_vis, r3_text, r3_vis, r4_vis, r5_text, r5_vis, r6_vis): (
String,
bool,
@@ -318,19 +255,9 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
GossipTitleButton6:IsVisible()",
)
.unwrap();
// **AVAILABLE quests lead, then ACTIVE** — the reference's own order (`GossipFrameUpdate` calls
// `GossipFrameAvailableQuestsUpdate` before `…ActiveQuestsUpdate`, ref l.27-28), and ours since
// this window started reading the reference's two list verbs instead of the single ordered
// `GetGossipQuestInfo` benilla invented. The packet listed the active quest first; the window
// does not.
// **Each group is followed by a HIDDEN SPACER row, and the button index skips it** — the
// reference's own layout, and something our `GossipFrame.xml` did not do. Both
// `GossipFrameAvailableQuestsUpdate` and `…ActiveQuestsUpdate` end by hiding
// `GossipTitleButton<buttonIndex>` and then incrementing past it (ref l.80-84, l.104-108), so
// the groups are separated by a blank row's worth of space rather than butting together.
//
// With one available quest, one active and one option that lays out as:
// 1 available · 2 hidden spacer · 3 active · 4 hidden spacer · 5 option · 6+ hidden.
// Available leads active (`GossipFrame.lua:27-28`), though the packet listed the active quest
// first. Each quest group ends by hiding the next button and skipping it (`:80-84`,
// `:104-108`): 1 available, 2 spacer, 3 active, 4 spacer, 5 option, the rest hidden.
assert_eq!((r1_text.as_str(), r1_vis), ("A Threat Within", true));
assert!(!r2_vis, "the available group's trailing spacer");
assert_eq!((r3_text.as_str(), r3_vis), ("Report to Goldshire", true));
@@ -342,8 +269,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
);
assert!(!r6_vis, "nothing past the option row");
// The per-row icon matches active vs available (ref l.75/99), verified via the resolved quads
// rather than the Lua-side texture string, so the assertion also proves the rows actually paint.
// Each group's icon (`GossipFrame.lua:75`, `:99`), read from the painted quads.
s.resolve();
let quads = s.extract();
let has_icon = |needle: &str| {
@@ -357,10 +283,7 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
);
assert!(has_icon("ActiveQuestIcon"), "row 2 (active) icon renders");
// The window's rect places row 3 (the option) strictly below row 2 (the last quest row) — the
// static anchor chain, not a runtime SetPoint, closes the gap between the two sections. Found
// by each row's own label text (rects are y-up, per `ExtractedQuad::rect`'s doc — "below" means
// a smaller `top`), the same technique the existing test already uses for the vendor icon/label.
// The option sits below the last quest row; rects are y-up, so below is a smaller top.
let label_top = |needle: &str| {
quads
.iter()
@@ -378,13 +301,8 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
"row 3 (option) sits below row 2 (last quest row): row2 top {row2_top}, row3 top {row3_top}"
);
// Click quest row 2 — the ACTIVE quest "Report to Goldshire", which the packet listed FIRST.
// The row calls `SelectGossipActiveQuest(1)`: 1 because it is the first row of the active list,
// which is the index the reference's own `GossipTitleButton_OnClick` passes. The binding maps
// that back to the whole-menu position the app's queue speaks — 1, the packet's own order. The
// two numbers being different for the same click is exactly what the single-list
// `SelectGossipQuest` we retired could not express.
// Button 3, not 2: the reference's hidden spacer sits at 2 (see the layout note above).
// Button 3, the active quest, calls `SelectGossipActiveQuest(1)`, its place in the active list
// (`GossipFrame.lua:53-61`); the binding maps it to menu position 1, the packet's order.
s.run("GossipTitleButton3:Click()").unwrap();
assert_eq!(s.take_gossip_quest_selects(), vec![1]);
assert!(
@@ -393,42 +311,21 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
);
}
/// A gossip option whose label WRAPS gets a row as tall as its wrapped text (the reference's
/// `GossipResize` — `SetHeight(GetTextHeight() + 2)`, ref-GossipFrame.lua l.130-132), so the static
/// row chain (each row on the previous row's BOTTOMLEFT) still stacks them clear of one another.
/// Without the resize every row stayed the template's 16 px while its label drew 2-3 wrapped lines,
/// and the labels printed on top of each other — the director's screenshot of a four-option
/// judgement menu, every option overlapping the next.
///
/// The engine-only harness has no font atlas, so the measure round-trip is answered with a
/// deterministic 6 px/char × 14 px/line fake (the app answers from the real atlas in-game). The
/// assertions pin the LAW — row height = its label's measured height + 2, and consecutive rows share
/// an edge — not any glyph metric.
/// `GossipResize` sizes a row to its label's height + 2 (`GossipFrame.lua:130-132`) and each row
/// hangs off the previous one's BOTTOMLEFT (`GossipFrame.xml:267`), so wrapped labels stack
/// without overlapping. The checks pin that rule, not a glyph metric.
#[test]
fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// **A measurer, because the reference measures inside the tick it sets the text.**
// `GossipResize` reads `titleButton:GetTextHeight()` on the line after `SetText`
// (`GossipFrame.lua:130-137`), and without a `TextMeasure` installed a bare VM answers the
// PREVIOUS frame's box — the row comes out 2px tall.
//
// This is what deferred the gossip window through 1751: it was read as "our measurement is a
// frame late", an engine gap. It is not. `UiScript::fill_measures` closes the loop inline
// whenever a measurer is installed, which the app always does
// (`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.
// `GossipResize` reads `GetTextHeight()` right after `SetText`, so the harness installs the
// synchronous measurer the app always has; without one a row sizes from last frame's text.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.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, "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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -437,8 +334,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
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
// at the row label's 275 px width.
// Three options that wrap at the label's 275 px (`GossipFrame.xml:114`).
let long = |t: &str| GossipOptionView {
label: t.into(),
icon_type: "gossip".into(),
@@ -465,8 +361,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
s.fire_event("GOSSIP_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The host's job: measure every FontString the frame asks about. 6 px/char, wrapped at the
// request's own wrap width, 14 px per resulting line.
// The host's batch answers: 6 px a character, wrapped at the request's width, 14 px a line.
let answer_measures = |s: &mut UiScript| {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
@@ -484,12 +379,8 @@ 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 — 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.
s.tick(0.016);
answer_measures(&mut s);
s.resolve();
@@ -505,11 +396,9 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let (t1, b1, h1) = row(1);
let (t2, b2, h2) = row(2);
let (t3, b3, h3) = row(3);
// Every label wrapped (the fake measure gives ≥ 2 lines of 14) …
for (i, h) in [(1, h1), (2, h2), (3, h3)] {
assert!(h >= 28.0, "row {i}'s label wraps: measured height {h}");
}
// … and each row is exactly its label + the reference's 2 px.
for (i, (top, bottom, h)) in [(1, (t1, b1, h1)), (2, (t2, b2, h2)), (3, (t3, b3, h3))] {
assert!(
(top - bottom - (h + 2.0)).abs() < 0.5,
@@ -517,7 +406,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
top - bottom
);
}
// The chain stacks them edge to edge — no row overprints the next (rects are y-up).
// Edge to edge; rects are y-up.
assert!(
(t2 - b1).abs() < 0.5,
"row 2 starts where row 1 ends: row1 bottom {b1}, row2 top {t2}"
@@ -528,14 +417,7 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
);
}
/// Pin §4's headline scenario, and §2's whole justification: with both windows loaded, opening
/// gossip then merchant closes gossip purely through panel replacement (both register
/// pushable=0, so the second `ShowUIPanel` replaces the left occupant — UIParent.lua l.729-732) —
/// never a server-side CloseGossip. Merchant's own close then vacates the slot.
/// The gossip window's open/close kits — the window-sound convention (decision 0090). The real
/// GossipFrame.xml frame Scripts play igQuestListOpen on OnShow (l.445) and igQuestListClose on OnHide
/// (l.454); GOSSIP_SHOW → ShowUIPanel → Show() fires OnShow, GOSSIP_CLOSED → HideUIPanel → Hide() fires
/// OnHide. Nothing queues at load (the frame is authored hidden="true").
/// The window's OnShow and OnHide play the kits (`GossipFrame.xml:445`, `:454`).
#[test]
fn gossip_show_hide_plays_open_and_close_kits() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -544,12 +426,7 @@ fn gossip_show_hide_plays_open_and_close_kits() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -558,7 +435,6 @@ fn gossip_show_hide_plays_open_and_close_kits() {
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).
assert!(
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
@@ -592,20 +468,13 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
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, "ScrollTemplates.xml"); // our scroll kits
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");
// 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);
}
@@ -630,8 +499,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
let win = frame_rect(&s.extract(), 384.0, 512.0);
assert_eq!((win.left, win.top), (0.0, 664.0));
// The vendor gossip option is picked; the app's SMSG_LIST_INVENTORY handler opens the
// merchant window with no server-side gossip close (pin §2) — purely a second ShowUIPanel.
// The vendor replaces gossip through the panel manager alone; no GOSSIP_CLOSED fires.
s.set_merchant(Some(MerchantState {
items: vec![MerchantItem {
name: Some("Refreshing Spring Water".into()),
@@ -641,8 +509,6 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
num_available: -1,
item_id: 159,
stats: None,
// Not this test's subject (the slot manager is) — a row with no template answer yet
// carries no link (decision 1059).
link: None,
max_stack: Some(1),
}],
@@ -651,8 +517,7 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Gossip is hidden (both pushable=0 ⇒ SetLeftFrame replaced it); merchant now holds the left
// slot at the same anchor.
// Both rows are pushable 0, so the newcomer replaces gossip (`UIParent.lua:729-732`).
assert!(
!s.eval::<bool>("return GossipFrame:IsVisible()").unwrap(),
"opening merchant replaced gossip at the left slot"
@@ -669,7 +534,6 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
.eval::<bool>("return GetLeftFrame():GetName() == \"MerchantFrame\"")
.unwrap());
// Merchant's own close vacates the slot entirely.
s.fire_event("MERCHANT_CLOSED", vec![]);
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
@@ -678,43 +542,33 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The cross-window session-clear (decision 0095): when one NPC window displaces another at the left
/// panel slot, the displaced window's OnHide fires its `CloseX()`, queuing the client-side clear
/// intent the app drains to end that session's resource. Without it the displaced session stayed
/// "open" in its resource, and the window would not reopen until the range-guard reset it — the
/// director's gossip↔vendor lockup. This drives the exact displacement and asserts the intents.
/// A displaced NPC window's OnHide calls its `CloseX()`, whose intent ends that session; a session
/// left open keeps its window from reopening.
#[test]
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, 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, "ScrollTemplates.xml"); // our scroll kits
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");
// 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);
}
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// Vendor open at the left slot; clear any startup/open intents.
// The vendor holds the left slot; drain what opening it queued.
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![]);
let _ = s.take_merchant_close();
let _ = s.take_gossip_close();
// Gossip opens over it → SetLeftFrame hides the merchant → merchant OnHide → CloseMerchant().
// Gossip replaces the vendor; its OnHide calls `CloseMerchant()` (`MerchantFrame.lua:51-52`).
s.set_gossip(Some(GossipMenu {
greeting: "Well met.".into(),
quests: Vec::new(),
@@ -727,7 +581,7 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
"gossip displacing the vendor ends the vendor session (OnHide → CloseMerchant)"
);
// The reverse: the vendor back over gossip → gossip OnHide → CloseGossip().
// And back: gossip's OnHide calls `CloseGossip()` (`GossipFrame.xml:455`).
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -737,36 +591,26 @@ 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 `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
/// its own `QuadContent::Frame` quad at its resolved rect (`UiScript::extract`'s doc), which is
/// enough to prove the slot math without a real loot window.
/// A left occupant with a higher `pushable` (LootFrame's 7) moves to center when a pushable-0
/// window arrives (`UIParent.lua:734-741`); a bare 50x50 frame stands in for the loot window.
#[test]
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, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, "ScrollTemplates.xml"); // our scroll kits
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 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);
}
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
// The synthetic pushable=7 loot stand-in opens first onto the empty left slot. `CreateFrame`
// frames start shown by default (matching the real client) — every shipped panel frame is
// authored `hidden="true"` for exactly this reason (ShowUIPanel no-ops on an already-visible
// frame), so the stand-in hides itself first too.
// A new frame starts shown, and `ShowUIPanel` ignores a visible one (`UIParent.lua:650`).
s.run(
r#"
local loot = CreateFrame("Frame", "LootFrame")
@@ -786,8 +630,7 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
let loot_left = frame_rect(&s.extract(), 50.0, 50.0);
assert_eq!((loot_left.left, loot_left.top), (0.0, 664.0));
// Merchant (pushable=0) then opens: loot's pushable=7 outranks it, so loot is pushed to the
// center slot (384, -104) and merchant takes the left spot loot vacated.
// The center slot is TOPLEFT 384,-104 (`UIParent.lua:880`).
s.set_merchant(Some(MerchantState {
items: vec![MerchantItem {
name: Some("Refreshing Spring Water".into()),
@@ -797,8 +640,6 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
num_available: -1,
item_id: 159,
stats: None,
// Not this test's subject (the slot manager is) — a row with no template answer yet
// carries no link (decision 1059).
link: None,
max_stack: Some(1),
}],
@@ -834,28 +675,21 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The gossip → bank handoff (decision 0604 follow-up, director-observed): vmangos sends no
/// `SMSG_GOSSIP_COMPLETE` for the gossip menu's bank option, and the slot logic alone can't close
/// the menu — the bank's `pushable = 6` promotes it to the *center* slot beside a pushable-0
/// gossip instead of replacing it. The app's `show_bank` therefore ends the gossip session itself,
/// so BANKFRAME_OPENED and GOSSIP_CLOSED fire in the *same frame* — in either order, since the
/// two feeds aren't ordered against each other. Both orders must converge on the same end state:
/// gossip hidden, the bank holding the LEFT slot (HideUIPanel's left-vacate branch slides a
/// center-parked "left-area" occupant back — UIParent.lua l.777-782).
/// vmangos sends only `SMSG_SHOW_BANK` for the bank option (`Player.cpp:12349-12351`), and the
/// bank (pushable 6) would seat at center, so the app closes gossip itself. Either order ends
/// with the bank at left; opened first, it slides back when gossip hides (`UIParent.lua:772-783`).
#[test]
fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_ui::script::BankState;
// The bank window is the reference's own file off the player's chain (1751).
let _data = benilla_formats::wow_data_or_skip!();
// The bank window's own dependency chain (bank_tests::setup), plus the gossip window.
let order_first = |bank_first: bool| {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
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, "Interface\\FrameXML\\ItemButtonTemplate.xml"); // the bank slots inherit it
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
@@ -865,20 +699,13 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
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, "ScrollTemplates.xml");
// Before BankFrame: its buttons inherit these templates, and `inherits=` resolves at load.
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\BankFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GossipFrame.xml");
// The gossip menu is open on the banker (its bank option showing).
s.set_gossip(Some(GossipMenu {
greeting: "Welcome to the bank of Ironforge!".into(),
quests: Vec::new(),
@@ -893,9 +720,7 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
.eval::<bool>("return GetLeftFrame():GetName() == \"GossipFrame\"")
.unwrap());
// The option is picked; SMSG_SHOW_BANK lands and the app both opens the bank AND clears
// the gossip session in the same apply pass — the two events fire the same frame, in
// whichever order the feeds run.
// On SMSG_SHOW_BANK the app opens the bank and clears gossip in one pass, in either order.
s.set_money(0);
s.set_bank(Some(BankState::default()));
s.set_gossip(None);
@@ -927,38 +752,29 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
order_first(false);
}
/// A menu too tall for the parchment SCROLLS instead of spilling out of the window — the reference's
/// own answer (`GossipGreetingScrollFrame`, ref-GossipFrame.xml l.223-436), which benilla had omitted
/// on the grounds that "our short greeting needs no scrolling". Once the rows grew to their wrapped
/// text, a menu of long options ran straight off the bottom of the window and over the world (the
/// director's second screenshot).
///
/// Pins the whole mechanism: the content is measured into the scroll child, the range is the overflow,
/// the bar appears only when there IS overflow, every row is CLIPPED to the frame's rect, and
/// scrolling moves the content up under that clip. Same deterministic measure fake as the row test.
/// The rows sit in `GossipGreetingScrollFrame` (`GossipFrame.xml:223-436`): a menu taller than the
/// frame gets a scroll range and a bar, and every row clips to the frame.
#[test]
fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// A measurer: `GossipResize` reads `GetTextHeight()` on the line after `SetText`, so a bare
// VM sizes every row from the previous frame's box. See
// `shipped_gossip_rows_grow_to_their_wrapped_labels` for why this is a harness gap and not an
// engine one.
// `GossipResize` reads `GetTextHeight()` right after `SetText`, so the harness installs the
// synchronous measurer the app always has; without one a row sizes from last frame's text.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.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, "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\UIPanelTemplates.xml"); // UIPanelScrollFrameTemplate
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.
// Eight options of about four lines each, far past the 334 px frame (`GossipFrame.xml:225`).
let long = |n: usize| GossipOptionView {
label: format!(
"Option {n}: I slay the man on the spot as my liege would expect me to, as he has \
@@ -988,7 +804,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
.collect();
s.set_measured_text_unwrapped(&answers);
};
// Settle: measures land, rows fit, the child is sized to them, the bar re-ranges.
// A few frames for the measures to land, the rows to fit and the bar to re-range.
for _ in 0..3 {
answer_measures(&mut s);
s.resolve();
@@ -997,14 +813,8 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// There is a range to scroll — which is the property that matters and the one the window
// exists to have.
//
// The child's own HEIGHT is no longer part of the assertion. Ours grew it past the 334px frame
// in `BenillaGossipFrame_SizeScrollChild`, a helper the reference does not have: stock leaves
// the scroll child at the frame's size and lets the rows' own extents drive the range. So the
// child measures 334 and the range is non-zero, where ours had both. Asserting the range alone
// is asserting the behaviour rather than one implementation's way of reaching it.
// The scroll child keeps its declared 300x334 (`GossipFrame.xml:236-238`), so only the range is
// checked: the rows' extents make it.
let (child_h, range): (f32, f32) = s
.eval(
"return GossipGreetingScrollChildFrame:GetHeight(), \
@@ -1015,19 +825,13 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
range > 0.0,
"the overflowing menu has somewhere to scroll: child {child_h}, range {range}"
);
// … and the bar is up for it (it stays hidden when everything fits — the fit case is covered by
// `shipped_gossip_frame_drives_end_to_end`'s two-option menu).
assert!(
s.eval::<bool>("return GossipGreetingScrollFrameScrollBar:IsVisible()")
.unwrap(),
"the scrollbar shows once the menu overflows"
);
// Every row is CLIPPED to the scroll frame — the mechanism that replaces "spilling out of the
// window". The engine clips by carrying a clip rect on the quad (decision 0112 §4/§5, applied in
// `ui_pass`), not by shrinking its rect, so the check is on the clip each row's text quad rides
// out with: it must be the scroll frame's own rect, and rows past the bottom must be entirely
// outside it (nothing of them survives the clip).
// A quad carries a clip rect rather than a shrunk rect: each row's clip is the frame's rect.
let (frame_top, frame_bottom): (f32, f32) = s
.eval("return GossipGreetingScrollFrame:GetTop(), GossipGreetingScrollFrame:GetBottom()")
.unwrap();
@@ -1052,15 +856,12 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
);
let _ = rect;
}
// And the menu really is longer than the window: at least one row sits entirely below the
// frame's bottom edge (drawn nowhere, because the clip discards it) rather than over the world.
assert!(
rows.iter().any(|(rect, _)| rect.top < frame_bottom),
"the menu overflows: some row is entirely below the frame"
);
// Scrolling pans the content up under that clip by exactly the scroll amount. Rects are y-up
// (`ExtractedQuad::rect`), so "up" means row 1's top EDGE VALUE grows as it slides off the top.
// Our kit's `BenillaScroll_Step` pans 100 px; rects are y-up, so row 1's top grows by 100.
let first_top =
|s: &mut UiScript| -> f32 { s.eval::<f32>("return GossipTitleButton1:GetTop()").unwrap() };
let before = first_top(&mut s);
@@ -1072,7 +873,6 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
(after - before - 100.0).abs() < 0.5,
"scrolling 100px lifts the content 100px: {before} → {after}"
);
// The bar followed the scroll (SyncBar off OnVerticalScroll), and the rows are still clipped.
assert_eq!(
s.eval::<f32>("return GossipGreetingScrollFrameScrollBar:GetValue()")
.unwrap(),
@@ -1088,20 +888,10 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── the panel manager as an ADDON reaches it (1206's audit, finished) ─────────────────────────
// ── The panel manager as an addon reaches it ──
/// **An addon's own window, registered in `UIPanelWindows`, gets the left slot** — the same slot,
/// at the same anchor, as one of ours.
///
/// `UIPanelWindows["MyFrame"] = { area = "left", pushable = 1 }` is the 1.12 way an addon asks to
/// be a panel rather than a floating box, and **20 corpus addons write that line**. The table has
/// been present since the manager landed, so nothing would have raised — but a table an addon can
/// write to and a manager that honours what it wrote are different claims, and only the second one
/// is worth anything (1203's lesson: a capability absent without a failure is the quiet kind).
///
/// The assertion is deliberately the *same* rect the shipped panels are asserted at
/// (`(0.0, 664.0)` for a 384x512 at 1024x768), so an addon's window is not merely "somewhere" —
/// it is where a client window would be.
/// An addon makes its frame a panel with a `UIPanelWindows` row, and it seats where a client
/// window does.
#[test]
fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1112,14 +902,13 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
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\UIPanelTemplates.xml"); // UIPanelScrollFrameTemplate
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.
s.run(
r#"AddonPanel = CreateFrame("Frame", "AddonPanel", UIParent)
AddonPanel:SetWidth(384) AddonPanel:SetHeight(512)
@@ -1141,15 +930,8 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
"the addon's panel is at the left slot, where a client panel would be"
);
// ...and it takes part in the pushable arbitration like one of ours. The reference's own
// branch (UIParent.lua l.725-741): left is the addon at pushable=1, nothing in center, and a
// pushable=0 window arrives — `leftInfo.pushable > info.pushable`, so the addon's frame is
// MOVED TO CENTER and the client window takes left. Both stay visible.
//
// Worth spelling out because the obvious expectation is the opposite one, and it is what this
// test asserted first: "a pushable=0 window replaces what is holding the slot" is only true
// when BOTH are pushable=0 (l.728-731). Getting this wrong for an addon means either losing
// its window or leaving two panels on top of each other.
// A pushable-0 window arriving moves the addon's pushable-1 frame to center
// (`UIParent.lua:735-737`); only two pushable-0 rows replace each other (`:729-732`).
s.set_gossip(Some(GossipMenu {
greeting: "Well met.".into(),
quests: Vec::new(),
@@ -1170,20 +952,14 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
"and it is at the center slot (UIParent TOPLEFT +384, -104)"
);
// HideUIPanel on an addon frame that is not currently in a slot must be a no-op, not an error.
s.run("HideUIPanel(AddonPanel)").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── the registry pinned to the bytes (decision 1507) ──────────────────────────────────────────
// ── The registry, pinned to the reference's rows ──
/// **The 1507 rows read exactly as the reference wrote them.** The registry is DATA — a different
/// number is a different window order — and 1507 exists because one row drifted unnoticed for
/// months (CharacterFrame carried pushable=0 labelled as "the ref's own row"; the bytes say
/// `pushable = 2, whileDead = 1`, UIParent.lua l.19) while another was simply missing
/// (ItemTextFrame, l.20 — bug B288: the reader opened through ShowUIPanel's unregistered bare-Show
/// branch, so gossip seated itself straight over the open note). A drive-by edit re-introducing
/// either now fails a named test instead of waiting for a player with a Verdant Note.
/// The registry rows as `UIParent.lua:14-50` writes them. An unregistered frame opens with a bare
/// `Show` and takes no slot (`:658-661`), so a missing row lets the next panel seat over it.
#[test]
fn the_1507_registry_rows_match_the_reference_bytes() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1191,7 +967,7 @@ fn the_1507_registry_rows_match_the_reference_bytes() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, r"Interface\FrameXML\GlobalStrings.lua");
@@ -1199,19 +975,17 @@ fn the_1507_registry_rows_match_the_reference_bytes() {
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).
// UIParent.lua:20
"UIPanelWindows['ItemTextFrame'].area == 'left'",
"UIPanelWindows['ItemTextFrame'].pushable == 0",
"UIPanelWindows['ItemTextFrame'].whileDead == nil",
// CharacterFrame — UIParent.lua l.19: pushable 2 (slides beside an NPC session), not 0.
// UIParent.lua:19
"UIPanelWindows['CharacterFrame'].pushable == 2",
"UIPanelWindows['CharacterFrame'].whileDead == 1",
// The whileDead flags the ref authors and 1507 carried (l.21, l.25, Blizzard_TalentUI:71).
// UIParent.lua:21 and :25
"UIPanelWindows['SpellBookFrame'].whileDead == 1",
"UIPanelWindows['QuestLogFrame'].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).
// UIParent.lua:45-50; `TalentFrame`'s row loads with `Blizzard_TalentUI.lua:71`.
"table.getn(UIChildWindows) == 4",
"UIChildWindows[1] == 'OpenMailFrame'",
"UIChildWindows[2] == 'GuildControlPopupFrame'",
@@ -1225,10 +999,7 @@ fn the_1507_registry_rows_match_the_reference_bytes() {
}
}
/// **A dead player opens only the windows whose row asked for it** — ShowUIPanel's own guard
/// (UIParent.lua l.663-666: `UnitIsDead("player") and not info.whileDead` refuses the open),
/// live now that `UnitIsDead` is a real unit binding. The refused window must not show AND must
/// not take a slot; a `whileDead = 1` row (the quest log, l.25) opens exactly as alive.
/// While dead, `ShowUIPanel` opens only a `whileDead` row (`UIParent.lua:663-666`).
#[test]
fn a_dead_player_opens_only_whiledead_windows() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1254,8 +1025,7 @@ 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.
// Bare stand-ins suffice: the guard runs before any seat is chosen.
s.run(
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()
@@ -1271,8 +1041,7 @@ fn a_dead_player_opens_only_whiledead_windows() {
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"the refused window took no slot"
);
// ...and the refusal is heard: NotWhileDeadError (the binary's 0x48d340 — push 0x7e) queued
// the catalog row's key for the app to resolve and toast.
// `NotWhileDeadError` (`0x48d340`, error 0x7e) queues its key for the app to show.
assert_eq!(
s.take_ui_errors(),
vec!["ERR_PLAYER_DEAD"],
@@ -1296,12 +1065,8 @@ fn a_dead_player_opens_only_whiledead_windows() {
);
}
/// **A frame ARRIVING at the center seat puts the child windows away; a frame PUSHED there does
/// not** — SetCenterFrame's `UIChildWindows` hide loop (UIParent.lua l.839-846), carried since
/// 1507, and the ref's exact asymmetry: MovePanelToCenter re-seats by direct assignment after a
/// `SetCenterFrame(nil)`, so the slide never runs the loop. The open letter (OpenMailFrame) is
/// the shipped tenant; a bare stand-in proves the slot math without the mail window's whole
/// dependency chain.
/// `SetCenterFrame` hides every `UIChildWindows` frame (`UIParent.lua:839-846`), but
/// `MovePanelToCenter` seats by assignment (`:877-885`), so a frame pushed to center leaves them.
#[test]
fn a_frame_arriving_at_center_puts_the_child_windows_away() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1317,9 +1082,8 @@ fn a_frame_arriving_at_center_puts_the_child_windows_away() {
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).
// MerchantFrame (pushable 0) holds left; TradeFrame (pushable 1) arrives at center
// (`UIParent.lua:738-739`).
s.run(
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()
@@ -1339,8 +1103,7 @@ fn a_frame_arriving_at_center_puts_the_child_windows_away() {
"the arriving center frame hid the open letter (the UIChildWindows loop)"
);
// The asymmetry: seats cleared, letter re-shown, LootFrame (pushable 7) holds left and a
// pushable=0 window shoves it — MovePanelToCenter SLIDES loot across, and the letter stays.
// LootFrame (pushable 7) holds left; a pushable-0 window slides it to center, the letter stays.
s.run(
r#"HideUIPanel(TradeFrame) HideUIPanel(MerchantFrame)
OpenMailFrame:Show()
@@ -1,16 +1,11 @@
//! 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
//! it; the latency→color law and its 10 s poll; and the hover tooltip's live number.
//! The stock latency meter (`MainMenuBar.xml:344`): the tinted bar in the main bar's last empty
//! recess, its 10 s poll and its hover tooltip.
use benilla_ui::script::{QuadContent, UiScript};
use super::test_ui::load_ui as load_xml;
/// The bar on a 1024×768 screen. 1024 wide is deliberate: the 1024-wide bar then spans x 0..1024,
/// so every ref offset below is also an absolute screen coordinate.
/// A 1024-wide screen, so the 1024-wide bar spans x 0..1024 and its offsets are screen coordinates.
fn harness(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -34,8 +29,7 @@ fn harness(extra: &[&str]) -> UiScript {
s
}
/// The meter's own paint quad (its `MainMenuBarPerformanceBar` texture) and where it sits in the
/// painter's order.
/// The meter texture's quad: its draw-order index, its rect and its tint.
fn bar_quad(s: &mut UiScript) -> (usize, [f32; 4], Option<[f32; 4]>) {
s.resolve();
let quads = s.extract();
@@ -54,11 +48,8 @@ fn bar_quad(s: &mut UiScript) -> (usize, [f32; 4], Option<[f32; 4]>) {
(i, [rect.left, rect.bottom, rect.right, rect.top], color)
}
/// ref-MainMenuBar.xml l.344-364: a 16×64 frame at the bar's BOTTOMRIGHT +(-227,-10), carrying a
/// 20×66 texture off its TOPRIGHT. On a 1024-wide bar that is x 781..797 for the frame and
/// x 777..797 for the texture — the empty recess between the last micro button (which ends at 763)
/// and the bag cluster. Both hang below the bar's own bottom, which is what drops the texture's
/// grey column into the slot rather than floating it above.
/// `MainMenuBar.xml:344`: a 16x64 frame at the bar's BOTTOMRIGHT (-227, -10) with a 20x66 texture
/// off its TOPRIGHT, in the recess between the last micro button and the bags.
#[test]
fn the_meter_sits_in_the_bar_recess_the_reference_leaves_for_it() {
benilla_formats::wow_data_or_skip!();
@@ -75,8 +66,7 @@ fn the_meter_sits_in_the_bar_recess_the_reference_leaves_for_it() {
assert_eq!(left, 781.0, "1024 − 227 − 16");
assert_eq!(bottom, -10.0, "hangs 10 below the bar's bottom");
// The texture overspills the frame: 20 wide off the TOPRIGHT ⇒ 4 further left, and 66 tall from
// the frame's top (54) ⇒ 2 further down.
// 20x66 off the frame's TOPRIGHT (797, 54): 4 past its left edge, 2 below its bottom.
let (_, rect, _) = bar_quad(&mut s);
assert_eq!(
rect,
@@ -86,9 +76,7 @@ fn the_meter_sits_in_the_bar_recess_the_reference_leaves_for_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `frameStrata="LOW"` is the mechanism, not decoration: the bar art is a transparent WINDOW over
/// this slot, so the meter has to paint UNDERNEATH it. At the default MEDIUM the 20-wide texture
/// would paint over the metal surround that frames the 10-wide slot.
/// The meter is LOW strata (`MainMenuBar.xml:344`): it paints under the bar art, through its slot.
#[test]
fn the_meter_paints_under_the_bar_art_it_shows_through() {
benilla_formats::wow_data_or_skip!();
@@ -109,8 +97,7 @@ fn the_meter_paints_under_the_bar_art_it_shows_through() {
transparent slot, not over it"
);
// And the hover button is above everything, the ref's own HIGH-strata split: the LOW frame
// beneath the art can't take the mouse itself.
// The button is HIGH (`MainMenuBar.xml:395`), as the LOW frame under the art gets no mouse.
assert_eq!(
s.eval::<String>("return MainMenuBarPerformanceBarFrameButton:GetFrameStrata()")
.unwrap(),
@@ -125,16 +112,14 @@ fn the_meter_paints_under_the_bar_art_it_shows_through() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// ref-MainMenuBar.xml l.375-392: the poll runs every `PERFORMANCEBAR_UPDATE_INTERVAL` seconds and
/// tints the bar green under 300 ms, yellow to 600, red beyond. 1.12 never scales the bar's HEIGHT
/// — color is the entire readout, which is why this test asserts the tint and the rect separately.
/// `MainMenuBar.xml:375`: every 10 s the poll tints the bar green to 300 ms, yellow to 600, red
/// beyond; the tint is the whole readout, the bar never changes height.
#[test]
fn the_meter_tints_by_latency_on_the_reference_thresholds() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[]);
// The first tick polls immediately (updateInterval starts at 0), so an unmeasured connection
// reads 0 ms and shows green rather than sitting untinted grey.
// updateInterval starts at 0 (`MainMenuBar.xml:373`), so the first tick polls: 0 ms, green.
s.tick(0.016);
let (_, _, color) = bar_quad(&mut s);
assert_eq!(color, Some([0.0, 1.0, 0.0, 1.0]), "0 ms ⇒ green");
@@ -150,8 +135,6 @@ fn the_meter_tints_by_latency_on_the_reference_thresholds() {
assert_eq!(color, Some(want), "{latency} ms: {band}");
}
// A latency that crosses a threshold does NOT repaint before the next poll beat — the ref's
// own 10 s cadence, and the reason a 30 s ping's jitter can't strobe the bar.
s.set_latency_ms(Some(0));
s.tick(1.0);
let (_, _, color) = bar_quad(&mut s);
@@ -170,8 +153,7 @@ fn the_meter_tints_by_latency_on_the_reference_thresholds() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The drawn colour of the tooltip's explanation line — found by its TEXT, so it can't be confused
/// with the label above it. `None` if that line isn't being painted at all.
/// The drawn colour of the tooltip's `NEWBIE_TOOLTIP_LATENCY` line, found by its text.
fn newbie_line_color(s: &mut UiScript) -> Option<[f32; 4]> {
let want = s
.eval::<String>("return NEWBIE_TOOLTIP_LATENCY")
@@ -187,17 +169,14 @@ fn newbie_line_color(s: &mut UiScript) -> Option<[f32; 4]> {
})?
}
/// Advance exactly one poll beat. The ref's OnUpdate spends the whole remaining interval on one
/// tick and only polls on the tick that finds it non-positive, so a beat is always two ticks longer
/// than the interval: the first drains it, the second crosses zero and re-arms it at 10.
/// One poll beat: OnUpdate polls only on a tick that finds the interval spent, so one tick drains
/// it and the next polls (`MainMenuBar.xml:376`).
fn poll_beat(s: &mut UiScript) {
s.tick(11.0);
s.tick(11.0);
}
/// `GetNetStats()` is the whole seam: the app pushes the averaged RTT, the meter reads it. The two
/// bandwidth returns are the named gap (benilla tallies no throughput) and must stay NUMBERS, so
/// arithmetic on them can't error.
/// The app pushes the averaged RTT; both bandwidth returns stay 0, as benilla counts no throughput.
#[test]
fn get_net_stats_reports_the_pushed_latency() {
benilla_formats::wow_data_or_skip!();
@@ -212,8 +191,7 @@ fn get_net_stats_reports_the_pushed_latency() {
s.eval::<(f64, f64, f64)>("return GetNetStats()").unwrap(),
(0.0, 0.0, 42.0)
);
// Back to unmeasured (a disconnect clears the app's ring) ⇒ 0, the reference's own reading for
// a connection it has no sample for.
// A disconnect clears the samples; no sample reads 0, as in the reference.
s.set_latency_ms(None);
assert_eq!(
s.eval::<f64>("local _, _, ms = GetNetStats() return ms")
@@ -223,22 +201,13 @@ fn get_net_stats_reports_the_pushed_latency() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hover tooltip (ref l.395-406): the HIGH-strata button owns the mouse over the meter, and its
/// tooltip reads the live number. The held-open plate re-reads it on the same 10 s beat.
///
/// The plate is the ref's TWO-line `GameTooltip_AddNewbieTip` — detailed tips ship ON in 1.12
/// (`SHOW_NEWBIE_TIPS = "1"`, ref UIOptionsFrame.lua l.100), so the explanation under the number is
/// the DEFAULT hover, not an opt-in. Decision 0661; this test is the guard on that, because a
/// regression here (the helper's branch flipping, the string going missing) shows up only as a
/// tooltip that is quietly one line short.
/// The hover tooltip is `GameTooltip_AddNewbieTip` (`MainMenuBar.xml:400`); each poll rewrites it.
#[test]
fn hovering_the_meter_shows_the_live_latency() {
benilla_formats::wow_data_or_skip!();
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.
// The 1.12 default (`UIOptionsFrame.lua:100`); the options file is not loaded here.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.resolve();
@@ -266,22 +235,20 @@ fn hovering_the_meter_shows_the_live_latency() {
s.eval::<String>("return NEWBIE_TOOLTIP_LATENCY").unwrap(),
"line 2 is the ref's own NEWBIE_TOOLTIP_LATENCY, verbatim"
);
// The newbie branch seats the plate at the default screen corner, NOT beside the meter — the
// `default` flag GameTooltip_SetDefaultAnchor stamps is the observable half of that.
// The newbie branch anchors the plate at the default corner (`GameTooltip.lua:109`).
assert_eq!(
s.eval::<i64>("return GameTooltip.default").unwrap(),
1,
"the default-corner anchor, not ANCHOR_RIGHT off the button"
);
// …and the explanation draws in NORMAL_FONT_COLOR, the gold that separates it from the white
// label above it.
// The explanation line is `NORMAL_FONT_COLOR` (`GameTooltip.lua:112`).
assert_eq!(
newbie_line_color(&mut s),
Some([1.0, 0.82, 0.0, 1.0]),
"NORMAL_FONT_COLOR (Fonts.xml l.37)"
);
// Still hovering, and the latency moved: the next poll beat rewrites the plate in place.
// Still hovering: the next beat rewrites the plate (`MainMenuBar.xml:388`).
s.set_latency_ms(Some(7));
poll_beat(&mut s);
assert_eq!(
@@ -300,7 +267,6 @@ fn hovering_the_meter_shows_the_live_latency() {
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
);
// …and the poll no longer touches it.
s.set_latency_ms(Some(999));
poll_beat(&mut s);
assert!(
@@ -310,8 +276,6 @@ fn hovering_the_meter_shows_the_live_latency() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The meter is a frame slot in the bar's own child list — a regression guard on the frame count
/// the bar's end-to-end test pins, kept here so the two move together.
#[test]
fn the_meter_adds_its_two_frames_to_the_bar() {
benilla_formats::wow_data_or_skip!();
@@ -325,7 +289,6 @@ fn the_meter_adds_its_two_frames_to_the_bar() {
"{name} must exist"
);
}
// The button is the frame's child (the ref's `parent=` attribute, expressed as nesting).
assert_eq!(
s.eval::<String>("return MainMenuBarPerformanceBarFrameButton:GetParent():GetName()")
.unwrap(),
+56 -170
View File
@@ -1,20 +1,11 @@
//! 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.
//! The pet action bar, stock `PetActionBarFrame.xml`, from a pushed slot list to its quads.
use benilla_ui::script::{PetActionView, QuadContent, UiScript};
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.
/// 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).
/// The pet bar's chain in manifest order, then the chat window, whose edit box `ShowPetActionBar`
/// raises over the sliding bar (PetActionBarFrame.lua:175).
pub(super) fn load_pet_bar(s: &UiScript) {
for file in [
"Interface\\FrameXML\\Fonts.xml",
@@ -51,9 +42,7 @@ pub(super) 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.)
/// A token slot's `name` is a `GlobalStrings` key: the two the tests read, at their stock values.
pub(super) fn declare_token_strings(s: &UiScript) {
s.run(
"PET_ACTION_ATTACK = 'Attack' \
@@ -62,13 +51,13 @@ pub(super) fn declare_token_strings(s: &UiScript) {
.unwrap();
}
/// The words the drag moves — `ACT_COMMAND`/Attack and `ACT_ENABLED`/Claw as the server packs
/// them. Carried on the views because the drag is word arithmetic (decision 1010).
/// Packed words as the server sends them, Attack (`ACT_COMMAND`) and Claw (`ACT_ENABLED`, spell
/// 3010): the drag moves words.
const ATTACK_WORD: u32 = 0x0700_0002;
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).
/// A hunter's bar: Attack (a lit command) in slot 1, Claw (autocast on) in 4, Defensive (a lit
/// reaction) in 9, the rest empty.
pub(super) fn hunter_slots() -> Vec<PetActionView> {
let mut slots = vec![PetActionView::default(); 10];
slots[0] = PetActionView {
@@ -101,11 +90,8 @@ pub(super) fn hunter_slots() -> Vec<PetActionView> {
slots
}
/// Count `path` quads **in the pet bar's own row**. The prerequisite `ActionBar.xml` brings 12
/// action buttons wearing the same `UI-Quickslot2` ring, so an unscoped count would be measuring
/// the main bar. Cut by the quad's CENTRE, not an edge: the button rings overhang their buttons by
/// a dozen pixels each way (54 px of ring on a 30 px button), so the two rows' extents actually
/// overlap even though their centres — 21 for the main bar, 70..75 for the pet bar — do not.
/// Count `path` quads in the pet bar's row, cut at centre y 50: the rings overhang their buttons
/// (54 px on 30), so only centres part the rows (main bar 21, pet bar 70-75).
fn textures(quads: &[benilla_ui::script::ExtractedQuad], path: &str) -> usize {
quads
.iter()
@@ -124,8 +110,6 @@ fn texture_rect(
.and_then(|q| q.rect)
}
/// The whole bar, end to end: hidden with no pet, shown with one, the three slot classes each
/// painting what they should, and hidden again when the pet goes.
#[test]
fn the_shipped_pet_bar_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
@@ -134,7 +118,6 @@ fn the_shipped_pet_bar_drives_end_to_end() {
load_pet_bar(&s);
declare_token_strings(&s);
// No pet: the bar is hidden, and nothing of it reaches the quad pass.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
assert_eq!(
@@ -143,7 +126,6 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"no pet, no shelf"
);
// A pet appears.
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -151,15 +133,13 @@ fn the_shipped_pet_bar_drives_end_to_end() {
s.resolve();
let quads = s.extract();
// The shelf art: both UI-PetBar strips draw.
assert_eq!(
textures(&quads, "Interface\\PetActionBar\\UI-PetBar"),
2,
"the two shelf strips"
);
// A TOKEN slot resolved its texture through the global the app named — the `getglobal` fork.
// Seeing the ICON PATH here (not the literal "PET_ATTACK_TEXTURE") is the whole proof.
// A token slot's texture names a global, resolved by `getglobal` (PetActionBarFrame.lua:102).
assert_eq!(
textures(&quads, "Interface\\Icons\\Ability_GhoulFrenzy"),
1,
@@ -170,11 +150,9 @@ fn the_shipped_pet_bar_drives_end_to_end() {
1,
"Defensive's icon came from PET_DEFENSIVE_TEXTURE"
);
// A SPELL slot took its path verbatim.
assert_eq!(textures(&quads, "Interface\\Icons\\Ability_Druid_Rake"), 1);
// Occupancy: 3 named slots show their filled ring, the 7 unnamed ones are hidden outright
// (the reference hides an unnamed button rather than showing an empty well).
// An unnamed slot's button hides outright (PetActionBarFrame.lua:122-128).
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-Quickslot2"),
3,
@@ -186,17 +164,13 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"an unnamed pet slot hides; it does not draw the empty ring"
);
// The checked ring is on both lit slots, and nowhere else.
assert_eq!(
textures(&quads, "Interface\\Buttons\\CheckButtonHilight"),
2,
"Attack + Defensive are lit; Claw is not"
);
// 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.
// The shine is the `$parentAutoCast` model; a hidden pane is not extracted.
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
1,
@@ -214,10 +188,8 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"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),
// and MainMenuBar is the 1024x53 frame at screen BOTTOM ⇒ its BOTTOMLEFT is (0,0), so the
// frame spans y[54,97]. Button 1 sits at the frame's own BOTTOMLEFT +(36,2) ⇒ x[72,102]
// y[56,86] — the same 72 the reference lands on (its PETACTIONBAR_XPOS 36 + the button's 36).
// TOPLEFT at MainMenuBar's BOTTOMLEFT (0, 0) + (36, 97) (UIParent.lua:1589,1736): the 43-tall
// frame spans y[54,97], and button 1 at (36, 2) in it is x[72,102] y[56,86].
let attack =
texture_rect(&quads, "Interface\\Icons\\Ability_GhoulFrenzy").expect("Attack icon");
assert_eq!(
@@ -225,8 +197,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
(72.0, 56.0, 102.0, 86.0)
);
// 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.
// The pet goes: the bar slides out over `PETACTIONBAR_SLIDETIME` (0.09 s) and then hides.
s.set_pet_actions(false, true, true, Vec::new());
s.fire_event("PET_BAR_UPDATE", vec![]);
for _ in 0..3 {
@@ -245,9 +216,8 @@ fn the_shipped_pet_bar_drives_end_to_end() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The click law: left runs the slot, right flips autocast, and a left click on the ATTACK slot
/// while the pet is already attacking calls it off instead of re-ordering it (the reference's
/// `IsPetAttackActive` fork — the one branch that makes the Attack button a toggle).
/// Left casts, right flips autocast, and a left click on an active Attack calls it off
/// (`IsPetAttackActive`, PetActionBarFrame.lua:257-265).
#[test]
fn clicks_route_through_the_attack_toggle_fork() {
benilla_formats::wow_data_or_skip!();
@@ -259,14 +229,13 @@ fn clicks_route_through_the_attack_toggle_fork() {
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
// Button 1 (Attack) spans x[72,102] y[56,86] ⇒ centre (87,71). Claw is slot 4: the buttons
// chain +8 on a 30 px width, so button 4's left = 72 + 3*38 = 186 ⇒ centre (201,71).
// Button 1 (Attack) spans x[72,102] y[56,86], centre (87, 71); the 30 px buttons chain +8, so
// button 4 (Claw) starts at 72 + 3 * 38 = 186, centre (201, 71).
let click = |s: &mut UiScript, x: f32, y: f32, button: &str| {
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
};
// The pet IS attacking, so a left click on Attack calls it off — no action is queued.
click(&mut s, 87.0, 71.0, "LeftButton");
assert_eq!(s.take_pet_stop_attacks(), 1);
assert!(
@@ -274,7 +243,6 @@ fn clicks_route_through_the_attack_toggle_fork() {
"the call-off replaces the press, it does not accompany it"
);
// Not attacking any more: the same click orders the attack.
let mut slots = hunter_slots();
slots[0].active = false;
slots[0].attack_active = false;
@@ -285,8 +253,7 @@ fn clicks_route_through_the_attack_toggle_fork() {
assert_eq!(s.take_pet_actions(), vec![1]);
assert_eq!(s.take_pet_stop_attacks(), 0);
// Right-clicking the spell slot flips its autocast; right-clicking a command token does not
// (a token has no spell id for the wire verb to name).
// A right-click flips a spell's autocast; a token is not autocastable.
click(&mut s, 201.0, 71.0, "RightButton");
assert_eq!(s.take_pet_autocast_toggles(), vec![4]);
click(&mut s, 87.0, 71.0, "RightButton");
@@ -297,19 +264,10 @@ fn clicks_route_through_the_attack_toggle_fork() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A click takes the ring off; the repaint puts it back** — the contract decision 1021's signal
/// count depends on, pinned here so it cannot quietly rot.
///
/// `PetActionButton_OnClick`'s first line is `this:SetChecked(0)` and it runs for every click, so
/// the ring always comes off. What puts it back is `PET_BAR_UPDATE` — and the repaint that answers
/// it must re-derive `SetChecked` from `isActive` per slot, **not** diff the views, or a press that
/// changed no state would have no way home.
///
/// The reference signals that repaint from the state writes (`0x4bc940`/`0x4bc960`) and **not**
/// from a `TogglePetAutocast` it refuses (`0x4bcbf7` — a token is not autocastable, i.e. every
/// right-click on Follow, Stay or a reaction). So on a right-click the ring genuinely stays off
/// until something else repaints the bar. That is the reference's own quirk, checked there by the
/// director; decision 1027 diverged from it and 1030 put it back.
/// Every click starts with `this:SetChecked(0)` (PetActionBarFrame.lua:253); the repaint restores
/// the ring from `isActive`, never by diffing views. The reference signals it from state writes
/// (`0x4bc940`/`0x4bc960`), not from a refused `TogglePetAutocast` (`0x4bcbf7`), so a right-click
/// on a token leaves the ring off until something else repaints.
#[test]
fn a_click_drops_the_ring_and_the_repaint_restores_it() {
benilla_formats::wow_data_or_skip!();
@@ -323,8 +281,7 @@ fn a_click_drops_the_ring_and_the_repaint_restores_it() {
.unwrap()
};
// Button 1 (Attack, lit) spans x[72,102] y[56,86] ⇒ centre (87,71). Both mouse buttons take
// the ring off — the CheckButton toggles itself, then `SetChecked(0)` lands it at 0 either way.
// Attack's centre. Either button: the CheckButton toggles, then `SetChecked(0)` lands it at 0.
for button in ["RightButton", "LeftButton"] {
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
@@ -338,7 +295,6 @@ fn a_click_drops_the_ring_and_the_repaint_restores_it() {
"{button}: the ref's own SetChecked(0), reproduced"
);
// A repaint carrying the very SAME views restores it — the property the signal count buys.
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(
checked(&s),
@@ -351,9 +307,8 @@ fn a_click_drops_the_ring_and_the_repaint_restores_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A DISABLED bar still draws — every icon desaturated, nothing hidden. That pair
/// (`PetHasActionBar` true, `GetPetActionsUsable` false) is what a feared or mind-controlled pet
/// looks like, and collapsing it to "hide the bar" would lose the state entirely.
/// An unusable pet's bar (`PetHasActionBar` true, `GetPetActionsUsable` false) still draws, every
/// icon desaturated (PetActionBarFrame.lua:129-134).
#[test]
fn a_disabled_bar_greys_rather_than_hides() {
benilla_formats::wow_data_or_skip!();
@@ -371,9 +326,8 @@ fn a_disabled_bar_greys_rather_than_hides() {
2,
"the bar is still on screen"
);
// Since decision 1327 `SetDesaturation` takes its SHADER arm — the icon carries the greyscale
// flag to the renderer and keeps its own tint, rather than the reference's no-shader 0.5
// vertex-colour fallback (which is what this asserted while nothing greyed).
// `SetDesaturated` reports shader support, so `SetDesaturation` takes its shader arm
// (UIParent.lua:1500-1510): a greyscale flag on the icon, not the 0.5 vertex-colour fallback.
let grey = quads
.iter()
.find_map(|q| match &q.content {
@@ -389,10 +343,7 @@ fn a_disabled_bar_greys_rather_than_hides() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pet bar with and without the bottom-left multibar RAISED. Since 1500 that bar is a player
/// option shipping off, so the flag now raises it rather than merely loading the file — both states
/// are ones a real session lands in. `shelf` counts the two `UI-PetBar` strips; `attack_top` is the
/// top edge of Attack's icon, the bar's own row in one number.
/// The pet bar with the bottom-left multibar up or not: shelf strips drawn, top of Attack's icon.
fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -422,15 +373,8 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
(shelf, attack_top)
}
/// **The row the pet bar shares with the bottom-left multibar** (decision 0988, director-caught:
/// the bar drew straight across a live row of spells, border and all).
///
/// Two rules, both the reference's, both applied by `UIParent_ManageFramePositions`: with that bar
/// up the pet bar rises by the ref's 43 px, and its shelf art — which is a border only while the
/// bar sits directly on the main bar — is hidden rather than drawn over the buttons below.
///
/// The regression this locks is specifically that the manage pass RUNS: the bar's own XML anchor
/// is the base position, so a pass that never fired would leave it exactly where the bug was.
/// With the bottom-left bar up, `UIParent_ManageFramePositions` raises the pet bar 43 px and hides
/// its shelf art, which would draw across that row (UIParent.lua:1589,1706-1708).
#[test]
fn the_pet_bar_rises_and_sheds_its_shelf_over_the_bottom_left_bar() {
benilla_formats::wow_data_or_skip!();
@@ -450,13 +394,8 @@ fn the_pet_bar_rises_and_sheds_its_shelf_over_the_bottom_left_bar() {
);
}
/// **The drag, through the shipped XML** (decision 1010). One verb serves both ends — the button's
/// `OnDragStart` and its `OnReceiveDrag` both call `PickupPetAction` — so what makes this a move
/// rather than two pick-ups is the binding's own fork on whether the cursor is already carrying.
///
/// Also locks the grid: while a pet action rides the cursor every slot shows, including the empty
/// ones, so there is somewhere visible to drop it. That is the whole reason `PET_BAR_SHOWGRID`
/// exists, and the reason an unnamed button's `Hide()` is conditional.
/// Both drag ends call `PickupPetAction` (PetActionBarFrame.lua:269-283), a move by the binding's
/// carrying-cursor fork; meanwhile `PET_BAR_SHOWGRID` shows every slot, the empty ones included.
#[test]
fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
benilla_formats::wow_data_or_skip!();
@@ -468,7 +407,7 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
// Slot 5 is empty, so its button is hidden — until the grid comes up.
// Slot 5 is empty, so its button hides until the grid comes up.
assert!(!s.eval::<bool>("return PetActionButton5:IsShown()").unwrap());
s.run("this = PetActionButton4; PetActionButton_OnDragStart()")
@@ -507,10 +446,8 @@ fn dragging_a_pet_spell_between_slots_moves_it_through_the_shipped_handlers() {
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// The pet bar honours **Lock ActionBars** too (decision 1136) — the reference's second consumer of
/// the same uvar (`PetActionBarFrame.lua:270/278`), reading the global `ActionBar.xml` declares.
/// Both drag ends refuse while locked; the shift-click pick-up above stays live, exactly as the
/// reference leaves it (l.253-255).
/// `LOCK_ACTIONBAR` stops both drag ends (PetActionBarFrame.lua:270,278) but not the shift-click
/// pick-up (PetActionBarFrame.lua:254-255).
#[test]
fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
benilla_formats::wow_data_or_skip!();
@@ -531,7 +468,6 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
// Shift-click is still the way through, and then the drop is refused too while locked.
s.set_modifiers(true, false, false);
s.run("this = PetActionButton4; PetActionButton_OnClick(\"LeftButton\")")
.unwrap();
@@ -559,8 +495,7 @@ fn the_lock_stops_the_pet_bar_drag_but_not_its_shift_click() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A shift-click is the same pick-up, whichever mouse button carried it — the reference's own fork
/// puts shift above the left/right split, so it never toggles autocast by accident.
/// Shift is tested above the button split (PetActionBarFrame.lua:254): either button picks up.
#[test]
fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
benilla_formats::wow_data_or_skip!();
@@ -589,21 +524,9 @@ fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
);
}
/// **The keybind pair** (decision 1052) — what a bound `BONUSACTIONBUTTONn` runs. The ref's
/// binding body calls `BonusActionButtonDown/Up`, one-liners onto `PetActionButtonDown/Up`
/// (l.218-231), so the whole lane is these two functions: press shows the pushed art, release
/// fires the slot.
///
/// The load-bearing half is what the key path does NOT do: it is a bare `CastPetAction`, with none
/// of `PetActionButton_OnClick`'s forks. Pinned below on an Attack token fed as already-active — a
/// left click on that same view calls the attack off (the test above) while the key re-issues it.
///
/// **What that fixture is and is not.** `attack_active` is pushed straight onto the view here, and
/// on a real *pet* bar it can never be true: `ui_pet`'s `possessing` carve (`0x4bd420`) raises the
/// latch only for a unit you are POSSESSING, so on a hunter's bar the click fork is dead code and
/// key and click behave identically. The state below is a **possess** bar's — Mind Control, Eye of
/// Kilrogg — which is the one case where the two paths genuinely diverge, and therefore the only
/// one worth pinning. Read as "ordinary pets differ from clicks", this test would be lying.
/// A bound `BONUSACTIONBUTTONn` runs `PetActionButtonDown/Up` (PetActionBarFrame.lua:218-231), a
/// bare `CastPetAction` on release. The live Attack is a possess bar's: `0x4bd420` raises the
/// attack latch only for a possessed unit, so on a hunter's bar key and click agree.
#[test]
fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
benilla_formats::wow_data_or_skip!();
@@ -620,7 +543,6 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
.unwrap()
};
// Down shows the pushed art and fires nothing (the ref's runOnUp shape).
s.run("PetActionButtonDown(1)").unwrap();
assert_eq!(state(&s), "PUSHED");
assert!(
@@ -628,9 +550,7 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
"the press is visual only — the ref fires on the release"
);
// Up releases the art and casts — slot 1 is Attack and the view says the order is live, so a
// LEFT CLICK here would call it off instead (a possess bar's case; see the header). The key
// has no such fork.
// A left click on this live Attack would call it off; the key casts.
s.run("PetActionButtonUp(1)").unwrap();
assert_eq!(state(&s), "NORMAL");
assert_eq!(s.take_pet_actions(), vec![1]);
@@ -640,8 +560,7 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
"IsPetAttackActive lives in OnClick, which a key press never reaches"
);
// The state guard is the whole re-entrancy story: an up with nothing pushed does nothing
// (a focus-stolen release, a stuck-latch sweep), and a second down does not re-fire.
// The button-state guard: an up with nothing pushed does nothing, a second down no re-fire.
s.run("PetActionButtonUp(1)").unwrap();
assert!(
s.take_pet_actions().is_empty(),
@@ -653,8 +572,7 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
s.run("PetActionButtonUp(1)").unwrap();
assert_eq!(s.take_pet_actions(), vec![1], "one press, one cast");
// An EMPTY slot is inert through the same path (CastPetAction's own guard): slot 2 of the
// hunter bar carries no name, and its button is hidden.
// Slot 2 is empty: `CastPetAction`'s own guard makes it inert.
s.run("PetActionButtonDown(2) PetActionButtonUp(2)")
.unwrap();
assert!(
@@ -664,26 +582,14 @@ 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 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
/// way this fork gets exercised the way a player's hover exercises it.
///
/// Buttons are 30 px chained +8 from the bar's own origin: button 1 spans x[72,102] y[56,86] ⇒
/// centre (87,71), button 4's left = 72 + 3·38 ⇒ centre (201,71) — the geometry
/// `clicks_route_through_the_attack_toggle_fork` already leans on.
/// Button centres: button 1 spans x[72,102] y[56,86], and the 30 px buttons chain +8.
const ATTACK_BUTTON: (f32, f32) = (87.0, 71.0);
const CLAW_BUTTON: (f32, f32) = (201.0, 71.0);
fn hovered_pet_bar() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The real command set and the real registered defaults, the way the app's own seed installs
// them — so `GetBindingKey("BONUSACTIONBUTTON1")` answers CTRL-1 (its byte-real default) and
// `GetCVar("UberTooltips")` answers "1" (its byte-read registrar value) rather than nil.
// The app's registries: `BONUSACTIONBUTTON1` binds CTRL-1 and `UberTooltips` reads "1".
s.register_bindings(&crate::bindings::registry_commands());
s.register_cvars(crate::cvars::registered_pairs());
load_pet_bar(&s);
@@ -699,18 +605,9 @@ fn tooltip_line1(s: &UiScript) -> String {
.unwrap()
}
/// **B230 — Attack/Follow/Stay name their keybinding; the pet's own spells do not.**
///
/// On 1.12, hovering Attack, Follow or Stay shows the button's keybinding in its tooltip. That is
/// narrower than "the pet bar": `PetActionButton_OnEnter` (PetActionBarFrame.lua l.285-305) forks
/// on `isToken or UberTooltips == "0"`, and only THAT branch concatenates
/// `NORMAL_FONT_COLOR_CODE.." ("..GetBindingText(GetBindingKey("BONUSACTIONBUTTON"..id), "KEY_")..")"`.
/// The other branch is a bare `SetPetAction` and appends nothing — so a fix that suffixed every
/// slot would be wrong, and the control below is what says ours doesn't.
///
/// 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 the stock `MicroButtonTooltipText` — the same idea, the other shape — produces).
/// `PetActionButton_OnEnter` appends the binding only on its `isToken or UberTooltips == "0"`
/// branch, colour code before the space: `Attack|cffffd200 (CTRL-1)|r`. The other branch is a
/// bare `SetPetAction` (PetActionBarFrame.lua:285-305).
#[test]
fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
benilla_formats::wow_data_or_skip!();
@@ -723,14 +620,13 @@ fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
"Attack|cffffd200 (CTRL-1)|r",
"a command token carries BONUSACTIONBUTTON1's key in the normal-font colour"
);
// Uber on + a token ⇒ still the default corner; only the CVar-off leg owner-anchors.
// With `UberTooltips` on, a token's plate still takes the default anchor.
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
"a token's plate takes the default corner while UberTooltips is on"
);
// The CONTROL: slot 4 is Claw, a real pet spell, and it goes through the engine channel —
// its name, and nothing in parentheses.
// The control: Claw, a pet spell, goes through `SetPetAction`.
s.mouse_move(CLAW_BUTTON.0, CLAW_BUTTON.1);
s.resolve();
let claw = tooltip_line1(&s);
@@ -742,13 +638,8 @@ fn token_tooltips_name_their_binding_and_pet_spells_do_not() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The append is UNCONDITIONAL in that branch, so an unbound row really does render empty
/// parentheses — `GetBindingText` answers `""` for a nil key (UIParent.xml, transcribed from the
/// ref's own l.1819-1821), and nothing guards the `"("`. That is the reference's behaviour, not a
/// slip of ours: MicroButtonTooltipText guards its own suffix and this one does not.
///
/// Stock it is unreachable — BONUSACTIONBUTTON1-10 default to CTRL-1..CTRL-0 — so it takes a
/// deliberate unbind to see, which is exactly what this does.
/// The append is unguarded and `GetBindingText` answers "" for a nil key (UIParent.lua:1819-1821),
/// so an unbound token shows empty parentheses. Stock binds CTRL-1..CTRL-0, so this unbinds one.
#[test]
fn an_unbound_token_row_renders_the_references_empty_parentheses() {
benilla_formats::wow_data_or_skip!();
@@ -771,13 +662,8 @@ fn an_unbound_token_row_renders_the_references_empty_parentheses() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The CVar is the fork's OTHER half, and it is not decoration: with `UberTooltips` off, a real pet
/// SPELL stops going through the engine channel and takes the token branch too — same built text,
/// same binding suffix — and the plate moves from the screen corner to beside the button
/// (`SetOwner(this, "ANCHOR_RIGHT")`). Both are the reference's, in the same five lines.
///
/// This is also the row's reason to exist in `cvars::REGISTERED`: benilla registers `UberTooltips`
/// at all because these Lua sites read it (the honest-tree rule's live-consumer half).
/// With `UberTooltips` off a pet spell takes the token branch too, binding suffix and all, and the
/// plate anchors `ANCHOR_RIGHT` of the button (PetActionBarFrame.lua:290-296).
#[test]
fn ubertooltips_off_takes_the_spells_through_the_token_branch_and_moves_the_plate() {
benilla_formats::wow_data_or_skip!();
@@ -1,9 +1,6 @@
//! The shipped pet frame, driven end to end (decision 0990) — `UnitFrames.xml`'s
//! `PetFrame` over synthetic `"pet"` snapshots and the events the app's feed fires.
//!
//! The frame's whole job is to be right about **which unit an event names**, and that is what most
//! of these test: `UNIT_PET` names the OWNER (`arg1 == "player"`, `0x4bc84f`), every other `UNIT_*`
//! names the pet itself, and a frame that mixes the two repaints off the player's health.
//! The stock pet frame, `PetFrame.xml`, over synthetic `"pet"` snapshots and the feed's events.
//! `UNIT_PET` names the owner (`arg1 == "player"`, `0x4bc84f`) and every other `UNIT_*` names the
//! pet: a frame that mixes the two repaints off the player's health.
use benilla_ui::script::{
AuraState, QuadContent, ScriptValue, SelectionRequest, UiScript, UnitState,
@@ -11,18 +8,8 @@ use benilla_ui::script::{
use super::test_ui::load_ui as load_xml;
/// The pet frame's production load prefix — it is parented to `PlayerFrame`, so the whole
/// unit-frame file comes with it, and the tooltip/dropdown kit its hover and its neighbours need.
///
/// **A player is seated, and that is load-bearing rather than tidy.** `PetFrame` really is a CHILD
/// of `PlayerFrame` — our `UnitFrames.xml:1792` says `parent="PlayerFrame"` and the reference's
/// `PetFrame.xml:4` says the same — and `UnitFrame_Update` hides a frame whose unit does not exist.
/// So a fixture with a pet and no player gives `PlayerFrame:Hide()`, and a hidden parent means an
/// invisible pet frame. That is the reference's own structure working correctly; a pet without a
/// player is a state the game cannot be in.
///
/// (It did not matter until the loader learned the `parent=` attribute, which it had been ignoring:
/// before that `PetFrame` was a top-level frame and nothing could hide it from above.)
/// The pet frame's load chain, with a player seated: `PetFrame` is a child of `PlayerFrame`
/// (PetFrame.xml:4), and `UnitFrame_Update` hides a frame whose unit does not exist.
fn load_pet_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -61,7 +48,7 @@ fn load_pet_frame() -> UiScript {
s
}
/// A pet snapshot. `max_power == 0` is the powerless pet (a skeleton) — the leg that swaps the art.
/// A pet snapshot; `max_power == 0` is a powerless pet, which swaps the art.
fn pet(name: &str, health: u32, power: u32, max_power: u32, power_type: u8) -> UnitState {
UnitState {
exists: true,
@@ -72,22 +59,14 @@ fn pet(name: &str, health: u32, power: u32, max_power: u32, power_type: u8) -> U
power_type,
power,
max_power,
// Every token the app's feed pushes is connected (`ui_unit.rs`, "the stated
// `is_connected` gap, closed"). It matters to the reference's own bar: stock
// `UnitFrameManaBar_Update` (UnitFrame.lua:213) greys a DISCONNECTED unit's bar to
// (0.5,0.5,0.5) and only reaches `UnitFrame_UpdateManaType`'s power colour otherwise, so a
// fixture that leaves this false paints every bar grey and tests nothing about power type.
// The feed marks every unit it pushes connected; stock `UnitFrameManaBar_Update` greys a
// disconnected unit's bar (UnitFrame.lua:214-216).
is_connected: true,
..UnitState::default()
}
}
/// Every texture path the UI actually **draws** this frame, and its vertex tint.
///
/// The engine ships `SetTexture`/`SetVertexColor` and no getters (the reference's `GetTexture`
/// exists but nothing in the shipped XML reads it), so these assertions go through the render path
/// the unit-frame tests already use — which is the honest question anyway: not what the region was
/// told, but what comes out the other end.
/// Every texture path drawn this frame, with its vertex tint.
fn drawn(s: &mut UiScript) -> Vec<(String, Option<[f32; 4]>)> {
s.resolve();
s.extract()
@@ -103,14 +82,12 @@ fn drawn(s: &mut UiScript) -> Vec<(String, Option<[f32; 4]>)> {
.collect()
}
/// Is exactly this texture drawn? Exact, not `contains` — `UI-SmallTargetingFrame` is a prefix of
/// `UI-SmallTargetingFrame-NoMana`, and the whole point of the art-swap test is telling them apart.
/// Exact, not `contains`: `UI-SmallTargetingFrame` prefixes the `-NoMana` plate's path.
fn draws(s: &mut UiScript, path: &str) -> bool {
drawn(s).iter().any(|(p, _)| p == path)
}
/// One aura on the pet's row. **Helpful**, because the row the pet frame fills is a BUFF row —
/// see [`the_debuff_row_fills_from_the_pets_own_auras`] for the reference's own `showBuffs = 1`.
/// One helpful aura: the pet frame's row shows buffs (PetFrame.lua:37,56).
fn pet_buff(spell_id: u32, name: &str, count: u8) -> AuraState {
AuraState {
spell_id,
@@ -118,7 +95,7 @@ fn pet_buff(spell_id: u32, name: &str, count: u8) -> AuraState {
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
count,
debuff_type: None,
// No unit but yourself carries a duration on the 1.12 wire (decision 0257 B6).
// Only your own auras carry a duration on the 1.12 wire.
duration: 0.0,
expiration_time: 0.0,
helpful: true,
@@ -128,8 +105,7 @@ fn pet_buff(spell_id: u32, name: &str, count: u8) -> AuraState {
}
}
/// Summon → the frame appears with the pet's name, health and power; dismiss → it hides again.
/// `UNIT_PET` is the only wire either edge has, which is why it carries them both.
/// `UNIT_PET` is the one event for both the summon and the dismiss.
#[test]
fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
benilla_formats::wow_data_or_skip!();
@@ -158,16 +134,12 @@ fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
.unwrap();
assert!(ok, "the summoned pet's name, health and power all draw");
// The dismiss: the token clears and UNIT_PET fires, exactly as `feed_pet_unit` does it.
// The dismiss, as `feed_pet_unit` sends it: the token clears, then `UNIT_PET`.
s.set_unit("pet", None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s.eval::<bool>("return PetFrame:IsVisible()").unwrap());
}
/// **The two argument gates.** `UNIT_PET` names the OWNER, so `arg1 == "pet"` on it is not our
/// event; every other `UNIT_*` names the pet, so `arg1 == "player"` on those is not ours either.
/// A frame that has these backwards paints the player's health onto the pet's bar — which is why
/// this drives each event with the WRONG token and asserts nothing moved.
#[test]
fn the_frame_answers_only_the_events_that_name_its_own_unit() {
benilla_formats::wow_data_or_skip!();
@@ -176,7 +148,6 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(s.eval::<bool>("return PetFrame:IsVisible()").unwrap());
// A health change on the PLAYER must not touch the pet's bar.
s.set_unit("pet", Some(pet("Grimjaw", 5, 45, 80, 0)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert_eq!(
@@ -185,7 +156,6 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
72.0,
"a UNIT_HEALTH for \"player\" is not the pet's event"
);
// …and the same event naming the pet does.
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("pet".into())]);
assert_eq!(
s.eval::<f64>("return PetFrameHealthBar:GetValue()")
@@ -193,7 +163,6 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
5.0
);
// UNIT_PET is the mirror image: it names the owner, so a "pet" arg1 is not ours.
s.set_unit("pet", None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("pet".into())]);
assert!(
@@ -204,28 +173,10 @@ fn the_frame_answers_only_the_events_that_name_its_own_unit() {
assert!(!s.eval::<bool>("return PetFrame:IsVisible()").unwrap());
}
/// The reference's art swap (`PetFrame.lua:29-33`): a pet with no power wears the `-NoMana`
/// plate, which has no mana rail to leave empty — and the bar hides with it. A hunter's FOCUS pet
/// takes the plain plate and the focus colour, which is the case the mana-coloured default would
/// get wrong.
///
/// **Two things about the reference's wiring that our transcription did not have.**
///
/// *The plate's only wire is `UNIT_PET`.* `PetFrame_Update` — the sole caller of the
/// `UnitManaMax("pet") == 0` fork — runs from `PetFrame_OnEvent`'s `UNIT_PET`/`arg1 == "player"`
/// arm and from the frame's `OnShow` (`PetFrame.lua:46-49`, `PetFrame.xml:284-287`), and from
/// nothing else. This fixture used to poke `UNIT_MAXPOWER`, which the stock frame does not
/// register at all — nor could it, since the bar's own registrations are 1.12's
/// `UNIT_MANA`/`UNIT_MAXMANA`/… (`UnitFrame.lua:190-200`) rather than our Era pair. Swapping one
/// pet for a differently-powered one is a `UNIT_PET` on the wire, which is the honest edge here.
///
/// *Nothing in `PetFrame.lua` hides the bar.* The hide comes out of `TextStatusBar`: the bar
/// inherits that template's `<OnValueChanged>` (`TextStatusBar.xml:32-34` — `PetFrame.xml:161-169`
/// overrides only OnLoad/OnEvent), so `UnitFrameManaBar_Update`'s `SetMinMaxValues(0, 0)`
/// (`UnitFrame.lua:211-212`) reaches `TextStatusBar_UpdateTextString`, whose `valueMax > 0` else
/// branch is `textStatusBar:Hide()` (`TextStatusBar.lua:34/55-57`). Same visible answer as our
/// transcription's explicit `Hide()`, by a different and much longer road — which is worth knowing,
/// because it means the bar comes back only through a value change, never through `PetFrame.lua`.
/// A powerless pet wears the `-NoMana` plate (PetFrame.lua:29-33), recut only on `UNIT_PET` or
/// OnShow (PetFrame.lua:46-49, PetFrame.xml:284-287). Its bar hides through `TextStatusBar`:
/// `SetMinMaxValues(0, 0)` (UnitFrame.lua:211-212) reaches the inherited OnValueChanged, which
/// hides a bar whose max is 0 (TextStatusBar.lua:34,55-57), so only a value change brings it back.
#[test]
fn a_powerless_pet_wears_the_no_mana_plate() {
benilla_formats::wow_data_or_skip!();
@@ -234,7 +185,7 @@ fn a_powerless_pet_wears_the_no_mana_plate() {
const PLAIN: &str = "Interface\\TargetingFrame\\UI-SmallTargetingFrame";
const NO_MANA: &str = "Interface\\TargetingFrame\\UI-SmallTargetingFrame-NoMana";
// Focus (power type 2) — a hunter's pet: plain art, orange bar.
// Focus (power type 2), a hunter's pet: the plain plate and the focus colour.
s.set_unit("pet", Some(pet("Boar", 100, 60, 100, 2)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
let (visible, r, g, b) = s
@@ -248,8 +199,7 @@ fn a_powerless_pet_wears_the_no_mana_plate() {
assert!(draws(&mut s, PLAIN), "the plate with a mana rail");
assert!(!draws(&mut s, NO_MANA));
// A skeleton: no power at all. `UNIT_PET` names the OWNER — see the doc above for why it, and
// not a power event, is what re-cuts the plate.
// A skeleton, with no power: `UNIT_PET` recuts the plate.
s.set_unit("pet", Some(pet("Skeleton", 100, 0, 0, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
@@ -261,8 +211,8 @@ fn a_powerless_pet_wears_the_no_mana_plate() {
assert!(!draws(&mut s, PLAIN));
}
/// `PET_ATTACK_START`/`PET_ATTACK_STOP` show and hide the flashing attack overlay, and neither
/// repaints the rest of the frame (they are the ref's own early returns).
/// `PET_ATTACK_START`/`PET_ATTACK_STOP` show and hide the attack overlay and repaint nothing else
/// (PetFrame.lua:58-62).
#[test]
fn the_attack_overlay_follows_its_own_two_events() {
benilla_formats::wow_data_or_skip!();
@@ -278,9 +228,8 @@ fn the_attack_overlay_follows_its_own_two_events() {
.eval::<bool>("return PetAttackModeTexture:IsVisible()")
.unwrap());
// The ref's OnUpdate ramps the overlay's tint alpha down from 1 on the opening (sign −1) leg.
// Two ticks so the ramp has visibly moved, and the range guard catches the classic transcription
// slip: the ref's constants are 0..255 and the API takes 0..1.
// `PetFrame_OnUpdate` ramps the alpha down from 1 on the opening leg; its constants are 0-255,
// divided into the API's 0-1 (PetFrame.lua:68-87).
s.tick(0.1);
s.tick(0.1);
let tint = drawn(&mut s)
@@ -301,25 +250,9 @@ fn the_attack_overlay_follows_its_own_two_events() {
.unwrap());
}
/// The four `$parentDebuff` buttons fill from the pet's own auras in order and the unused ones
/// hide. `UNIT_AURA("pet")` is the wire (`PetFrame.lua:54-57`).
///
/// **Two things the reference does that our deleted transcription did not**, both worth stating
/// because the frames' NAMES argue for the opposite on both counts:
///
/// 1. **The row shows BUFFS, not debuffs**, despite every frame in it being called
/// `PetFrameDebuffN`. `PetFrame.lua:37`/`:56` are the only two `RefreshBuffs` calls in the whole
/// of 1.12's FrameXML that pass `showBuffs = 1`, and that argument selects
/// `UnitBuff(unit, i, SHOW_CASTABLE_BUFFS)` at `BuffFrame.lua:277`. (Every other caller —
/// `PartyMemberFrame.lua:60`/`:176`/`:259`, `TargetFrame.lua:538` — passes `0`, which is the
/// `UnitDebuff` arm at `:280`.) Our `BenillaPetFrame_UpdateDebuffs` read `UnitDebuff("pet", i)`,
/// so a hunter whose pet was buffed saw an empty row and a poisoned pet saw icons the reference
/// would not show.
/// 2. **There is no stack count.** `RefreshBuffs` sets an icon and a border and nothing else
/// (`BuffFrame.lua:287-301`), and `PartyBuffButtonTemplate` — the template all four inherit
/// (`PetFrame.xml:173`/`:188`/`:203`/`:218`) — declares only `$parentIcon` and `$parentBorder`
/// (`PartyFrameTemplates.xml:3-36`). `PetFrameDebuff2Count` is not a frame in the reference's
/// house at all; the old assertion was reading a FontString our transcription invented.
/// The row shows buffs despite its `PetFrameDebuffN` names: `RefreshBuffs(this, 1, "pet")` selects
/// `UnitBuff` (PetFrame.lua:54-57, BuffFrame.lua:277). Its `PartyBuffButtonTemplate` has an icon
/// and a border and no count (PartyFrameTemplates.xml:3-36).
#[test]
fn the_debuff_row_fills_from_the_pets_own_auras() {
benilla_formats::wow_data_or_skip!();
@@ -347,11 +280,8 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
)
.unwrap();
assert!(ok, "two auras draw, the other two rows stay down");
// …and the icons are the pet's own auras, in the pet's own order. `RefreshBuffs` paints them
// with `debuffIcon:SetTexture(debuff)` where `debuff` is `UnitBuff`'s FIRST return
// (`BuffFrame.lua:277`/`:288`) — which on the 1.12 wire is the texture path
// (`TargetFrame.lua:269-272` uses it the same way, and `:287-303` reads the debuff triple as
// `texture, applications, dispelType`).
// The icons, in order: `RefreshBuffs` sets `UnitBuff`'s first return, in 1.12 the texture
// path (BuffFrame.lua:277,288).
assert!(
draws(&mut s, "Interface\\Icons\\Spell_1000"),
"the first buff's ICON draws — if this fails with the aura's NAME on the row instead, \
@@ -359,7 +289,6 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
);
assert!(draws(&mut s, "Interface\\Icons\\Spell_1001"));
// They clear when the auras do.
s.set_auras("pet", Some(vec![]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]);
assert!(!s
@@ -367,8 +296,7 @@ fn the_debuff_row_fills_from_the_pets_own_auras() {
.unwrap());
}
/// Left-clicking the frame targets the pet — the ref's `PetFrame_OnClick` bottom leg, and the one
/// the app answers in `target::click::target_unit_requests`' `"pet"` arm.
/// A plain left-click targets the pet (PetFrame.lua:119-126).
#[test]
fn left_clicking_the_pet_frame_targets_it() {
benilla_formats::wow_data_or_skip!();
@@ -382,23 +310,14 @@ fn left_clicking_the_pet_frame_targets_it() {
vec![SelectionRequest::Unit("pet".into())]
);
// The right button is the deferred PET menu — it must do nothing at all, not target.
// The right button toggles `PetFrameDropDown` (PetFrame.lua:127-128), never a target.
s.run("PetFrame_OnClick(\"RightButton\")").unwrap();
assert!(s.take_selection_requests().is_empty());
}
/// **All three legs of `PetFrame_OnClick` survive the click** — B208's "dropping food from the bag
/// onto the pet doesn't feed" (decision 1055).
///
/// The handler transcribed the reference's three legs correctly from the day it shipped, but two of
/// the globals it calls — `DropItemOnUnit` and `SpellTargetUnit` — were never registered, so the
/// middle leg called a nil value and the whole handler errored out. Nothing fed, and nothing said
/// why. The bottom-leg test above could not catch it: it only ever exercises the `else`.
///
/// So this walks the legs the reference's own order picks between them (`SpellIsTargeting()` first,
/// then `CursorHasItem()`, then plain target — `PetFrame.lua:114-129`), and asserts each *runs*.
/// The feed itself is the app's half, gated and sent in `ui_action::drop_item`; what the VM owes is
/// a queued token and no error.
/// `PetFrame_OnClick` tries a spell target, then an item drop, then a target
/// (PetFrame.lua:114-129). Every global it calls must exist, so food dropped on the pet queues a
/// drop instead of raising.
#[test]
fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
benilla_formats::wow_data_or_skip!();
@@ -406,8 +325,6 @@ fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
// The three globals the handler reaches for must all exist. Named individually so a failure
// says which one went missing rather than just "attempt to call a nil value".
for global in [
"SpellIsTargeting",
"CursorHasItem",
@@ -421,8 +338,7 @@ fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
);
}
// The middle leg, for real: put food in the backpack, pick it up the way a player does, then
// click the pet. Before the fix this click errored instead of queueing anything.
// The middle leg: food picked up from the backpack, then a click on the pet.
s.set_container(
0,
Some(benilla_ui::script::ContainerState {
@@ -431,7 +347,7 @@ fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
slots: std::collections::HashMap::from([(
1,
benilla_ui::script::ContainerSlot {
item_id: 2287, // Haunch of Meat — pet food
item_id: 2287, // Haunch of Meat, pet food
count: 1,
..Default::default()
},
@@ -452,13 +368,11 @@ fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
vec!["pet".to_string()],
"a held item + a pet click queues the drop"
);
// …and it is the DROP leg, not the target leg — the reference's if/elseif is exclusive.
// The drop leg alone: the reference's if/elseif is exclusive.
assert!(s.take_selection_requests().is_empty());
}
/// The GlobalStrings the happiness tooltip resolves by key. Loaded from the MPQ in production; the
/// test declares the ones it reads, the pet-bar tests' convention. (That the real keys exist in the
/// shipped file is asserted separately, by name, in `ui_pet_stats`.)
/// Declares the `GlobalStrings` keys the happiness tooltip reads.
fn declare_happiness_strings(s: &UiScript) {
s.run(
"PET_HAPPINESS1 = 'Unhappy' PET_HAPPINESS2 = 'Content' PET_HAPPINESS3 = 'Happy' \
@@ -482,18 +396,12 @@ fn stats(
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
// The unit frame draws neither the family word (the paper doll's level line) nor the diet
// (its tooltip) — decision 1062. Left at their defaults on purpose: the happiness icon
// these tests drive must not start depending on them.
// Family and diet stay default: the unit frame draws neither.
..benilla_ui::script::PetStats::default()
}
}
/// The happiness icon end to end through the real XML (decision 1005): shown for a hunter pet,
/// re-cut per bucket, hidden for anything that is not one.
///
/// The tooltip key is the assertion rather than the texcoords because it is what proves **which
/// branch ran** — `PET_HAPPINESS2` can only come from the `happiness == 2` arm.
/// The tooltip is `PET_HAPPINESS<n>` (PetFrame.lua:147), so it names the bucket that painted.
#[test]
fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
benilla_formats::wow_data_or_skip!();
@@ -519,7 +427,7 @@ fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
tip,
"bucket {bucket} took the wrong texcoord branch"
);
// The loyalty line is chosen by the SIGN of the rate — and is absent at exactly zero.
// The loyalty line follows the rate's sign and is nil at zero (PetFrame.lua:149-155).
let line = s
.eval::<Option<String>>("return PetFrameHappiness.tooltipLoyalty")
.unwrap();
@@ -530,8 +438,7 @@ fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
);
}
// A warlock's imp: `HasPetUI`'s second return is nil, so the icon hides however happy the
// first return looks.
// A warlock's imp: `HasPetUI`'s second return is nil, so the icon hides (PetFrame.lua:134-137).
s.set_pet_stats(true, stats(false, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
@@ -539,9 +446,8 @@ fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
.unwrap());
}
/// **Bucket 0 keeps the icon up.** The reference hides on `not happiness`, and `0` is truthy in
/// Lua — so a client that folded bucket 0 into nil would hide a frame the reference shows. This is
/// the `GetPetHappiness 0x4be900` trap, tested where it would actually bite: in the frame.
/// The frame hides on `not happiness`, and 0 is truthy in Lua: `GetPetHappiness` (`0x4be900`)
/// answers bucket 0 as a number, not a nil.
#[test]
fn happiness_bucket_zero_keeps_the_icon_showing() {
benilla_formats::wow_data_or_skip!();
@@ -557,7 +463,7 @@ fn happiness_bucket_zero_keeps_the_icon_showing() {
"bucket 0 is a number, not the hide case"
);
// …whereas a genuine nil — the gate failure — does hide it.
// A nil does hide it.
s.set_pet_stats(true, stats(true, None, 100.0, 0.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
@@ -565,8 +471,7 @@ fn happiness_bucket_zero_keeps_the_icon_showing() {
.unwrap());
}
/// `UNIT_HAPPINESS` repaints the icon **alone** — the reference's own arm returns before the
/// frame-wide update, so a happiness tick must not be a full repaint.
/// `UNIT_HAPPINESS` repaints the icon alone (PetFrame.lua:63-64).
#[test]
fn unit_happiness_repaints_only_the_icon() {
benilla_formats::wow_data_or_skip!();
@@ -581,7 +486,6 @@ fn unit_happiness_repaints_only_the_icon() {
"Unhappy"
);
// Feed it up a bucket and fire ONLY the happiness event.
s.set_pet_stats(true, stats(true, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert_eq!(
@@ -593,21 +497,9 @@ fn unit_happiness_repaints_only_the_icon() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The layering law (decision 0884), the pet frame's copy of the player/target/party tests: the
/// frame art must draw OVER the bar fills, which only the frame LEVEL can hold — the draw layer is
/// bucket-wide. A "simplification" back to declaration order puts the bars on top as pasted slabs.
///
/// **The art frame has no name in the reference, so it is reached through its texture.** Our
/// deleted transcription gave it one (`PetFrameTextureFrame`); stock `PetFrame.xml:52-124` builds
/// it as two ANONYMOUS `<Frame setAllPoints="true">` wrappers, one inside the other, with
/// `PetFrameTexture` in the inner one's BACKGROUND layer. That nesting is how the reference buys
/// the level: `PetFrame` is 2, its direct children (both bars, `PetFrame.xml:125`/`:150`) are 3,
/// and the doubly-nested art frame is 4. The law is unchanged — the art is one level above the
/// bars — only the way to name the frame is, so this asks the texture for its parent.
///
/// It is also *why* the reference needs no `PetFrameManaBar:Hide()` of its own for the
/// [`a_powerless_pet_wears_the_no_mana_plate`] case: the `-NoMana` plate is painted a level ABOVE
/// the mana bar and simply covers the rail.
/// The art draws over the bar fills, which only frame level can hold. The art frame is anonymous,
/// nested two deep (PetFrame.xml:52-124) where the bars are direct children (PetFrame.xml:125,150),
/// so it is reached as `PetFrameTexture:GetParent()`.
#[test]
fn the_pet_art_paints_over_the_bars() {
benilla_formats::wow_data_or_skip!();
@@ -1,29 +1,14 @@
//! The shipped **pet paper doll** driven end-to-end, engine-only (decision 1057) — and since
//! decision 1751's character swap, "shipped" is the reference's own
//! `Interface\FrameXML\PetPaperDollFrame.xml`, read off the player's patch chain behind the
//! reference's `CharacterFrame.xml` and `PaperDollFrame.xml`. Our
//! `assets/ui/PetPaperDollFrame.xml` is deleted; [`super::test_ui::CHARACTER_UI`] is the load
//! list, and every test here opens with `wow_data_or_skip!()` because a chain entry needs the
//! install.
//!
//! The page's rows are the character page's own code with a different unit token, and
//! `character_tests.rs` already pins those. What is genuinely new — and what these test — is the
//! **conditional tab**: it goes up and down with the pet, the tab beside it slides to close the
//! gap, the page refuses to open without a pet, and it closes itself when the pet leaves. That
//! machinery is the half a green build cannot see.
//! The stock pet paper doll (`PetPaperDollFrame.xml`) in the stock character window, engine only.
//! Its rows are the character page's code under the `"pet"` token; these test what comes and goes
//! with the pet.
use benilla_ui::script::{
PetStats, QuadContent, ScriptValue, UiScript, UnitCombatStats, UnitState,
};
/// The page's production load prefix. [`super::test_ui::CHARACTER_UI`] is the whole character
/// block in `benilla.toc`'s order — the pet page cannot stand on its own file, because
/// `PetPaperDollFrame_Update` calls seven `PaperDollFrame_Set*` setters with a `"pet"` unit
/// token (stock `PetPaperDollFrame.lua:75-81`) and `PetTab_Update` moves a tab that
/// `CharacterFrame.xml` declares.
///
/// **Callers must open with `benilla_formats::wow_data_or_skip!()` themselves** — the macro
/// `return`s from the function it is written in, so a helper cannot hold the guard for its caller.
/// The whole character block: the page calls seven `PaperDollFrame_Set*` setters
/// (`PetPaperDollFrame.lua:75-81`) and moves a tab `CharacterFrame.xml` declares. Callers open with
/// `wow_data_or_skip!()` themselves, as the macro returns from the function it is written in.
fn load_pet_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -63,9 +48,8 @@ fn pet_unit() -> UnitState {
}
}
/// A hunter pet's stat block — `hunter_pet` is `HasPetUI`'s SECOND return, the one that gates the
/// training-point line and the diet icon. The family pair is the shipped `CreatureFamily.dbc` row
/// for a Boar (id 5, food mask 63) resolved through `ItemPetFood.dbc` (decision 1062).
/// A hunter pet: `hunter_pet` is `HasPetUI`'s second return, which gates the training points and
/// the diet icon. The family and diet are CreatureFamily.dbc's Boar (id 5, food mask 63).
fn hunter_pet_stats() -> PetStats {
PetStats {
icon: None,
@@ -83,8 +67,7 @@ fn hunter_pet_stats() -> PetStats {
}
}
/// Numbers a pet's descriptor really carries: stats and resistances, no PLAYER-block buff split
/// (a creature has none — decision 1057), so every pos/neg stays zero.
/// A pet's descriptor numbers: a creature has no buff split, so every pos/neg stays zero.
fn pet_combat_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [123, 88, 210, 20, 45],
@@ -109,8 +92,7 @@ fn texts(s: &mut UiScript) -> Vec<String> {
.collect()
}
/// A tab's resolved left edge. Only meaningful with the **window open** — a frame nothing lays out
/// has no rect at all, and `GetLeft()` answers nil rather than a stale number.
/// A tab's left edge; only with the window open, as an unlaid frame's `GetLeft()` is nil.
fn tab_left(s: &mut UiScript, tab: u32) -> f64 {
s.resolve();
s.eval::<f64>(&format!("return CharacterFrameTab{tab}:GetLeft()"))
@@ -125,7 +107,7 @@ fn give_pet(s: &mut UiScript) {
s.fire_event("PET_BAR_UPDATE", vec![]);
}
/// Take it away again — the dismiss/death path: `UNIT_PET` names the OWNER, not the pet (0990).
/// Dismiss or lose the pet: `UNIT_PET` names the owner, not the pet.
fn take_pet(s: &mut UiScript) {
s.set_unit("pet", None);
s.set_pet_stats(false, PetStats::default());
@@ -133,14 +115,7 @@ fn take_pet(s: &mut UiScript) {
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
}
/// The loader itself, plus the page's own frame count.
///
/// **A count is a fingerprint of a file, not a target** (decision 1800's closing note): this one
/// is the reference's `PetPaperDollFrame.xml` — the page, the five stat rows and five resistance
/// frames, the XP bar, the model pane with its two rotate buttons, the diet icon and the close
/// button — and it moves only when the player's own file does. It is new with decision 1751's
/// character swap: this test asserted no count while the file it loaded was ours, because a count
/// of our own transcription fingerprints nothing but the transcription.
/// The frame count fingerprints the stock `PetPaperDollFrame.xml`; it moves only with that file.
#[test]
fn shipped_pet_page_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -154,29 +129,18 @@ fn shipped_pet_page_loads_clean() {
}
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
assert_eq!(pet_frames, 34, "the reference's own PetPaperDollFrame.xml");
// The page itself exists and starts down, like every other subframe.
assert!(!s
.eval::<bool>("return PetPaperDollFrame:IsVisible()")
.unwrap());
}
/// **The conditional tab, both ways.** With no pet the Pet tab is down and the tab beside it slides
/// onto its spot; with a pet the Pet tab comes up *at that very spot* and its neighbour moves out
/// past it. Pinning "tab-3-when-closed sits exactly where tab 2 stands when open" is what proves
/// the re-anchor really ran — a tab merely hidden, with the row left as-authored, would leave a gap
/// and pass a weaker check. `PetTab_Update` is the whole mechanism, and it is four lines
/// (stock `PetPaperDollFrame.lua:189-198`).
///
/// **The name was stale, not the test.** It said "Skills closes the gap" from when Skills WAS tab
/// 3; the Reputation page moved it to 4 long before this file went to the chain, and the tab
/// `PetTab_Update` re-anchors is and always was `CharacterFrameTab3` — REPUTATION in the
/// reference's row (`CharacterFrame.xml:115`) and in our deleted copy alike. Renamed so it says
/// what it checks; the assertions are untouched.
/// `PetTab_Update` (`PetPaperDollFrame.lua:189-198`) hides the Pet tab and re-anchors tab 3 onto
/// its spot; the exact position proves the re-anchor ran, where a bare hide would leave a gap.
#[test]
fn the_pet_tab_rises_and_falls_with_the_pet_and_the_next_tab_closes_the_gap() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = load_pet_page();
// The rects only exist while something lays the window out.
// Open, so the tabs have rects.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(
@@ -213,9 +177,7 @@ fn the_pet_tab_rises_and_falls_with_the_pet_and_the_next_tab_closes_the_gap() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The reference's `ToggleCharacter` guard (stock `CharacterFrame.lua:4-6`): asking for the pet page
/// with no pet does **nothing at all** — it does not open the window on some other tab, and it does
/// not close an already-open one.
/// Stock `ToggleCharacter`'s guard (`CharacterFrame.lua:4-6`): no open, no close, no page switch.
#[test]
fn asking_for_the_pet_page_without_a_pet_does_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -226,7 +188,6 @@ fn asking_for_the_pet_page_without_a_pet_does_nothing() {
"no pet: the window stays shut"
);
// …and with the window already open on the character page, the refusal leaves it open.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.run(r#"ToggleCharacter("PetPaperDollFrame")"#).unwrap();
assert!(
@@ -236,9 +197,7 @@ fn asking_for_the_pet_page_without_a_pet_does_nothing() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The page paints the **pet's** numbers, not the player's — the whole point of un-gating the stat
/// bindings. No player COMBAT-STATS snapshot is pushed here (only the unit one the window needs to
/// open), so anything that leaked through the `"player"` path would read zeros and this would fail.
/// No player combat stats are pushed, so a number read through `"player"` would be zero.
#[test]
fn the_page_reads_the_pets_own_snapshot() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -256,8 +215,7 @@ fn the_page_reads_the_pets_own_snapshot() {
"expected {want:?} on the page; drew {drawn:?}"
);
}
// Unspent training points = total - spent (stock `PetPaperDollFrame.lua:85-86`), not either
// number by itself.
// Unspent training points, total minus spent (`PetPaperDollFrame.lua:85-86`).
assert!(
drawn.iter().any(|t| t == "40"),
"unspent training points (170-130); drew {drawn:?}"
@@ -265,10 +223,8 @@ fn the_page_reads_the_pets_own_snapshot() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The window has ONE name line and the two pages take turns holding it (stock
/// `PetPaperDollFrame.lua:50-60`) — the pet's
/// name replaces the player's on show and hands it back on hide. A page that forgot the hand-back
/// would leave the character sheet nameless.
/// The pages share the window's name line: the pet's on show, the player's back on hide
/// (`PetPaperDollFrame.lua:50-60`).
#[test]
fn the_page_borrows_the_windows_name_line_and_gives_it_back() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -291,10 +247,8 @@ fn the_page_borrows_the_windows_name_line_and_gives_it_back() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A pet dismissed or killed while its own page is open **closes the window** (stock
/// `PetPaperDollFrame.lua:32-37`). This
/// is the one arm that must run while the page is visible and the pet is already gone, so it is
/// also the arm that would blow up on a page that repainted before checking.
/// A pet lost under its open page closes the window before any repaint
/// (`PetPaperDollFrame.lua:32-37`).
#[test]
fn the_pet_leaving_closes_the_page_under_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -311,9 +265,8 @@ fn the_pet_leaving_closes_the_page_under_it() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A warlock's minion: `HasPetUI` says yes, its second return says no. The page opens in full and
/// the three hunter-only pieces stay down (stock `PetPaperDollFrame.lua:83-93`; 1005's class gate,
/// seen from the page's side).
/// A warlock's minion passes `HasPetUI` but not its second return, so the three hunter-only pieces
/// stay down (`PetPaperDollFrame.lua:83-93`).
#[test]
fn a_minion_gets_the_page_without_the_hunter_furniture() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -338,7 +291,6 @@ fn a_minion_gets_the_page_without_the_hunter_furniture() {
assert!(!s
.eval::<bool>("return PetTrainingPointLabel:IsVisible()")
.unwrap());
// …and the stat rows still read the minion's own numbers.
assert!(
texts(&mut s).iter().any(|t| t.contains("210")),
"the shared rows are not hunter-gated"
@@ -346,23 +298,9 @@ fn a_minion_gets_the_page_without_the_hunter_furniture() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **The level line, both ways** (decision 1062). The reference guards its whole `SetText` on
/// `UnitCreatureFamily("pet")` (stock `PetPaperDollFrame.lua:68-70`), so the family is not
/// decoration on an existing line — it is the *condition* for the line existing at all. With a
/// family the row reads "Level 58 Imp"; without one nothing about the pet reaches the line, which
/// is the state 1057 shipped in and the hole this closes.
///
/// The minion is deliberate: a warlock's Imp fails `HasPetUI`'s second return, so this also pins
/// that the family word rides OUTSIDE 1005's hunter gate. Gating it would blank this line for every
/// warlock in the game.
///
/// **The two halves are two separate page loads on purpose.** The guard skips the `SetText`;
/// it does not clear the FontString — so pushing a family and then taking it away leaves the old
/// word on screen (this test found that by asserting the opposite first). That is the reference's
/// own shape and it is unreachable in play: a family is a property of the pet's *template*, it
/// never goes away under a live pet, and the page closes with the pet anyway. The case that IS
/// reachable is "the page painted before the creature query answered", which is a page that never
/// had a family — the second half here.
/// The level line is written only when `UnitCreatureFamily("pet")` answers
/// (`PetPaperDollFrame.lua:68-70`), outside the hunter gate, so a minion gets it. The guard skips
/// the `SetText` without clearing the line, so each half loads its own page.
#[test]
fn the_level_line_names_the_family_and_is_untouched_without_one() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -374,7 +312,7 @@ fn the_level_line_names_the_family_and_is_untouched_without_one() {
true,
PetStats {
family,
..PetStats::default() // hunter_pet false — a warlock's minion
..PetStats::default() // hunter_pet false: a warlock's minion
},
);
s.fire_event("PET_BAR_UPDATE", vec![]);
@@ -390,19 +328,10 @@ fn the_level_line_names_the_family_and_is_untouched_without_one() {
"UNIT_LEVEL_TEMPLATE + the family word; drew {drawn:?}"
);
// No family — the creature query has not answered, or the template has none. The guard skips
// the whole `SetText`, so the FontString keeps whatever it already held: nothing derived from
// the pet is written, which is why nil (never "") is the binding's contract.
//
// **Retired divergence (decision 1751's character swap).** This used to read "no family ⇒ no
// 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. **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.
// No family, so the binding answers nil and the line keeps its XML placeholder,
// `text="Level level race class"` (`PetPaperDollFrame.xml:70`). The 1.12 client draws that
// too: a FontString's LoadXML falls back to the raw attribute when it is not a string key
// (`0x771029`), as `Button::LoadXML` does at `0x778c31`.
let drawn = with_family(None);
assert!(
drawn.iter().any(|t| t == "Level level race class"),
@@ -414,13 +343,8 @@ fn the_level_line_names_the_family_and_is_untouched_without_one() {
);
}
/// **The diet tooltip** (decision 1062): the happiness-art icon under the rotate buttons is the
/// pet's DIET affordance, and its hover runs the reference's own
/// `format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))` (stock
/// `PetPaperDollFrame.xml:267-270`) — the vararg binding feeding the `UIParent.lua` joiner.
///
/// Driven through the pointer, not by re-typing the expression, so a guard left in the XML or a
/// frame that has stopped taking the mouse fails here.
/// The diet icon's hover shows `format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))`
/// (`PetPaperDollFrame.xml:267-270`), driven through the pointer so the hit test is covered too.
#[test]
fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -433,10 +357,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
"the diet icon is shown for a hunter pet"
);
// …and it is actually PAINTED, not merely a mouse-enabled hole. The director's 2026-08-06
// report was exactly that split — the tooltip answered while nothing was on screen — so the
// hover assertion below is not sufficient on its own: pin the art too, with its size and its
// reference TexCoords (the icon is the pet's DIET affordance, so its coords are static).
// The icon must also be painted: a hover alone passes over a mouse-enabled hole.
s.resolve();
let quads = s.extract();
let icon = quads
@@ -453,20 +374,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
rect.right - rect.left,
rect.top - rect.bottom
);
// …and it draws ON TOP of the model pane it sits inside — the actual regression. The pane is
// opaque and the icon's rect is wholly within it, so being in the render list is not the same
// as being seen. The pane rides BACKGROUND for exactly this reason (decision 1070): at ARTWORK
// the draw layer, which is bucket-wide and outranks the frame (0884), buried the icon no
// matter which frame was declared later.
//
// The pane is found by NAME rather than by "a texture quad with an empty path", which is what
// this used to look for. Our deleted `assets/ui/PetPaperDollFrame.xml` declared `PetModelFrame`
// as a plain `<Frame>` carrying one opaque BACKGROUND booth texture standing in for a widget
// this engine did not draw; the reference declares it `<PlayerModel>` (stock
// `PetPaperDollFrame.xml:177`), which is a real widget here now and extracts as
// `QuadContent::ModelPane`. The old needle matches nothing against it — this asserts the
// containment itself rather than folding it into a `find`, so a pane that stopped covering the
// icon fails loudly instead of dropping out of the search.
// The icon sits inside the pet's `<PlayerModel>` pane (`PetPaperDollFrame.xml:177`).
let pane = quads
.iter()
.find(|q| {
@@ -481,13 +389,10 @@ 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 (`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.
// The pane's scene draws after the icon, as in the reference: the two share a (strata, level)
// bucket, the icon is BACKGROUND art, and a model drains from the ARTWORK batch last
// (`0x76d160` registers its callback for layer 2 only; `0x76fb00` drains quads, then text,
// then callbacks). The scene is transparent wherever the pet is not, so the icon shows.
assert!(
pane.z > icon.z,
"the pane's scene draws out of the ARTWORK batch, after a sibling's BACKGROUND art \
@@ -497,11 +402,8 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
);
let centre = super::test_ui::centre_of(&mut s, "PetPaperDollPetInfo");
// Through the MOUSE, not `GetScript("OnEnter")(f)`: the reference's handler is inline XML that
// opens `GameTooltip:SetOwner(this, "ANCHOR_RIGHT")` (stock `PetPaperDollFrame.xml:268`), and
// only the engine sets `this` — calling the script by hand passes nil and raises. Driving the
// pointer also puts the frame's `enableMouse` and the hit test under test, which is the half
// that was never covered.
// Through the mouse: the handler's `SetOwner(this, ...)` (`PetPaperDollFrame.xml:268`) needs
// the engine to set `this`.
super::test_ui::hover(&mut s, "PetPaperDollPetInfo");
assert_eq!(
s.hit_test_name(centre.0, centre.1).as_deref(),
@@ -517,10 +419,7 @@ fn hovering_the_diet_icon_lists_what_the_pet_eats() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// `BuildListString`'s own edges, on the shipped `UIParent.xml` (decision 1062). The reference's is
/// a bare comma join with **no** "and" and **no** localization string, and it answers **nil** for
/// zero arguments — the shape `format("%s", …)` would error on, which is exactly why the diet icon
/// is hunter-gated. Quietly returning `""` here would hide that coupling.
/// Stock `BuildListString` (`UIParent.lua:1051-1057`) joins with ", " and answers nil for nothing.
#[test]
fn build_list_string_is_a_plain_comma_join_that_nils_on_nothing() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1,28 +1,13 @@
//! 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).
//! The stock stable window (`PetStable.xml`) repaints while closed on every `UNIT_PET`
//! (`PetStable.lua:20`) and concatenates `UnitName("pet")` (`PetStable.lua:129`); until the pet
//! name query answers, `UnitName` reads `UNKNOWNOBJECT`, as in the reference.
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.
/// The window and what its paint calls. Callers open with `wow_data_or_skip!()` themselves: every
/// entry comes off the chain, and the macro returns from the function it is written in.
const STABLE_UI: &[&str] = &[
r"Interface\FrameXML\GlobalStrings.lua",
r"Interface\FrameXML\Fonts.xml",
@@ -61,9 +46,7 @@ fn load_stable() -> UiScript {
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.
/// The pet as `ui_pet::unit` pushes it on the summon, nameless until the name query answers.
fn pet(name: Option<&str>) -> UnitState {
UnitState {
exists: true,
@@ -77,9 +60,8 @@ fn pet(name: Option<&str>) -> UnitState {
}
}
/// 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.
/// Icon and family come from `CreatureFamily.dbc` on the same tick, so `GetPetIcon()`, the paint's
/// is-there-a-pet test (`PetStable.lua:161`), answers before the name does.
fn hunter_pet_stats() -> PetStats {
PetStats {
icon: Some(r"Interface\Icons\Ability_Hunter_Pet_Boar".into()),
@@ -90,28 +72,20 @@ fn hunter_pet_stats() -> PetStats {
}
}
/// `Option`, not `String`: an emptied line reads back **nil**, because `FontString:GetText
/// 0x79d690` substitutes nil for an empty string (decision 2110).
/// An emptied line reads back nil: `FontString:GetText` (`0x79d690`) returns nil for "".
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.
/// Call Pet with the stable closed: the paint runs before the name arrives, and a nil name would
/// raise "attempt to concatenate a nil value" at `PetStable.lua:129`.
#[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.
// No stable visited: `GetStablePetInfo` misses 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())]);
@@ -138,8 +112,7 @@ fn call_pet_repaints_the_closed_stable_with_unknownobject_while_the_name_is_in_f
"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.)
// The name lands; the window re-reads it on its next repaint, not on `UNIT_NAME_UPDATE`.
s.set_unit("pet", Some(pet(Some("Snarl"))));
s.fire_event("PET_STABLE_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -151,8 +124,7 @@ fn call_pet_repaints_the_closed_stable_with_unknownobject_while_the_name_is_in_f
);
}
/// 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.
/// With no pet and no stable row 0, the paint takes its empty arm (`PetStable.lua:151`).
#[test]
fn dismissing_the_pet_empties_the_closed_stable_without_raising() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1,35 +1,13 @@
//! The two guild-charter windows (decision 1672): the guild registrar's two panels, and the
//! charter itself with its two faces.
//!
//! What these guard that the Rust-side unit tests structurally cannot: the windows are Lua over an
//! engine snapshot, so a `GetPetitionInfo` destructured in the wrong order, a name row wired to the
//! wrong index, the leader/signer swap tested the wrong way round, or a button pointed at the wrong
//! verb are all invisible to `script::petition`'s own tests and green in the parse sweep. Each test
//! below fails on exactly one of those.
//!
//! **The engine API is STOOD IN FOR here, deliberately** — `guild_tests`' convention, and for its
//! reasons: what is under test is the window and nothing else, the tests need no app feed seated,
//! and a change to the engine's plumbing cannot quietly turn one green. Two fixture shapes are the
//! ones `script::petition` promises and are easy to get wrong:
//!
//! - **era booleans are `1`/`nil`, never `true`/`false`** — `isOriginator` and `CanSignPetition`;
//! - **`GetPetitionInfo` returns SIX values in a fixed order**
//! (`petitionType, title, bodyText, maxSignatures, originatorName, isOriginator`), and the
//! fourth is the wire's *requirement*, not `MAX_PETITION_SIGNATURES`.
//! The stock guild registrar and charter windows over a Lua stand-in for the engine's petition API,
//! so only the windows are under test. The stand-in keeps the API's shapes: booleans are `1`/`nil`,
//! and `GetPetitionInfo`'s fourth return is the wire's signature requirement.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui_strict as load_xml;
/// The charter engine API, stood in for in Lua.
///
/// One mutable table, `BenillaPetitionFixture`, is the whole model; every verb appends to `.calls`,
/// which a test drains with `BenillaPetitionCalls()`. Tests mutate the table's FIELDS — never
/// replace the table, since the closures below hold it as an upvalue.
///
/// Seeded as a **signer's** view of a charter with two of nine signatures: the case where every
/// distinction this window makes is live at once (the Sign face showing, rows both filled and
/// empty, Request still enabled).
/// The petition API in Lua, seeded as a signer's view with two signatures. Tests mutate
/// `BenillaPetitionFixture`'s fields, never the table, which the closures hold as an upvalue.
const PETITION_FIXTURE: &str = r#"
BenillaPetitionFixture = {
petitionType = "charter",
@@ -76,19 +54,13 @@ function BuyGuildCharter(name) record("BuyGuildCharter:" .. name) end
function RenamePetition(name) record("RenamePetition:" .. name) end
"#;
/// The windows' manifest slice, in `benilla.toc` order, with the fixture seated first.
///
/// `MoneyFrame.xml` is load-bearing rather than incidental: `GuildRegistrarMoneyFrame` declares
/// `inherits="MoneyFrameTemplate"`, which resolves at LOAD (decision 1580), so without it the
/// registrar's price row would be a bare frame and the unknown-template guard above would fire.
/// The windows' manifest slice in `benilla.toc` order, the fixture first; `MoneyFrame.xml` comes
/// before the registrar, whose price row inherits `MoneyFrameTemplate` at load.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(PETITION_FIXTURE).unwrap();
// The player's own strings: the reference's `PetitionFrame_Update` formats
// GUILD_CHARTER_TEMPLATE and reads GUILD_PETITION_*_INSTRUCTIONS and NOT_YET_SIGNED straight
// out of GlobalStrings, with no fallback of its own — `format(nil, …)` raises, and the window
// never paints.
// Stock `PetitionFrame_Update` formats `GlobalStrings` entries with no fallback.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -99,20 +71,15 @@ fn setup() -> UiScript {
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.
// `QuestFrame.xml` includes `QuestTitleButtonTemplate`, which the registrar's rows inherit.
load_xml(&s, "ScrollTemplates.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, "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
// `ChatFrameEditBox`, which the stock purchase button focuses unguarded
// (`GuildRegistrarFrame.xml:271`).
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, for dropdowns
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // ChatFrame's dropdowns inherit it
load_xml(&s, "Interface\\FrameXML\\UIMenu.xml"); // the kit the chat menus build from
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
@@ -137,7 +104,6 @@ fn calls(s: &UiScript) -> String {
s.eval::<String>("return BenillaPetitionCalls()").unwrap()
}
/// Open the charter window the way the engine does.
fn show_petition(s: &mut UiScript) {
s.fire_event("PETITION_SHOW", vec![]);
assert!(s.errors().is_empty(), "PETITION_SHOW: {:?}", s.errors());
@@ -152,12 +118,7 @@ fn show_registrar(s: &mut UiScript) {
);
}
/// A signer's charter paints the header, the signed rows, the unsigned rows, and the member
/// instructions — and shows Sign, not Request/Rename.
///
/// The row assertion is the load-bearing half: rows 1..2 carry the two signers and rows 3..9 must
/// read `<not yet signed>`. A `GetPetitionNameInfo` bound 0-based would put "Kaplan" in row 1 and
/// blank row 2 while every other assertion here still passed.
/// Rows 1-2 carry the signers and 3-9 read `<not yet signed>`: `GetPetitionNameInfo` is 1-based.
#[test]
fn a_signers_charter_shows_the_sign_face_and_nine_rows() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -172,7 +133,7 @@ fn a_signers_charter_shows_the_sign_face_and_nine_rows() {
);
assert_eq!(text(&s, "PetitionFrameCharterName"), "Legacy of Steel");
assert_eq!(text(&s, "PetitionFrameMasterName"), "Tigole");
// The three static labels come from GlobalStrings via `text=` and are never painted.
// The three static labels come from `text=` attributes; no script paints them.
assert_eq!(text(&s, "PetitionFrameCharterTitle"), "Guild Name");
assert_eq!(text(&s, "PetitionFrameMasterTitle"), "Guild Master");
assert_eq!(text(&s, "PetitionFrameMemberTitle"), "Members");
@@ -187,8 +148,7 @@ fn a_signers_charter_shows_the_sign_face_and_nine_rows() {
);
}
// The charter's own static furniture — the three labels, the nine rows and the instructions
// all draw, not merely hold text.
// Every part draws, not merely holds text.
for part in [
"PetitionFrameCharterTitle",
"PetitionFrameCharterName",
@@ -215,12 +175,7 @@ fn a_signers_charter_shows_the_sign_face_and_nine_rows() {
);
}
/// The owner's charter is the same window inside out: Request + Rename replace Sign, and the
/// instructions change.
///
/// Both halves are asserted because the two buttons share one anchor — a Show/Hide written the
/// wrong way round stacks them and the top one wins, which looks correct from a screenshot of
/// either view alone.
/// Request and Rename replace Sign; the buttons share one anchor, so both halves are asserted.
#[test]
fn the_owners_charter_swaps_sign_for_request_and_rename() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -238,12 +193,8 @@ fn the_owners_charter_swaps_sign_for_request_and_rename() {
);
}
/// Request Signature disables against the **wire's** requirement, never against the nine rows.
///
/// The fixture requires 4. At three signatures the button is live; at four it is not — and nine
/// rows are still painted either way. A window that compared against `MAX_PETITION_SIGNATURES`
/// would leave Request enabled on a full charter, and every other assertion in this file would
/// still pass.
/// Request disables at the wire's requirement, 4 here, not at the nine rows
/// (`PetitionFrame.lua:38`).
#[test]
fn request_signature_disables_at_the_wires_requirement_not_at_nine() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -277,8 +228,7 @@ fn request_signature_disables_at_the_wires_requirement_not_at_nine() {
);
}
/// `CanSignPetition` gates the Sign button independently of which face is showing: a charter can be
/// a signer's view and still be unsignable (already signed, already guilded, full).
/// A signer's view can still be unsignable: already signed, already guilded, or full.
#[test]
fn the_sign_button_follows_can_sign_petition() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -301,10 +251,8 @@ fn the_sign_button_follows_can_sign_petition() {
);
}
/// Each button reaches its own verb, and closing the window closes the engine's session.
///
/// The `ClosePetition` half is the one worth pinning: it rides `OnHide`, so it fires however the
/// window closes, and without it the engine keeps a charter session for a window nobody can see.
/// `ClosePetition` rides `OnHide`, so it fires however the window closes
/// (`PetitionFrame.xml:391-394`).
#[test]
fn the_charter_buttons_reach_their_verbs_and_the_close_clears_the_session() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -326,8 +274,7 @@ fn the_charter_buttons_reach_their_verbs_and_the_close_clears_the_session() {
assert_eq!(calls(&s), "ClosePetition", "OnHide clears the session");
}
/// The rename dialog is registered, takes the box's text, and is capped at the server's own
/// 24-character charter-name limit.
/// Stock `RENAME_GUILD` caps the name at 24 (`StaticPopup.lua:138`), the server's limit too.
#[test]
fn rename_guild_sends_the_box_text_and_caps_at_twenty_four() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -358,9 +305,7 @@ fn rename_guild_sends_the_box_text_and_caps_at_twenty_four() {
assert_eq!(calls(&s), "RenamePetition:Second Legacy");
}
/// The registrar opens on its services list, and Purchase swaps panels **locally** — no verb, no
/// packet. Getting that wrong would send a buy on the first click of "Purchase a Guild Charter",
/// before the player has typed a name.
/// Purchase only swaps panels; the buy waits for a typed name.
#[test]
fn the_registrar_opens_on_services_and_purchase_is_a_local_panel_swap() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -370,13 +315,7 @@ fn the_registrar_opens_on_services_and_purchase_is_a_local_panel_swap() {
assert!(visible(&s, "GuildRegistrarFrame"));
assert!(visible(&s, "GuildRegistrarGreetingFrame"));
assert!(!visible(&s, "GuildRegistrarPurchaseFrame"));
// **The two service rows must be VISIBLE, not merely labelled.** This assertion is here
// because its absence shipped the bug: the rows inherited `hidden="true"` from a template
// modelled on the quest list's POOLED rows, which are shown one at a time by code. These are
// static and nothing ever calls `:Show()` on them, so both were permanently invisible — the
// window came up as a bare "Available Services" heading over blank parchment — while every
// `GetText()` assertion below passed, because a hidden button still knows its own label.
// Decision 0672's lesson in another key: a frame that loads is not a frame that draws.
// Visible, not merely labelled: a hidden button still answers `GetText()`.
for row in ["GuildRegistrarButton1", "GuildRegistrarButton2"] {
assert!(visible(&s, row), "{row} is on screen, not just loaded");
}
@@ -393,8 +332,6 @@ fn the_registrar_opens_on_services_and_purchase_is_a_local_panel_swap() {
s.run("GuildRegistrarButton1:Click()").unwrap();
assert!(visible(&s, "GuildRegistrarPurchaseFrame"));
assert!(!visible(&s, "GuildRegistrarGreetingFrame"));
// The purchase panel's own furniture, for the reason the services rows above are checked: a
// window whose parts load but do not draw passes every text assertion.
for part in [
"GuildRegistrarPurchaseText",
"GuildRegistrarCostLabel",
@@ -408,11 +345,7 @@ fn the_registrar_opens_on_services_and_purchase_is_a_local_panel_swap() {
assert_eq!(calls(&s), "", "the swap sends nothing");
}
/// Buying sends the box's text and closes the window; registering sends the no-argument turn-in.
///
/// The close-after-buy is the reference's own behaviour and is asserted because it looks like a bug
/// otherwise: a buy is one-shot, and its answer is an item arriving rather than anything this
/// window will be told.
/// The stock purchase button closes the window after the buy (`GuildRegistrarFrame.xml:269-270`).
#[test]
fn purchase_sends_the_typed_name_and_register_turns_the_charter_in() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -445,10 +378,8 @@ fn purchase_sends_the_typed_name_and_register_turns_the_charter_in() {
);
}
/// The price row is filled at panel-swap time from `GetGuildCharterCost()`, in copper.
///
/// Reading it at *show* time instead would paint a 0: the cost belongs to the open registrar's
/// charter list, and the greeting panel never displays one.
/// Stock `GuildRegistrar_ShowPurchaseFrame` reads `GetGuildCharterCost()`, in copper, at the swap
/// (`GuildRegistrarFrame.lua:8-11`).
#[test]
fn the_charter_price_is_read_when_the_purchase_panel_opens() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -460,8 +391,8 @@ fn the_charter_price_is_read_when_the_purchase_panel_opens() {
assert_eq!(text(&s, "GuildRegistrarCostLabel"), "Cost:");
}
/// Re-opening the registrar always lands on the services list, never on a half-filled purchase
/// panel left over from last time.
/// Stock `GuildRegistrar_OnShow` shows the greeting and hides the purchase panel
/// (`GuildRegistrarFrame.lua:1-3`).
#[test]
fn reopening_the_registrar_returns_to_the_services_list() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -477,8 +408,8 @@ fn reopening_the_registrar_returns_to_the_services_list() {
assert!(!visible(&s, "GuildRegistrarPurchaseFrame"));
}
/// Both windows are registered UIPanels — without a row, `ShowUIPanel` degrades to a bare `Show()`
/// in no slot and two left-slot windows paint over each other (B288's shape, decision 1507).
/// Without a `UIPanelWindows` row, `ShowUIPanel` is a bare `Show()` and the windows overlap
/// (`UIParent.lua:33-34`).
#[test]
fn both_charter_windows_are_registered_left_slot_panels() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -491,7 +422,7 @@ fn both_charter_windows_are_registered_left_slot_panels() {
"{frame} must hold a panel row"
);
}
// And they are rivals, not neighbours: opening one seats it where the other was.
// Both left with `pushable = 0`: one replaces the other.
show_registrar(&mut s);
show_petition(&mut s);
assert!(visible(&s, "PetitionFrame"));
@@ -1,10 +1,5 @@
//! The escort-quest confirm (decision 1733, `QuestShareFrame.xml`): the Lua wiring between the
//! `QUEST_ACCEPT_CONFIRM` event `ui_quest_share`'s feed fires and the shared StaticPopup engine,
//! driven exactly as that feed drives it.
//!
//! The asymmetry these tests pin is the reference's: **Yes sends, No does not**. There is no
//! decline packet for an escort confirm, so a test that only checked "the popup closes" would pass
//! against a version that silently answered No on the wire.
//! The stock escort-quest confirm, `QUEST_ACCEPT`, raised by the `QUEST_ACCEPT_CONFIRM` that
//! `ui_quest_share`'s feed fires. Yes sends; there is no decline packet.
use benilla_ui::script::{ScriptValue, UiScript};
@@ -36,10 +31,7 @@ fn confirm(s: &mut UiScript, who: &str, quest: &str) {
);
}
/// The event raises the popup with BOTH args filled, in the reference's order: the player first,
/// the quest title second (`QUEST_ACCEPT = "%s is starting %s\nWould you like to as well?"`). A
/// swap would read "Escort Duty is starting Thrall", which is exactly the kind of wrong that looks
/// right at a glance.
/// `QUEST_ACCEPT` takes the player's name, then the quest title (`GlobalStrings.lua:3227`).
#[test]
fn the_confirm_names_the_player_first_and_the_quest_second() {
benilla_formats::wow_data_or_skip!();
@@ -57,7 +49,6 @@ fn the_confirm_names_the_player_first_and_the_quest_second() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Yes queues exactly one `ConfirmAcceptQuest()` and closes the popup.
#[test]
fn yes_answers_once() {
benilla_formats::wow_data_or_skip!();
@@ -72,9 +63,8 @@ fn yes_answers_once() {
);
}
/// **No sends nothing.** The reference's `QUEST_ACCEPT` entry has an `OnAccept` and no `OnCancel`
/// at all (`StaticPopup.lua:727-737`), because there is no decline packet — the server's pending
/// latch is cleared by the next thing that touches it. Same for ESC.
/// The entry has no OnCancel (`StaticPopup.lua:727`): the server's pending latch clears on the
/// next thing that touches it.
#[test]
fn no_and_escape_send_nothing() {
benilla_formats::wow_data_or_skip!();
@@ -90,9 +80,8 @@ fn no_and_escape_send_nothing() {
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
}
/// A second confirm replaces the first rather than stacking — the server keeps ONE share latch per
/// player, so an older question is already dead by the time a newer one arrives, and answering the
/// stale popup would send the wrong quest id.
/// The entry is `exclusive` (`StaticPopup.lua:735`), matching the server's one share latch per
/// player: a newer question replaces the older.
#[test]
fn a_second_confirm_replaces_the_first() {
benilla_formats::wow_data_or_skip!();
+45 -105
View File
@@ -1,18 +1,11 @@
//! The shipped **questgiver window** open/close sound, driven engine-only (no Bevy): the real
//! `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.
//! The stock questgiver window (`QuestFrame.xml`), driven engine-only through the quest events.
use benilla_ui::script::{QuestPanel, QuestState, ScriptValue, SoundRequest, UiScript};
use super::test_ui::load_ui as load_xml;
/// The questgiver window's open/close kits — the window-sound convention (decision 0090). The real
/// QuestFrame.lua plays igQuestListOpen in QuestFrame_OnShow (l.285) and igQuestListClose in
/// QuestFrame_OnHide (l.294), wired via the frame OnShow/OnHide (QuestFrame.xml l.1012/1015). A
/// questgiver event (QUEST_DETAIL here) → ShowUIPanel → Show() fires OnShow; QUEST_FINISHED →
/// HideUIPanel → Hide() fires OnHide. Nothing queues at load (the frame is authored hidden="true").
/// Same pair as gossip — questgiver and gossip are the same "list" surface to the client.
/// Stock `QuestFrame_OnShow` and `QuestFrame_OnHide` play `igQuestListOpen` and `igQuestListClose`
/// (`QuestFrame.lua:285`, `:294`).
#[test]
fn questgiver_show_hide_plays_open_and_close_kits() {
benilla_formats::wow_data_or_skip!();
@@ -29,9 +22,7 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -39,14 +30,11 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
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!(
s.take_sounds().is_empty(),
"no sound at load (never transitions)"
);
// A quest-details panel arrives (SMSG_QUESTGIVER_QUEST_DETAILS → QUEST_DETAIL) and shows the
// window through ShowUIPanel.
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
title: "A Threat Within".into(),
@@ -66,7 +54,6 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
"opening the questgiver window plays igQuestListOpen"
);
// QUEST_FINISHED hides it through HideUIPanel → OnHide.
s.fire_event("QUEST_FINISHED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
@@ -80,11 +67,8 @@ fn questgiver_show_hide_plays_open_and_close_kits() {
);
}
/// The four-child-panel restructure (this pass): each questgiver event shows exactly ONE of the four
/// real sub-panels and hides the other three — mirroring the ref's own `QuestFrame*Panel_OnShow`
/// (each hides its three siblings on show). No sound on a panel SWITCH (only the outer window's
/// OnShow/OnHide play kits, per the ref `QuestFrame_OnShow`/`OnHide` — a panel change while the window
/// stays open is silent).
/// Each stock `QuestFrame*Panel_OnShow` hides its three siblings; only the window's own show and
/// hide play a sound.
#[test]
fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
benilla_formats::wow_data_or_skip!();
@@ -101,7 +85,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -146,8 +130,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
.unwrap(),
"Deputy Willem"
);
// No sound on the panel event itself — the window was already open (well, first open here DOES
// play the open kit; drain it so the assertion below is about the SWITCH, not this first show).
// Drain the first show's open sound; the switch below must play none.
s.take_sounds();
s.set_quest(Some(QuestState {
@@ -178,13 +161,8 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
);
}
/// The reward grid, ported from the ref's shared `QuestFrameItems_Update` — on ONE numbered pool
/// per panel (`QuestDetailItem1..10`), the reference's own shape: choices first, then the
/// reward-spell row, then fixed rewards, each positioned at runtime. The fixture is the one the
/// task's capture drives — 2 choices + 1 fixed reward + money — so the pool reads
/// `Item1`/`Item2` = the choices, `Item3` = the fixed reward, `Item4`+ unused. It used to run on
/// two 6-deep pools named `QuestDetailChoice*`/`QuestDetailReward*`, names the reference does not
/// have and a split the ref's own function cannot be written against.
/// Stock `QuestFrameItems_Update` fills one pool of 10 per panel: choices, the spell row, then
/// fixed rewards, so two choices and one reward are `Item1`, `Item2` and `Item3`.
#[test]
fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
benilla_formats::wow_data_or_skip!();
@@ -201,7 +179,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -271,14 +249,10 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
"Rewards"
);
// The detail panel's choice rows are informational only (ref: only the REWARD panel's rows
// select). They DO carry an OnClick now (`QuestItem_OnClick`, the ref's own
// QuestItemTemplate script — the ctrl/shift fork, decisions 1059/1060), but it has no select
// arm at all: an unmodified click must not raise and must not set an itemChoice.
// Detail rows run `QuestItem_OnClick`, which has no select arm (`QuestFrame.lua:115-125`).
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.
// Only the reward panel's update (`QuestFrame.lua:519`) and its row clicks set `itemChoice`.
assert_eq!(
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice or 0")
.unwrap(),
@@ -287,8 +261,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
);
}
/// The reward panel's choice rows ARE selectable (ref `QuestRewardItem_OnClick`) — clicking one moves
/// the highlight and arms `GetQuestReward`'s 1-based→0-based conversion.
/// A reward row click selects its choice (`QuestFrame.lua:136-139`).
#[test]
fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
benilla_formats::wow_data_or_skip!();
@@ -305,7 +278,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -345,7 +318,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
.unwrap(),
"no row picked yet"
);
// Completing without a choice picked is a no-op (ref: QuestChooseRewardError guard).
// No choice picked: `QuestChooseRewardError()` instead (`QuestFrame.lua:97-98`).
s.run("QuestFrameCompleteQuestButton:Click()").unwrap();
assert!(
s.take_quest_actions().is_empty(),
@@ -368,8 +341,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
);
}
/// The greeting panel's own Goodbye button (ref `QuestFrameGreetingGoodbyeButton`, a widget the flat
/// v1 layout never carried) — a plain client-side close, no `DeclineQuest()` call (ref l.748-752).
/// Goodbye only hides the window, with no `DeclineQuest()` (`QuestFrame.xml:748-752`).
#[test]
fn greeting_goodbye_button_closes_the_window() {
benilla_formats::wow_data_or_skip!();
@@ -386,7 +358,7 @@ fn greeting_goodbye_button_closes_the_window() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -419,16 +391,8 @@ fn greeting_goodbye_button_closes_the_window() {
);
}
/// A rect-level regression guard for the exact bug the capture-loop caught: `BenillaQuestPanelButtonTemplate`
/// is a virtual template declared `hidden="true"` (never itself drawn) — an instance that doesn't
/// explicitly `:Show()` stays invisible even though it isn't declared `hidden="true"` itself. Every
/// panel's action buttons must show, extracted at real on-window rects (never dropped/hidden).
///
/// It runs the **instant-text** arm, which is not the shipped one: `QUEST_FADING_DISABLE` boots at
/// the reference's `"0"` since 1804 (it was pinned `"1"` by direction from 2026-07-17 until then),
/// so the flag is planted below. That arm is the one this guard wants — it is the shorter path to
/// a settled panel, and its one-frame Accept-disabled window is a state the rects have to survive.
/// The write-on arm is `write_on_still_fades_when_instant_text_is_off`'s subject.
/// Accept and Decline keep real on-window rects through the instant-text arm's one frame of
/// disabled Accept; the test plants `QUEST_FADING_DISABLE = "1"`, the default being `"0"`.
#[test]
fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
benilla_formats::wow_data_or_skip!();
@@ -445,14 +409,13 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.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 {
panel: QuestPanel::Detail,
@@ -493,8 +456,8 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
v
};
// The instant-text arm (QUEST_FADING_DISABLE planted "1" above): OnShow still disables Accept
// and zeroes the block for the ref's one-frame window — the reveal edge just starts at 1024.
// `OnShow` still disables Accept and zeroes the block; the reveal just starts at 1024
// (`QuestFrame.lua:543-551`).
let writing = button_art(&mut s);
assert_eq!(
writing.len(),
@@ -514,8 +477,8 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
);
}
// First tick: instant text keeps the writing sound — exactly one quill scratch — then the
// gradient runs off, the objectives/rewards block SNAPS to opaque (no fade) and Accept wakes.
// The first `OnUpdate` scratches once, snaps the block opaque and enables Accept
// (`QuestFrame.lua:554-568`).
let scratches = |sounds: Vec<SoundRequest>| {
sounds
.iter()
@@ -554,11 +517,8 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
);
}
/// The ref write-on runs verbatim: with QUEST_FADING_DISABLE = "0" — the ref's own default, and
/// ours too since 1804 (it was pinned "1" from 2026-07-17 until then, which is why this file
/// still says the value out loud rather than leaning on the shipped one) — the description writes
/// on at 40 chars/s scratching the quill each tick, Accept stays dead mid-write, and the
/// objectives/rewards block FADES in over QUESTINFO_FADE_IN rather than snapping.
/// With the default `QUEST_FADING_DISABLE = "0"` the text writes on at 40 chars/s, scratching each
/// tick with Accept disabled, then the block fades in (`QuestFrame.lua:5-6`, `:554-568`).
#[test]
fn write_on_still_fades_when_instant_text_is_off() {
benilla_formats::wow_data_or_skip!();
@@ -575,7 +535,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -600,7 +560,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
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.
// Mid-write: the reveal is at char 4 of 13.
s.tick(0.1);
assert!(
s.take_sounds()
@@ -612,8 +572,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
.eval::<bool>("return QuestFrameAcceptButton:IsEnabled() ~= 0")
.unwrap());
// Half a second more runs the 13-char gradient off (40 chars/s): Accept wakes and the block
// is mid-FADE — armed at 0 and ramping over QUESTINFO_FADE_IN, not snapped to opaque.
// Half a second more ends the 13-char gradient: Accept wakes and the block is mid-fade.
s.tick(0.5);
assert!(s.errors().is_empty(), "tick errors: {:?}", s.errors());
assert!(s
@@ -635,9 +594,8 @@ fn write_on_still_fades_when_instant_text_is_off() {
);
}
/// The giver window's title bar shows the NPC name — the panel-open events carry it as arg1 and
/// the in-place QUEST_ITEM_UPDATE refresh (how a late ask-once name arrives in live play) must
/// update it too (the 0112-era capture showed a permanently blank bar).
/// Every quest event but `QUEST_FINISHED`, `QUEST_ITEM_UPDATE` over a shown window included,
/// re-reads `UnitName("npc")` into the title bar (`QuestFrame.lua:26`, `:62-63`).
#[test]
fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
benilla_formats::wow_data_or_skip!();
@@ -654,7 +612,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -684,8 +642,6 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
"Marshal McBride",
"the panel-open event's arg1 lands in the title bar"
);
// And it RENDERS: the quad extracts with a rect near the window top (the 0112-era capture
// showed a blank bar — pin the whole path, not just the Lua-visible text).
s.resolve();
let name_quad = s
.extract()
@@ -700,7 +656,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
"the NPC-name quad carries a resolved rect (got None — under-constrained)"
);
// The late-name path: open with an empty name, the refresh brings it.
// A cleared name comes back on the refresh.
s.run("getglobal('QuestFrameNpcNameText'):SetText('')")
.unwrap();
s.fire_event(
@@ -715,18 +671,15 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
);
}
/// The gossip window's twin law, on the greeting panel's quest-title rows: a title long enough to
/// WRAP at the row label's 275 px must grow its row, or the static/Lua-chained rows below print
/// through it (the shape of the gossip overlap the director reported). Same deterministic
/// 6 px/char × 14 px/line measure fake as the gossip row test.
/// A title that wraps at the row label's 275px (`QuestFrameTemplates.xml:202`) grows its row, or
/// the rows below overlap it.
#[test]
fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// 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.
// Stock sizes each row right after `SetText`, `GetTextHeight() + 2` (`QuestFrame.lua:245`), so
// the measure must be synchronous, as the app's is.
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");
@@ -739,7 +692,7 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -815,16 +768,9 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
);
}
/// The questgiver rows' modifier fork (decisions 1059/1060), on the panel where it can do the most
/// damage: the REWARD panel, whose choice rows are also the quest's reward *selection*. Ref
/// `QuestRewardItem_OnClick` (QuestFrame.lua:127-141) — ctrl previews, shift posts the link, and the
/// choice SELECT is the third arm, so neither modified click may also pick the reward. The fixed
/// reward row beside it rides the plainer `QuestItem_OnClick` (l.115-125, the base
/// QuestItemTemplate's own OnClick) and previews the same way with no select arm to disturb.
///
/// Ordering note: the dressing room docks the same left UIPanel slot as the questgiver window
/// (`UiPanels.xml`, `pushable = 2` vs the giver's `0`), so the ctrl arm is exercised last — opening
/// it closes the giver window, exactly as it does in play.
/// Stock `QuestRewardItem_OnClick` (`QuestFrame.lua:127-141`): ctrl previews, shift posts the
/// link, and only a plain click selects. The ctrl arm runs last: the dressing room takes the
/// questgiver window's left slot (`UIParent.lua:24`, `:41`).
#[test]
fn reward_rows_preview_and_post_without_selecting_the_choice() {
benilla_formats::wow_data_or_skip!();
@@ -841,7 +787,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
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, "ScrollTemplates.xml"); // our scroll kits
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
@@ -893,9 +839,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
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
// arm). Row 2 is picked here so the modified clicks below — aimed at row 1 — would visibly
// move it if they leaked into the select arm.
// A plain click selects row 2; the modified clicks below aim at row 1.
s.run("QuestRewardItem2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return QuestFrameRewardPanel.itemChoice")
@@ -911,7 +855,6 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"a plain click never opens the dressing room"
);
// SHIFT + chat open → the row's full escaped link, and the selection is untouched.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("QuestRewardItem1:Click()").unwrap();
@@ -929,8 +872,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"the shift arm returns — it must NOT also select the clicked choice"
);
// A FIXED reward row posts too — same pool, same template now, and the select arm is gated
// by `button.kind == "choice"` rather than by which pool the row came from.
// A fixed reward row posts too; only `this.type == "choice"` selects (`QuestFrame.lua:136`).
s.run("ChatFrameEditBox:SetText(\"\")").unwrap();
s.set_modifiers(true, false, false);
s.run("QuestRewardItem3:Click()").unwrap();
@@ -941,8 +883,6 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"|cffffffff|Hitem:2504:0:0:0|h[Worn Shortbow]|h|r"
);
// CTRL → the dressing room wearing the clicked reward, and STILL no reselect. The intent pair
// is ordered: Dress (the room was closed, so it re-dresses in the player's own gear) then TryOn.
s.set_modifiers(false, true, false);
s.run("QuestRewardItem1:Click()").unwrap();
s.set_modifiers(false, false, false);
@@ -1,13 +1,5 @@
//! The timed-quest countdown window against the transcribed reference behaviour (decision 1150,
//! closing B234): the extracted 1.12 `QuestTimerFrame.lua` is the spec — one `SecondsToTime` line
//! per timed quest, the frame's height `45 + 16·n`, hidden entirely at zero timers, and every row
//! mapping back to its quest for the click and the hover.
//!
//! Three halves, and the last two are the load-bearing ones (`mirror_timer_tests`' lesson, and
//! 0675's): the state tests read the Lua back, which is blind to what actually *paints*; the
//! [`draw list`](UiScript::extract) test is what would catch a window that is shown, correctly
//! filled and invisible; and the geometry diff is what would catch every number being wrong while
//! all of the above passes.
//! The stock timed-quest window (`QuestTimerFrame.lua`): one `SecondsToTime` row per timed quest,
//! height `45 + 16 * n`, hidden at zero timers, each row mapped back to its quest.
use benilla_ui::script::{
QuestLogEntryView, QuestLogObjectiveView, QuestLogState, ScriptValue, UiScript,
@@ -15,23 +7,16 @@ use benilla_ui::script::{
use super::test_ui::load_ui as load_xml;
/// Fonts + UIParent (SecondsToTime lives there) + the window itself. Deliberately NOT the whole
/// manifest: the window must stand up on its own dependencies, and a harness that loads everything
/// hides a missing one.
/// The window's own dependencies only, so a missing one shows.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The window's title is `text="QUEST_TIMERS"`, resolved against the global at load — without
// the player's own strings it draws the KEY, which is exactly what a `text=` attribute does
// with an unknown name.
// The title's `text="QUEST_TIMERS"` resolves at load; an unknown name draws as the key.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
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.
// `SecondsToTime` for every row, `UIParent_ManageFramePositions` for OnShow and OnHide.
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.
// QuestLogFrame.xml brings `MAX_QUESTS` (`QuestLogFrame.lua:2`), the repaint's loop bound.
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
@@ -49,7 +34,7 @@ fn harness() -> UiScript {
s
}
/// A quest-log row. `timer` is the descriptor slot's absolute deadline (0 = untimed).
/// A quest-log row; `timer` is the absolute deadline, 0 for untimed.
fn quest(id: u32, title: &str, timer: u32) -> QuestLogEntryView {
QuestLogEntryView {
quest_id: id,
@@ -85,21 +70,17 @@ fn row_text(s: &UiScript, i: u32) -> String {
.unwrap()
}
/// The reference's whole visible contract in one pass: nothing at all with no timed quest; the
/// window with one line per timed quest, in log order, captioned by `SecondsToTime`; the height
/// `45 + 16·n`; and back to hidden when the last timer goes.
#[test]
fn the_window_follows_the_timed_quests() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness();
// An untimed log: QUEST_LOG_UPDATE fires, the frame stays down.
s.set_quest_log(log(vec![quest(783, "A Threat Within", 0)]));
s.set_server_unix_time(1_000_000.0);
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(!shown(&s, "QuestTimerFrame"), "no timed quest, no window");
// Two timed quests either side of an untimed one. 14:20 and 45 s out.
// Two timed quests around an untimed one, 860 s and 45 s out.
s.set_quest_log(log(vec![
quest(1, "Deliver the Message", 1_000_860),
quest(2, "A Threat Within", 0),
@@ -110,9 +91,7 @@ fn the_window_follows_the_timed_quests() {
assert!(shown(&s, "QuestTimerFrame"));
assert!(shown(&s, "QuestTimer1") && shown(&s, "QuestTimer2"));
assert!(!shown(&s, "QuestTimer3"), "only two timers are live");
// SecondsToTime's shipped spelling, plural rule and trailing space, all of it (ref
// UIParent.lua:1004-1031) — over the reference's own `−1` (decision 1154), so a 860-second
// gap reads 859 and a 45-second one 44.
// The reference's timers read 1 s short, formatted by `SecondsToTime` (`UIParent.lua:1004`).
assert_eq!(row_text(&s, 1), "14 Mins 19 Secs ");
assert_eq!(row_text(&s, 2), "44 Secs ");
assert_eq!(
@@ -120,24 +99,19 @@ fn the_window_follows_the_timed_quests() {
45.0 + 16.0 * 2.0
);
// Row 2 belongs to the THIRD log entry — the untimed row in the middle must not shift it.
// Row 2 maps to the third log entry, past the untimed one.
assert_eq!(s.eval::<i64>("return GetQuestIndexForTimer(2)").unwrap(), 3);
// The clock alone moves and the OnUpdate repaints — no QUEST_LOG_UPDATE, which is the whole
// point of the engine owning the subtraction.
// The clock alone moves; OnUpdate repaints with no QUEST_LOG_UPDATE (`QuestTimerFrame.lua:35`).
s.set_server_unix_time(1_000_030.0);
s.tick(0.2);
assert_eq!(row_text(&s, 2), "14 Secs ");
// The last timer expires out of the list: the window goes with it.
s.set_quest_log(log(vec![quest(2, "A Threat Within", 0)]));
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(!shown(&s, "QuestTimerFrame"));
}
/// The countdown must actually reach the screen. The state test above passes just as happily for a
/// window that is shown, correctly filled, and drawing nothing — the failure mode `cast_tests`
/// and `mirror_timer_tests` were both written for.
#[test]
fn the_countdown_reaches_the_draw_list() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -166,9 +140,7 @@ fn the_countdown_reaches_the_draw_list() {
);
}
/// PLAYER_ENTERING_WORLD is the other event the reference registers (`QuestTimerFrame.lua:3`) —
/// a world entry with a timed quest already in the log must paint without waiting for the log to
/// change again.
/// The window also repaints on `PLAYER_ENTERING_WORLD` (`QuestTimerFrame.lua:3`).
#[test]
fn a_world_entry_paints_a_quest_already_running() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -177,7 +149,6 @@ fn a_world_entry_paints_a_quest_already_running() {
s.set_server_unix_time(1_000_000.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Bool(true)]);
assert!(shown(&s, "QuestTimerFrame"));
// Singular at exactly one minute — SecondsToTime's `_P1` plural rule, the ref's own; 90 s of
// gap reads 89 through the `−1`.
// One minute takes the singular `MINUTES_ABBR`, not its `_P1` plural; 90 s reads 89.
assert_eq!(row_text(&s, 1), "1 Min 29 Secs ");
}
File diff suppressed because it is too large Load Diff
+41 -138
View File
@@ -1,46 +1,15 @@
//! **"`Quiver.CastPetAction` is nil when the addon's own macro runs"** — bug B267, reproduced end
//! to end and then closed.
//!
//! ## Why this file exists
//!
//! The report is a `/run Quiver.CastPetAction("Furious Howl")` macro raising
//! `attempt to call field 'CastPetAction' (a nil value)`. The field is published on the addon's
//! **last** line of `VARIABLES_LOADED`:
//!
//! ```lua
//! if event == "VARIABLES_LOADED" then
//! LoadLocale() Migrations() savedVariablesRestore()
//! initSlashCommandsAndModules() -- builds the whole config UI, HUNTERS ONLY
//! RegisterGlobalFunctions() -- <- Quiver.CastPetAction = … lives here
//! ```
//!
//! so anything that raises in that handler takes every global function with it. Three separate
//! walls did, one behind the other, and each is a real gap of ours:
//!
//! | | what it does | who found it |
//! |---|---|---|
//! | the Region method map was two implementations, not one | `Api._Height = WorldFrame.GetHeight` applied to a Texture raised `stale or invalid frame handle` | this bug |
//! | `Button:SetFontString` missing | every dropdown option row died on its first line | behind wall 1 |
//! | `Frame:SetMinResize`/`SetMaxResize` missing | `SideEffectMakeMoveable` died on every module frame | behind wall 2 |
//!
//! **The addon survey scored Quiver `loaded, session=ok, probe=ok` through all three.** It seats a
//! WARRIOR, and every one of these is behind `if cl == "HUNTER"` — the same blind spot shape as
//! Bagnon's (`ui_script::bagnon_render_tests`), one axis over: there the columns asked "did it
//! raise" and never "did it draw"; here they ask both, of a session the addon declines to run in.
//! So the fixture below seats a **hunter**, and that is the load-bearing part of it.
//!
//! Nothing from the corpus is committed, and the test skips cleanly on a machine without it — the
//! `ui_chat::ace_gate_tests` rule.
//! Quiver, a third-party hunter addon, in a hunter's session. Its `VARIABLES_LOADED` handler
//! builds its modules and then publishes its global functions (`Quiver.CastPetAction` and the
//! rest), so any raise in that handler loses them all. The modules run only for a hunter, so the
//! fixture seats one. Nothing from the corpus is committed; the tests skip without it.
use std::path::{Path, PathBuf};
use benilla_ui::script::{ScriptValue, UiScript, UnitState};
use benilla_ui::toc::Toc;
/// The corpus root **and** a Quiver in it, or `None` — a skip, never a failure. Quiver is a live
/// third-party addon (`github.com/SabineWren/Quiver`) whose shipped file is a generated bundle, so
/// a machine that wants this test builds it into the corpus once. The corpus itself resolves
/// through `benilla_formats::addon_corpus` — the one resolver, whose doc carries the incident.
/// The corpus root holding a Quiver, or `None` for a skip. Quiver ships a generated bundle, so a
/// machine that wants these tests builds it into the corpus once.
fn quiver_root() -> Option<PathBuf> {
benilla_formats::addon_corpus_candidates()
.into_iter()
@@ -70,7 +39,7 @@ fn read_toc(root: &Path, name: &str) -> Toc {
))
}
/// One addon's `.toc` files through the same two arms the real loader uses (decision 1186).
/// One addon's `.toc` files through the same two arms the real loader uses.
fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
let toc = read_toc(root, name);
let provider = |req: &str| -> Option<Vec<u8>> { std::fs::read(root.join(req)).ok() };
@@ -100,11 +69,8 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
errors
}
/// A VM shaped like the reporter's session: our whole interface, and a **hunter** at the keyboard.
///
/// The class is the point (see the module doc). Everything else is the addon-survey seat's own
/// minimum — a named player on a named realm, with a faction group, because a character in a real
/// session always has all three and a nil one is a failure mode no player can produce.
/// Our whole interface with a hunter at the keyboard: named, on a named realm, with a faction
/// group, as every real session has.
fn seat_a_hunter(root: &Path) -> UiScript {
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
@@ -132,12 +98,8 @@ fn seat_a_hunter(root: &Path) -> UiScript {
..Default::default()
}),
);
// A HUNTER'S SPELLBOOK. Quiver's `Api.Spell.FindSpellIndex` opens
// `GetSpellTabInfo(GetNumSpellTabs())` and adds `offset + numSpells`, so an EMPTY book is
// `attempt to perform arithmetic on local 'tabOffset'` every tick — the reference answers nil
// there too, so that is the addon's own defect on a spell-less character and not a gap of
// ours. Seating a book is what lets this test TICK, which is where the module OnUpdates live.
// Two tabs and four spells is the addon-survey fixture's own shape, with a hunter's names.
// A spellbook: Quiver's `FindSpellIndex` does arithmetic on `GetSpellTabInfo`'s offset, which
// is nil for an empty book in the reference too, so without one every tick raises.
{
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView};
let slot = |spell_id: u32, name: &str, rank: Option<&str>| SpellSlotView {
@@ -173,10 +135,7 @@ fn seat_a_hunter(root: &Path) -> UiScript {
let info = super::addons::info_from_toc("Quiver", &read_toc(root, "Quiver"));
s.register_addons(vec![info], Some(root.to_path_buf()), None, None);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
// The in-game UI loads on world entry, so a player always exists by then.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -194,12 +153,8 @@ fn seat_a_hunter(root: &Path) -> UiScript {
s
}
/// **The reported symptom.** Load Quiver into a hunter's session, drive the session start the
/// client drives, and ask the question the macro asked.
///
/// The assertion is deliberately the addon's own field and not "no errors": B267 was filed as a
/// nil field, and a future wall inside `initSlashCommandsAndModules` would take this field out
/// again even if it left a different error behind.
/// After a hunter's session start `Quiver.CastPetAction` is a function. Asserted on the field
/// itself, since a raise anywhere in `initSlashCommandsAndModules` loses it.
#[test]
fn quiver_publishes_its_global_functions_for_a_hunter() {
benilla_formats::wow_data_or_skip!();
@@ -214,7 +169,7 @@ fn quiver_publishes_its_global_functions_for_a_hunter() {
s.fire_event(e, Vec::new());
}
// The macro the report ran: `/run Quiver.CastPetAction("Furious Howl"); …`
// What a `/run Quiver.CastPetAction("Furious Howl")` macro calls.
assert_eq!(
s.eval::<String>("return type(Quiver.CastPetAction)")
.unwrap(),
@@ -223,8 +178,7 @@ fn quiver_publishes_its_global_functions_for_a_hunter() {
before publishing it — errors so far: {:#?}",
s.errors()
);
// The whole of `RegisterGlobalFunctions`, not just the one the report named — they publish
// together, so any of them missing means the same handler died at the same place.
// Everything `RegisterGlobalFunctions` publishes; they go together.
for name in [
"CastNoClip",
"CastPetAction",
@@ -244,10 +198,8 @@ fn quiver_publishes_its_global_functions_for_a_hunter() {
}
}
/// The session start itself raises **nothing** — the three walls, stated as the thing they were.
///
/// Separate from the field assertion above because it fails differently and more usefully: this is
/// the test that names a *new* wall the moment one appears, instead of reporting a nil field.
/// A hunter's session start and a second of frames after it raise nothing; a failure here names
/// the raise.
#[test]
fn quiver_survives_a_hunter_session_start_without_raising() {
benilla_formats::wow_data_or_skip!();
@@ -259,9 +211,8 @@ fn quiver_survives_a_hunter_session_start_without_raising() {
for e in ["VARIABLES_LOADED", "PLAYER_LOGIN", "PLAYER_ENTERING_WORLD"] {
s.fire_event(e, Vec::new());
}
// …and then a second of frames, because Quiver's modules are OnUpdate-driven (the auto-shot
// timer, the range indicator, the aspect tracker) and a handler that only raises once it is
// *running* is invisible to the event burst alone.
// Then a second of frames: the modules (the auto-shot timer, the range indicator, the aspect
// tracker) run on OnUpdate.
for _ in 0..10 {
s.tick(0.1);
}
@@ -272,14 +223,10 @@ fn quiver_survives_a_hunter_session_start_without_raising() {
);
}
// ───────────────────────────────────────────────────────────────────────────────────────────────
// The Auto Shot Timer's state machine — why the bar "just stays full".
// ───────────────────────────────────────────────────────────────────────────────────────────────
// ── The Auto Shot Timer's state machine ────────────────────────────────────────────────────────
/// A ranged weapon on the character sheet, so `UnitRangedDamage("player")` answers a real speed.
///
/// 2.8s is a vanilla hunter bow. The addon computes `reloadTime = speed - 0.5`, so this is what
/// makes the reload phase measurable rather than a divide by zero.
/// A 2.8 s bow, so `UnitRangedDamage("player")` answers a speed; the addon's reload is
/// `speed - 0.5`.
fn seat_a_bow(s: &mut UiScript) {
use benilla_ui::script::UnitCombatStats;
s.set_player_combat_stats(Some(UnitCombatStats {
@@ -299,8 +246,8 @@ fn start_session(s: &mut UiScript) {
}
}
/// The addon's own state machine, read through the three globals it publishes for macros.
/// Reading these beats measuring the bar's pixels: they ARE what the bar draws from.
/// The addon's shot state, read through the three functions it publishes for macros, which the
/// bar draws from.
fn shot_state(s: &mut UiScript) -> (bool, bool, f64, f64) {
let mid = s.eval::<bool>("return Quiver.PredMidShot()").unwrap();
let (reloading, reload_left) = s
@@ -312,16 +259,8 @@ fn shot_state(s: &mut UiScript) -> (bool, bool, f64, f64) {
(mid, reloading, reload_left, shoot_left)
}
/// **The reported symptom, reproduced.** *"the shot timer doesn't seem to work properly when
/// standing still and shooting, it just stays full."*
///
/// Quiver's ONLY detector for "an auto shot actually fired" is `ITEM_LOCK_CHANGED` — in the real
/// client, spending an arrow toggles the ammo slot's lock, and the addon's own comment says so:
/// *"Inventory event, such as using ammo or drinking a potion. This is how we detect auto shots."*
///
/// benilla fires `ITEM_LOCK_CHANGED` only from bag / cursor / mail / loot paths. Nothing fires it
/// for ammo spent on a ranged attack. So the addon starts its 0.5s aim, saturates it, and waits
/// forever for a shot it is never told about — which is a bar pinned at 100%.
/// Quiver detects a fired auto shot only by `ITEM_LOCK_CHANGED`, which a spent arrow's stack
/// write fires; with no such event its 0.5 s aim saturates and the bar stays full.
#[test]
fn auto_shot_bar_saturates_when_no_ammo_lock_event_ever_arrives() {
benilla_formats::wow_data_or_skip!();
@@ -351,9 +290,7 @@ fn auto_shot_bar_saturates_when_no_ammo_lock_event_ever_arrives() {
);
}
/// **The mechanism, proven.** The same session, plus the one event benilla never sends: the bar
/// immediately behaves. This is what pins the diagnosis on the missing ammo lock rather than on
/// the addon, on `SetWidth`, or on the standing-still check.
/// The same session plus one `ITEM_LOCK_CHANGED`: the reload phase starts at once.
#[test]
fn auto_shot_bar_drains_the_moment_an_ammo_lock_event_arrives() {
benilla_formats::wow_data_or_skip!();
@@ -365,7 +302,7 @@ fn auto_shot_bar_drains_the_moment_an_ammo_lock_event_arrives() {
s.fire_event("START_AUTOREPEAT_SPELL", Vec::new());
s.tick(0.1);
// The arrow leaves the quiver. This is the event the real client fires and we do not.
// The arrow leaves the quiver.
s.fire_event("ITEM_LOCK_CHANGED", Vec::new());
s.tick(0.1);
@@ -381,9 +318,7 @@ fn auto_shot_bar_drains_the_moment_an_ammo_lock_event_arrives() {
);
}
// ───────────────────────────────────────────────────────────────────────────────────────────────
// The Aspect Tracker — what it actually draws, and the one aspect it deliberately does not.
// ───────────────────────────────────────────────────────────────────────────────────────────────
// ── The Aspect Tracker ─────────────────────────────────────────────────────────────────────────
/// Seat one active player buff by name and announce it the way the app's aura feed does.
fn seat_a_buff(s: &mut UiScript, spell_id: u32, name: &str, icon: &str) {
@@ -407,12 +342,8 @@ fn seat_a_buff(s: &mut UiScript, spell_id: u32, name: &str, icon: &str) {
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
}
/// Is any quad drawing this art, visibly?
///
/// **The buff bar is hidden first, and that is load-bearing.** An active aura paints its own icon
/// through `BuffButton*`, so while Aspect of the Hawk is up TWO quads carry
/// `Spell_Nature_RavenForm` — the tracker's, and the player's buff bar. An earlier cut of this
/// helper counted both and reported the tracker as drawing when it had correctly hidden itself.
/// Whether a visible quad draws this art. The buff bar is hidden first: an active aura's own
/// `BuffButton` icon carries the same art as the tracker's.
fn draws(s: &mut UiScript, leaf: &str) -> bool {
use benilla_ui::script::QuadContent;
s.eval::<()>("if BuffFrame then BuffFrame:Hide() end")
@@ -431,13 +362,8 @@ fn draws(s: &mut UiScript, leaf: &str) -> bool {
})
}
/// **The Aspect Tracker draws.** Cheetah up ⇒ the Cheetah icon is on screen.
///
/// This is the module end to end on our stack: the scanning tooltip
/// (`CreateFrame("GameTooltip", …, "GameTooltipTemplate")` → `SetPlayerBuff` → the *named*
/// `…TextLeft1` font string → a string compare against the localized spell name), then
/// `Texture:SetTexture` and the backdrop frame it lives in. Every one of those is a real
/// dependency of ours, and a break in any of them shows up here as a missing quad.
/// With Aspect of the Cheetah up the tracker draws its icon, found through a scanning tooltip:
/// `SetPlayerBuff`, then the named `TextLeft1` line compared with the spell name.
#[test]
fn aspect_tracker_draws_the_icon_for_an_active_aspect() {
benilla_formats::wow_data_or_skip!();
@@ -460,26 +386,9 @@ fn aspect_tracker_draws_the_icon_for_an_active_aspect() {
);
}
/// **The Hawk arm is gated on the frame LOCK, and getting that wrong looks exactly like a bug.**
///
/// `chooseIconTexture` tests seven aspects by name (Beast, Cheetah, Fox, Monkey, Viper, Wild,
/// Wolf) and then handles Hawk differently:
///
/// ```lua
/// elseif Api.Spell.PredSpellLearned(Hawk) and not Api.Aura.PredBuffActive(Hawk)
/// or not Quiver_Store.IsLockedFrames
/// ```
///
/// `and` binds tighter than `or`, so this is `(learned and NOT active) or (UNLOCKED)`. Two
/// clauses, and the second is the one that catches you out:
///
/// - the Hawk icon is a **missing-aspect reminder**, not a status light — with Hawk up it is
/// suppressed by the first clause;
/// - but **unlocked frames force it on regardless**, so you can see the frame you are dragging.
///
/// A fresh profile starts UNLOCKED, so the honest default is *the reminder shows even with Hawk
/// up*. This test asserts both halves, because an earlier cut of it asserted only "blank while
/// Hawk is up" and failed — the addon was right and the test was wrong.
/// `chooseIconTexture` shows the Hawk icon when `(learned and not active) or not IsLockedFrames`:
/// a reminder while Hawk is missing, and forced on while frames are unlocked, the fresh-profile
/// default, so the frame can be dragged.
#[test]
fn the_hawk_reminder_is_suppressed_only_once_frames_are_locked() {
benilla_formats::wow_data_or_skip!();
@@ -495,7 +404,7 @@ fn the_hawk_reminder_is_suppressed_only_once_frames_are_locked() {
"Interface\\Icons\\Spell_Nature_RavenForm",
);
// Unlocked (the fresh-profile default): the reminder shows even though Hawk IS up.
// Unlocked: the reminder shows even though Hawk is up.
s.eval::<()>("Quiver_Store.IsLockedFrames = false").unwrap();
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
assert!(
@@ -513,13 +422,8 @@ fn the_hawk_reminder_is_suppressed_only_once_frames_are_locked() {
);
}
/// **B267's second half, end to end through the real feed.** Not a hand-fired event this time:
/// a container snapshot whose arrow stack ticks down by one, pushed through
/// [`crate::ui_items::feed::apply_container_source`] exactly as a server object update does.
///
/// This is the test that would have caught the bug in the first place. The A/B above proves the
/// addon reacts to `ITEM_LOCK_CHANGED`; this proves **we actually send one when an arrow is
/// spent**, which is the half that was missing (decision 1509).
/// An arrow stack one lighter, pushed through [`crate::ui_items::feed::apply_container_source`]
/// as a server update is, fires `ITEM_LOCK_CHANGED` and starts Quiver's reload.
#[test]
fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
benilla_formats::wow_data_or_skip!();
@@ -565,8 +469,7 @@ fn spending_an_arrow_starts_quivers_reload_through_the_real_item_feed() {
let (_, reloading_before, _, _) = shot_state(&mut s);
assert!(!reloading_before, "no shot has landed yet");
// The server tells us the stack is one lighter. That is a fired shot, and the only way the
// addon can ever know it.
// The server's stack count drops by one: a fired shot.
apply_container_source(
&mut s,
&mut memory,
+64 -192
View File
@@ -1,24 +1,11 @@
//! The social window's fourth tab — the raid pane (decision 1549, `RaidFrame.xml`): the tab, the
//! two states the pane has, the 8x5 grid's seating and colouring, the drag's three landings, the
//! row menu, the ready-check popup and the saved-instance panel.
//!
//! Its own module rather than more of `friends_tests` for `guild_tests`' reason: the pane is Lua
//! over a **raid** roster, so every test here has to push one first, and a file about the friends
//! list must not be in that business.
//!
//! What these guard that `ui_party`'s Rust tests structurally cannot: the pane is Lua over a
//! snapshot, so a row seated in the wrong slot, a colour rule inverted, a drag wired to the wrong
//! verb, or a menu row shown to the wrong person are all invisible there and green in the parse
//! sweep. Each test below fails on exactly one of those.
//! The social window's raid tab (`RaidFrame.xml` and `Blizzard_RaidUI`): the 8x5 grid's seating
//! and colouring, the drag, the row menu, the ready check and the saved-instance panel.
use benilla_ui::script::{
PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo, SelectionRequest,
UiScript,
};
/// 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);
@@ -36,11 +23,9 @@ fn text_of(s: &UiScript, region: &str) -> String {
.unwrap()
}
/// One raid row. `rank` is 2 leader / 1 assistant / 0 member; `subgroup` is the **1-based** number
/// the pane shows, which the record stores 0-based — the binding is what adds the one
/// (`RaidMemberInfo::subgroup`'s own doc, the reference's `0x4bb61a inc`). Converting here rather
/// than at every call site is the point: a fixture that hands the record a 1-based number seats
/// every row one group to the right, which looks exactly like a seating bug in the pane.
/// One raid row; `rank` is 2 leader, 1 assistant, 0 member. `subgroup` is the 1-based number the
/// pane shows: the record stores it 0-based and the binding adds one (`0x4bb61a`), so a 1-based
/// record would seat every row one group to the right.
fn row(
name: &str,
rank: u32,
@@ -63,10 +48,10 @@ fn row(
}
}
/// Push a roster and fire the event that follows it, exactly as `ui_party::feed_party` does.
/// Pushes a roster and fires its event, as `ui_party`'s `feed_party` does.
fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
// `members` is our own SUBGROUP's slice — non-empty is what makes `IsPartyLeader()`/
// `IsRaidLeader()` answer 1 for leader_index 0 (the shared `leads_the_group` predicate).
// `members` is our own subgroup's slice; non-empty, it makes `IsPartyLeader()` and
// `IsRaidLeader()` answer 1 for `leader_index` 0.
let members = raid
.iter()
.skip(1)
@@ -89,7 +74,7 @@ fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
s.fire_event("RAID_ROSTER_UPDATE", Vec::new());
}
/// A 12-member raid across three subgroups, us leading — the shape most tests want.
/// A 12-member raid across three subgroups, led by us, with row 2 an assistant.
fn twelve() -> Vec<RaidMemberInfo> {
let mut raid = vec![row("Me", 2, 1, "Warrior", true, false)];
for i in 1..12u32 {
@@ -106,10 +91,7 @@ fn twelve() -> Vec<RaidMemberInfo> {
raid
}
/// Open the tab **and drain what opening it queues**. `RaidFrame`'s OnShow calls
/// `RequestRaidInfo()` — the reference's own, and the reason the Raid Info button can ever be
/// right — so every `take_party_requests` after this would otherwise start with that ask and no
/// test about a button would be about that button.
/// Opens the tab and drains the `RequestRaidInfo()` its OnShow sends (`RaidFrame.xml:350`).
fn open_raid_tab(s: &mut UiScript) {
s.run("ToggleFriendsFrame(4)").unwrap();
assert_eq!(
@@ -119,14 +101,8 @@ fn open_raid_tab(s: &mut UiScript) {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The tab
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The tab ──────────────────────────────────────────────────────────────────────────────────────
/// Tab 4 exists, opens the pane, titles the window RAID, and puts every other subframe away.
///
/// This is the test the whole decision exists for: `FriendsFrame.xml` shipped three tabs and said
/// so in its header, and the tab strip is the one place a missing pane is visible without a raid.
#[test]
fn the_fourth_tab_opens_the_raid_pane() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -147,15 +123,13 @@ fn the_fourth_tab_opens_the_raid_pane() {
] {
assert!(!visible(&s, other), "{other} goes away");
}
// And the tab's own OnClick is wired — a tab that PanelTemplates enables but that does
// nothing when clicked is exactly the dead tab the guild arc had to fix.
s.run("FriendsFrameTab1:Click()").unwrap();
assert!(!visible(&s, "RaidFrame"));
s.run("FriendsFrameTab4:Click()").unwrap();
assert!(visible(&s, "RaidFrame"), "the tab button opens it too");
}
/// Closing the window closes the Raid Info flyout with it — the ref's fourth satellite.
/// `FriendsFrame_OnHide` hides `RaidInfoFrame` too (`FriendsFrame.lua:136`).
#[test]
fn hiding_the_window_closes_the_raid_info_panel() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -170,12 +144,10 @@ fn hiding_the_window_closes_the_raid_info_panel() {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The not-in-a-raid state
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The not-in-a-raid state ──────────────────────────────────────────────────────────────────────
/// Out of a raid the pane is the blurb plus Convert To Raid, and the button is live only for the
/// leader of an actual party — the three states, in order.
/// Out of a raid the pane is the blurb and Convert To Raid, live only for a party's leader
/// (`RaidFrame.lua:62-69`).
#[test]
fn convert_to_raid_is_live_only_for_a_party_leader() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -195,7 +167,7 @@ fn convert_to_raid_is_live_only_for_a_party_leader() {
);
assert!(!visible(&s, "RaidFrameAddMemberButton"));
// A party we lead: `leader_index == 0` with members is the shared leads-the-group predicate.
// A party we lead: `leader_index == 0` with members.
s.set_party(PartyState {
members: vec![PartyMemberInfo {
name: "Alice".into(),
@@ -218,7 +190,6 @@ fn convert_to_raid_is_live_only_for_a_party_leader() {
"and the button really sends it"
);
// A party we do NOT lead.
s.set_party(PartyState {
members: vec![PartyMemberInfo {
name: "Alice".into(),
@@ -236,16 +207,9 @@ fn convert_to_raid_is_live_only_for_a_party_leader() {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The grid
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The grid ─────────────────────────────────────────────────────────────────────────────────────
/// The roster paints: each row lands in ITS SUBGROUP's next free slot, carries its rank token, and
/// rows past the roster stay hidden.
///
/// The seating is the assertion with teeth. Row order and slot order are different orders — row 5
/// of a raid whose first four are in group 1 is group 2's *first* slot — and every drag, kick and
/// menu action downstream addresses a row by the index this seating implies.
/// Row order and slot order differ; every drag, kick and menu action addresses a row by row index.
#[test]
fn the_grid_seats_each_row_in_its_own_subgroups_next_free_slot() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -268,7 +232,7 @@ fn the_grid_seats_each_row_in_its_own_subgroups_next_free_slot() {
s.eval::<String>("return RaidGroupButton12.slot").unwrap(),
"RaidGroup3Slot4"
);
// The slot knows its occupant too — the link the drop code reads to decide move-vs-swap.
// The slot knows its occupant, which the drop reads to choose move or swap.
assert_eq!(
s.eval::<String>("return RaidGroup2Slot1.button").unwrap(),
"RaidGroupButton5"
@@ -302,18 +266,14 @@ fn the_grid_seats_each_row_in_its_own_subgroups_next_free_slot() {
"all eight groups show while in a raid"
);
// Leaving the raid puts the whole grid away and brings the blurb back.
push_raid(&mut s, Vec::new());
assert!(!visible(&s, "RaidGroup1"));
assert!(!visible(&s, "RaidGroupButton1"));
assert!(visible(&s, "RaidFrameRaidDescription"));
}
/// The colour ladder: offline grey beats everything, then dead red, then the class colour — and
/// all three columns take it together.
///
/// Inverting any two of these is invisible to every other test in this file: the rows still seat,
/// the names still read, the clicks still fire.
/// Offline grey is tested first, then dead red, then the class colour, on all three columns
/// (`Blizzard_RaidUI.lua:135-152`).
#[test]
fn a_rows_colour_is_offline_then_dead_then_class() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -325,7 +285,6 @@ fn a_rows_colour_is_offline_then_dead_then_class() {
row("Me", 2, 1, "Warrior", true, false),
row("Corpse", 0, 1, "Mage", true, true),
row("Gone", 0, 1, "Mage", false, false),
// Offline AND dead: grey wins, because a disconnected member's health is not news.
row("GoneDead", 0, 1, "Mage", false, true),
],
);
@@ -342,7 +301,7 @@ fn a_rows_colour_is_offline_then_dead_then_class() {
(b * 100.0).round() / 100.0,
)
};
// WARRIOR = 0.78, 0.61, 0.43 (Fonts.xml's RAID_CLASS_COLORS, the reference's own values).
// WARRIOR is 0.78, 0.61, 0.43 (`RAID_CLASS_COLORS`, `Fonts.xml:52`).
let warrior: (f32, f32, f32) = s
.eval("return RAID_CLASS_COLORS.WARRIOR.r, RAID_CLASS_COLORS.WARRIOR.g, RAID_CLASS_COLORS.WARRIOR.b")
.unwrap();
@@ -366,11 +325,8 @@ fn a_rows_colour_is_offline_then_dead_then_class() {
(0.5, 0.5, 0.5),
"offline AND dead is grey — offline is tested first"
);
// The level column takes the same colour as the name, never a different one. (The CLASS
// column is a Button rather than a FontString — the reference makes it one because it is the
// class pullout's drag handle — and a Button in this engine has `SetTextColor` but no getter,
// so it cannot be read back here. `RaidGroupButton_SetRowColor` writes all three from one
// colour in three adjacent lines; these two are the readable half of that.)
// Name and level share the row's colour; the class column is a Button, whose text colour
// this engine cannot read back.
assert_eq!(
round(color("RaidGroupButton2")),
round(
@@ -383,10 +339,8 @@ fn a_rows_colour_is_offline_then_dead_then_class() {
);
}
/// `UNIT_HEALTH`/`UNIT_LEVEL` repaint ONE row and do not go through the roster event.
///
/// This is the reason `RAID_ROSTER_UPDATE` is fired on the roster's identity rather than on every
/// field: a 40-row rebuild per point of damage taken is what the other choice costs.
/// `UNIT_HEALTH` and `UNIT_LEVEL` repaint one `raidN` row without the roster event
/// (`Blizzard_RaidUI.lua:27-38`).
#[test]
fn a_units_health_and_level_repaint_only_that_row() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -396,7 +350,7 @@ fn a_units_health_and_level_repaint_only_that_row() {
push_raid(&mut s, raid.clone());
assert_eq!(text_of(&s, "RaidGroupButton3Level"), "60");
// The row dies. Only the per-unit event is fired — no RAID_ROSTER_UPDATE.
// The row dies; only the per-unit event fires.
raid[2].ninth = true;
s.set_party(PartyState {
members: vec![PartyMemberInfo {
@@ -416,7 +370,7 @@ fn a_units_health_and_level_repaint_only_that_row() {
.unwrap();
assert!(red < 0.2, "row 3 went red without a roster rebuild ({red})");
// A unit event for something that is not a raid token is ignored rather than mis-parsed.
// A unit event for a token that is not `raidN` is ignored.
s.fire_event(
"UNIT_HEALTH",
vec![benilla_ui::script::ScriptValue::Str("target".into())],
@@ -432,12 +386,9 @@ fn a_units_health_and_level_repaint_only_that_row() {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The management buttons
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The management buttons ───────────────────────────────────────────────────────────────────────
/// Ready Check is the leader's alone; Add Member shows for everyone in a raid. Both are hidden
/// outright out of one — they share their seat with Convert To Raid.
/// Out of a raid both hide, sharing a seat with Convert To Raid (`Blizzard_RaidUI.lua:653-672`).
#[test]
fn ready_check_is_the_leaders_and_add_member_is_everyones() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -460,7 +411,6 @@ fn ready_check_is_the_leaders_and_add_member_is_everyones() {
.take_party_requests()
.contains(&PartyRequest::ReadyCheckStart));
// A member, not the leader: no Ready Check button at all.
let raid = twelve();
s.set_party(PartyState {
members: vec![PartyMemberInfo {
@@ -479,15 +429,10 @@ fn ready_check_is_the_leaders_and_add_member_is_everyones() {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The drag
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The drag ─────────────────────────────────────────────────────────────────────────────────────
/// The drag's three landings: an empty slot in another group MOVES, an occupied one SWAPS, and
/// anything else — including this row's own group — sends nothing and springs the row home.
///
/// Driven through the real handlers with `TARGET_RAID_SLOT` standing in for the hover sweep, which
/// needs a live cursor this harness has no way to place.
/// An empty slot in another group moves, an occupied one swaps, anything else springs the row home
/// (`Blizzard_RaidUI.lua:257-273`); `TARGET_RAID_SLOT` stands in for the hover sweep.
#[test]
fn dragging_a_row_moves_swaps_or_springs_back() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -503,14 +448,14 @@ fn dragging_a_row_moves_swaps_or_springs_back() {
.unwrap();
};
// Row 12 (group 3, seat 4) onto group 3's fifth seat — its OWN group. Nothing goes out.
// Row 12 (group 3, seat 4) onto group 3's fifth seat, its own group: nothing goes out.
drag(&s, "RaidGroupButton12", "RaidGroup3Slot5");
assert!(
s.take_party_requests().is_empty(),
"a drop inside the row's own group is not a move"
);
// Row 12 onto group 5's empty first seat — a MOVE, and the subgroup is the Lua 1-based one.
// Row 12 onto group 5's empty first seat: a move, with the 1-based subgroup.
drag(&s, "RaidGroupButton12", "RaidGroup5Slot1");
assert_eq!(
s.take_party_requests(),
@@ -520,7 +465,7 @@ fn dragging_a_row_moves_swaps_or_springs_back() {
}]
);
// Row 12 onto group 1's second seat, which row 2 is in — a SWAP, by the two ROW indices.
// Row 12 onto group 1's second seat, which row 2 holds: a swap, by the two row indices.
drag(&s, "RaidGroupButton12", "RaidGroup1Slot2");
assert_eq!(
s.take_party_requests(),
@@ -531,8 +476,7 @@ fn dragging_a_row_moves_swaps_or_springs_back() {
);
}
/// A non-leader cannot drag at all — the gate is on both ends, so a drag that never started also
/// never stops.
/// `OnDragStart` and `OnDragStop` both return for a non-leader (`Blizzard_RaidUI.lua:236`, `:248`).
#[test]
fn only_the_leader_may_drag() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -560,11 +504,8 @@ fn only_the_leader_may_drag() {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The row's click and menu
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The row's click and menu ─────────────────────────────────────────────────────────────────────
/// Left-clicking a row targets that row's `raidN` token — the same token the tooltip reads.
#[test]
fn left_clicking_a_row_targets_its_raid_token() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -578,11 +519,7 @@ fn left_clicking_a_row_targets_its_raid_token() {
);
}
/// Right-clicking a row opens the RAID menu, and each of its four rows appears for exactly the
/// person the reference shows it to.
///
/// The menu is addressed by ROW INDEX, and every rank rule re-reads that row's rank — so a menu
/// opened on the wrong index would offer Promote on somebody who is already an assistant.
/// The RAID menu's rank rules re-read the row's rank by row index (`UnitPopup.lua:382-401`).
#[test]
fn the_row_menu_offers_the_rank_verbs_by_rank() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -590,7 +527,6 @@ fn the_row_menu_offers_the_rank_verbs_by_rank() {
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
// Row 3 is a plain member: as the leader we may promote it, not demote it, and may kick it.
s.run("RaidGroupButton3:Click(\"RightButton\")").unwrap();
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
@@ -619,7 +555,6 @@ fn the_row_menu_offers_the_rank_verbs_by_rank() {
assert!(shown(&s, "RAID_DEMOTE"));
assert!(!shown(&s, "RAID_PROMOTE"));
// Row 1 is the leader — us. Nothing may be done to the leader.
s.run("HideDropDownMenu(1) RaidGroupButton1:Click(\"RightButton\")")
.unwrap();
assert!(!shown(&s, "RAID_LEADER"));
@@ -629,8 +564,7 @@ fn the_row_menu_offers_the_rank_verbs_by_rank() {
);
}
/// The menu's verbs reach the engine addressed the way the wire wants them — three by name, and
/// the kick by the row index.
/// Three verbs go by name and the kick by row index (`UnitPopup.lua:624-631`).
#[test]
fn the_menu_verbs_queue_the_right_requests() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -666,18 +600,14 @@ fn the_menu_verbs_queue_the_right_requests() {
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// Raid Info
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── Raid Info ────────────────────────────────────────────────────────────────────────────────────
/// The saved-instance panel fills from `GetSavedInstanceInfo`, and its button is live only once
/// the server has answered with something.
#[test]
fn the_raid_info_panel_lists_the_saved_lockouts() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
open_raid_tab(&mut s);
// The first UPDATE_INSTANCE_INFO only arms the ref's latch (`RaidFrame.hasRaidInfo`).
// The first `UPDATE_INSTANCE_INFO` only arms `RaidFrame.hasRaidInfo` (`RaidFrame.lua:43-47`).
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
s.set_saved_instances(vec![
SavedInstanceInfo {
@@ -697,8 +627,7 @@ fn the_raid_info_panel_lists_the_saved_lockouts() {
.unwrap(),
1
);
// The rows live inside the flyout, so `IsVisible` is false until it is open — open it, which
// is what the button does, and then ask.
// The rows live inside the flyout, so `IsVisible` is false until it opens.
s.run("RaidInfoFrame:Show()").unwrap();
assert!(visible(&s, "RaidInfoInstance1"));
assert!(visible(&s, "RaidInfoInstance2"));
@@ -718,7 +647,6 @@ fn the_raid_info_panel_lists_the_saved_lockouts() {
"four rows fit, so two need no bar"
);
// Bound to nothing: the button dies, and the panel does not keep a stale bar standing.
s.set_saved_instances(Vec::new());
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
@@ -726,42 +654,29 @@ fn the_raid_info_panel_lists_the_saved_lockouts() {
.unwrap(),
0
);
// **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.
// The stale row stays, as in the reference: `RaidInfoFrame_Update` wraps its row loop in
// `if ( savedInstances > 0 )` (`RaidFrame.lua:94`), so a count of zero never reaches the
// `Hide()`. The dead button keeps the flyout from reopening onto it.
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"));
s.run("RaidFrameRaidInfoButton:GetScript(\"OnClick\")()")
.unwrap();
assert!(!visible(&s, "RaidInfoFrame"), "and closes it again");
}
/// **The player with no lockouts at all** — the case that shipped broken (1561). Two answers, both
/// empty, is the whole ordinary session: one for `PLAYER_ENTERING_WORLD`'s `RequestRaidInfo` and
/// one for the pane's own on show. The first arms `RaidFrame.hasRaidInfo` and returns; the second
/// is the one that has to put the button away and empty the panel behind it.
///
/// Its sibling above proves the same arithmetic from a list that had something in it. This one
/// proves it from a list that never did, which is the case a *diff* cannot reach — and the button
/// left live over an empty panel is exactly what the director saw.
/// An ordinary session gets two empty answers, for `PLAYER_ENTERING_WORLD`'s `RequestRaidInfo` and
/// the pane's OnShow; the first arms the latch, the second disables the button.
#[test]
fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
open_raid_tab(&mut s);
// Answer one: the latch, and nothing else. The button is still whatever it loaded as — the
// reference does not disable it in `RaidFrame_OnLoad` either, so this window is its own.
// Answer one only arms the latch; `RaidFrame_OnLoad` leaves the button as it loaded.
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrame.hasRaidInfo or 0").unwrap(),
@@ -769,7 +684,6 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
"the first answer only arms the latch"
);
// Answer two, saying the same nothing.
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled()")
@@ -778,16 +692,8 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
"an empty lockout list is a dead button"
);
// **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.
// As in the reference, the row loop never runs (`RaidFrame.lua:94`), so the rows keep the
// visibility `RaidFrame.xml` declares; only a frame shown by hand reaches them.
s.run("RaidInfoFrame:Show()").unwrap();
assert!(
visible(&s, "RaidInfoInstance1") && visible(&s, "RaidInfoInstance10"),
@@ -795,8 +701,7 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
);
s.run("RaidInfoFrame:Hide()").unwrap();
// The button is dead to a real click, not merely drawn grey — the half the director asked for
// by name. Driven through the pointer, because that is the path a press actually takes.
// The button is dead to a real click through the pointer, not merely drawn grey.
let (bx, by) = {
let l: f32 = s.eval("return RaidFrameRaidInfoButton:GetLeft()").unwrap();
let r: f32 = s.eval("return RaidFrameRaidInfoButton:GetRight()").unwrap();
@@ -815,13 +720,8 @@ fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
);
}
/// The scroll bar the fifth lockout brings on has to land on the trough drawn behind it, not where
/// `UIPanelScrollFrameTemplate` seats a bare ScrollFrame's bar (1561).
///
/// Geometry, asserted as the relationship rather than as four numbers: the bar is CENTRED in the
/// trough art on X, and its top is the panel's own -3 rather than the template's -16. Both were
/// wrong before — 2 px left, 13 px low — and neither is visible to any test that only asks whether
/// the bar is shown.
/// Past four lockouts `RaidInfoFrame_Update` seats the bar at `(8, -3)` off the scroll frame
/// (`RaidFrame.lua:108-110`), centred on the trough art, not at the template's `(6, -16)`.
#[test]
fn the_scroll_bar_is_seated_on_the_trough_the_panel_draws_behind_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -862,20 +762,17 @@ fn the_scroll_bar_is_seated_on_the_trough_the_panel_draws_behind_it() {
< 0.01,
"and hangs 3 px under the frame's top, not the template's 16"
);
// The up arrow rides above the bar, so re-seating the bar is what lifts it into the trough's
// own cap — the piece of this that is actually visible.
// The up arrow rides above the bar, so the re-seat lifts it into the trough's cap.
assert!(
top(&s, "RaidInfoScrollFrameScrollBarScrollUpButton") > top(&s, "RaidInfoScrollFrame"),
"the up arrow clears the scroll frame, where the template left it 13 px inside"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The ready check
// ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The ready check ──────────────────────────────────────────────────────────────────────────────
/// `READY_CHECK` opens the popup naming the leader, and the two buttons answer opposite ways —
/// including the No button's argument-less call, which is the reference's own spelling.
/// `READY_CHECK` opens the popup naming the leader; Yes calls `ConfirmReadyCheck(1)`, No calls it
/// with no argument (`Blizzard_RaidUI.xml:710`, `:728`).
#[test]
fn the_ready_check_popup_opens_and_answers() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -907,7 +804,6 @@ fn the_ready_check_popup_opens_and_answers() {
"No passes no argument at all, and absent must mean not-ready"
);
// The raid ending ends the question.
s.fire_event("READY_CHECK", Vec::new());
assert!(visible(&s, "ReadyCheckFrame"));
push_raid(&mut s, Vec::new());
@@ -917,24 +813,9 @@ fn the_ready_check_popup_opens_and_answers() {
);
}
// 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).
///
/// The defect this pins is not in this file at all; it is the engine's, and the raid grid is
/// simply where it bites hardest. No release is fed once the OS pointer is outside the window, so
/// `UiScript::pointer_left_window` used to drop the gesture silently: `OnDragStop` never ran, this
/// pane's `MOVING_RAID_MEMBER` stayed set, the engine's one `StartMoving` slot stayed taken, and
/// the row followed the cursor around the screen from then on — swallowing every press aimed at
/// any row underneath it. "I moved one member and then could not move any other" is what that
/// looks like from a hand on the mouse.
///
/// The three things this asserts are the three the bug broke, in order of how visible they are:
/// the row goes home, the pane forgets it, and the next row still drags.
/// No release reaches a gesture once the OS pointer leaves the window, so
/// `UiScript::pointer_left_window` ends the drag: `OnDragStop` runs, the row goes home, and the
/// next row still drags.
#[test]
fn a_drag_carried_off_the_window_edge_ends_instead_of_gluing_the_row_to_the_cursor() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -976,7 +857,6 @@ fn a_drag_carried_off_the_window_edge_ends_instead_of_gluing_the_row_to_the_curs
"the row springs back to its slot — a drop on nothing is not a move"
);
// The row is no longer following anything.
s.mouse_move(400.0, 300.0);
s.tick(0.016);
s.mouse_move(500.0, 200.0);
@@ -987,7 +867,6 @@ fn a_drag_carried_off_the_window_edge_ends_instead_of_gluing_the_row_to_the_curs
"…and it stays there however far the cursor travels"
);
// And the next row drags normally, which is the thing the director actually lost.
let (gx, gy) = centre(&s, "RaidGroupButton8");
let (tx, ty) = centre(&s, "RaidGroup5Slot1");
s.mouse_button(gx, gy, "LeftButton", true);
@@ -1008,14 +887,8 @@ fn a_drag_carried_off_the_window_edge_ends_instead_of_gluing_the_row_to_the_curs
);
}
/// The whole gesture, through the REAL pointer path, three times over — press, cross the
/// threshold, travel, release — with the roster echo in between, exactly as `/partytest raid`
/// feeds it.
///
/// Its sibling `dragging_a_row_moves_swaps_or_springs_back` calls `RaidGroupButton_OnDragStop`
/// with the globals set by hand, which is the *landing* logic and nothing else: it never fires
/// `OnDragStart`, never calls `StartMoving`, never runs the hover sweep, and — the part that
/// mattered — never drags a SECOND row. This one does all four.
/// Three whole gestures through the real pointer path, with the roster echo `/partytest raid`
/// supplies in between.
#[test]
fn one_drag_does_not_cost_the_next_one() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1064,8 +937,7 @@ fn one_drag_does_not_cost_the_next_one() {
"drag {row_index} onto group {group}"
);
assert!(s.errors().is_empty(), "{from}: {:?}", s.errors());
// The sandbox echo `/partytest raid` supplies, so the next drag starts from a repainted
// grid rather than a frozen one.
// The echo, so the next drag starts from a repainted grid.
raid[row_index as usize - 1].subgroup = group - 1;
push_raid(&mut s, raid.clone());
}
File diff suppressed because it is too large Load Diff
@@ -1,28 +1,9 @@
//! 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
//! asserts the tuples, and stops at the seam. Everything below is about what the tuples become on
//! screen — which of the 15 fixed row slots a visible index lands in, whether a header slot draws
//! the plus or the minus art, the `FACTION_BAR_COLORS[standingID]` fill, the scroll offset
//! re-binding the slots, the popup's three boxes, and the watch bar's own text and posture.
//!
//! The fixture deliberately mirrors the engine module's own (Alliance over Ironforge + Stormwind,
//! 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.
//!
//! **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.
//! The stock Reputation tab and its watch bar, both `ReputationFrame.xml`, engine-only: what the
//! faction tuples become on screen.
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.
/// A visible bar row: `standing_id` 5 (Friendly), 1000 into a 6000-wide rank window.
fn entry(faction_id: u32, rep_list_id: u32, parent_id: u32, name: &str) -> FactionEntry {
FactionEntry {
faction_id,
@@ -42,8 +23,7 @@ fn entry(faction_id: u32, rep_list_id: u32, parent_id: u32, name: &str) -> Facti
}
}
/// A header row as the wire really delivers one: flag `0x08` set and NOT visible (the Steamwheedle
/// Cartel's actual byte). Its own visibility never gates its group.
/// A header as the wire delivers one: flag `0x08`, not visible, which never gates its group.
fn header(faction_id: u32, rep_list_id: u32, name: &str) -> FactionEntry {
FactionEntry {
visible: false,
@@ -52,19 +32,9 @@ fn header(faction_id: u32, rep_list_id: u32, name: &str) -> FactionEntry {
}
}
/// The engine module's own fixture, pushed out of order so the engine's sort is what shows. The
/// visible rows it produces, in order:
///
/// | slot | row |
/// |------|-----|
/// | 1 | header `Alliance` |
/// | 2 | `Ironforge` |
/// | 3 | `Stormwind` |
/// | 4 | header `Steamwheedle Cartel` |
/// | 5 | `Booty Bay` |
/// | 6 | header `Other` |
/// | 7 | `Argent Dawn` |
/// | 8 | `Bloodsail Buccaneers` |
/// `benilla-ui`'s own reputation fixture, pushed out of order so the sort shows. Visible slots:
/// 1 Alliance (header), 2 Ironforge, 3 Stormwind, 4 Steamwheedle Cartel (header), 5 Booty Bay,
/// 6 Other (header), 7 Argent Dawn, 8 Bloodsail Buccaneers.
fn state() -> ReputationState {
ReputationState {
entries: vec![
@@ -80,27 +50,16 @@ fn state() -> ReputationState {
}
}
/// The character window, whose third tab this page is — [`super::test_ui::CHARACTER_UI`], which
/// already carries `ReputationFrame.xml` (stock `CharacterFrame_ShowSubFrame` hides all five pages
/// by name, unguarded) and `ActionBar.xml` (the watch bar, and the show/hide pair's
/// `ShowWatchedReputationBarText`). This page adds nothing of its own.
///
/// It was a hand-copied manifest prefix until the window became the reference's (decision 1751):
/// stock `CharacterFrame_OnLoad` reaches into four other files before it does anything else, so the
/// list stopped being short enough for a per-module copy to stay honest.
/// The character window, [`super::test_ui::CHARACTER_UI`], which carries `ReputationFrame.xml`:
/// `CharacterFrame_ShowSubFrame` hides every page by name, unguarded (CharacterFrame.lua:25-33).
fn load_page(s: &UiScript) {
for file in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(s, file);
}
}
/// A level-`level` player behind the window — enough of one that opening it is quiet.
///
/// **Race and class carry both halves of their pairs**, which is not decoration since 1751:
/// `UnitRace`/`UnitClass` answer `(localized, file)` or `nil, nil` — the binding `zip`s the two —
/// and stock `PaperDollFrame_SetLevel` formats level, race and class into `CharacterLevelText`
/// unguarded (`PaperDollFrame.lua:100-104`), on every show of the window this page is a tab of.
/// A `UnitState::default()` player therefore raises the moment `ToggleCharacter` runs.
/// A player with both halves of race and class: `UnitRace`/`UnitClass` answer nil without the
/// pair, and `PaperDollFrame_SetLevel` formats them unguarded (PaperDollFrame.lua:101).
fn player(level: u32) -> UnitState {
UnitState {
exists: true,
@@ -125,8 +84,7 @@ fn shown_reputation_page() -> UiScript {
s
}
/// Click the centre of a named frame through the real pointer pipeline, the way the app does — so
/// a frame silently eating the row's clicks fails here rather than on the director's screen.
/// Click a named frame's centre through the pointer pipeline, so a frame eating the clicks fails.
fn click_center(s: &mut UiScript, name: &str) {
s.resolve();
let (x, y) = s
@@ -145,11 +103,8 @@ fn text_of(s: &mut UiScript, expr: &str) -> String {
.unwrap()
}
/// **`IsVisible`, not `IsShown`, is the right question for the scroll bar since 1860.** The
/// reference's `FauxScrollFrame_Update` hides the scroll FRAME when the list fits
/// (`scrollBar:SetValue(0); frame:Hide()`); our deleted kit hid the BAR itself through
/// `BenillaScrollBar_SetShown`. So the bar's own `IsShown` stays true under the reference and only
/// its hidden ancestor takes it off screen — which `IsVisible` reports and `IsShown` cannot.
/// `IsVisible`, not `IsShown`, for the scroll bar: when the list fits, `FauxScrollFrame_Update`
/// hides the scroll frame, not the bar (UIPanelTemplates.lua:169-170).
fn visible(s: &mut UiScript, name: &str) -> bool {
s.eval::<bool>(&format!("return {name}:IsVisible() and true or false"))
.unwrap_or(false)
@@ -160,10 +115,8 @@ fn shown(s: &mut UiScript, name: &str) -> bool {
.unwrap()
}
/// **The page is the character window's tab 3, and it comes up on it.** The whole renumber rides on
/// this: `ToggleCharacter` selects the row from the page's own `id=`
/// (ref `CharacterFrame.lua:11`), so a page seated at the wrong id shows the right pane under the
/// wrong tab. Skills moved to 4 in the same breath and is asserted here for the same reason.
/// `ToggleCharacter` selects the tab from the page's own `id=` (CharacterFrame.lua:10), and the
/// tabs open Reputation at 3 and Skills at 4 (CharacterFrame.lua:40-43).
#[test]
fn the_reputation_page_opens_on_the_windows_third_tab() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -179,11 +132,6 @@ fn the_reputation_page_opens_on_the_windows_third_tab() {
3,
"and the tab row selects 3 — the reference's own slot for Reputation"
);
// **The id IS the tab slot**, and that is the whole coupling: `ToggleCharacter` selects the tab
// with `PanelTemplates_SetTab(CharacterFrame, subFrame:GetID())`
// (`CharacterFrame.lua:10`), while `CharacterFrameTab_OnClick` hardcodes which page each tab
// opens (`:41-43` — tab 3 → Reputation, tab 4 → Skills). A page whose `id=` disagreed would
// open on click and light the wrong tab.
assert_eq!(
s.eval::<i64>("return ReputationFrame:GetID()").unwrap(),
3,
@@ -194,15 +142,8 @@ fn the_reputation_page_opens_on_the_windows_third_tab() {
4,
"and Skills is the reference's 4 beside it"
);
// **`CHARACTERFRAME_SUBFRAMES` is the hide-all list, not the tab map** — re-pointed here at
// 1751, because our deleted `CharacterFrame.xml` wrote the two as one thing and the
// reference's does not. Stock line 1 is
// `{ "PaperDollFrame", "PetPaperDollFrame", "SkillFrame", "ReputationFrame", "HonorFrame" }` —
// Skills THIRD and Reputation FOURTH, the opposite of their tab slots — and it is only ever
// walked by `CharacterFrame_ShowSubFrame`, which shows the one it was named and hides the rest
// (`CharacterFrame.lua:25-33`). So what this page needs from the list is membership: a page
// missing from it is a page nothing ever hides, which is the "one page at a time" assertion at
// the end of this test.
// `CHARACTERFRAME_SUBFRAMES` is the hide-all list, not the tab map (CharacterFrame.lua:1): a
// page missing from it is never hidden.
assert!(
s.eval::<bool>(
"for _, v in CHARACTERFRAME_SUBFRAMES do if v == \"ReputationFrame\" then return true end end return false"
@@ -211,30 +152,23 @@ fn the_reputation_page_opens_on_the_windows_third_tab() {
"the hide-all list names this page"
);
// Eight rows in fifteen slots: the tail goes dark on BOTH twins, and a list that fits raises
// neither the scroll bar nor its trough (the kit shows and hides the pair together).
// Eight rows in fifteen slots: slot 9 shows neither twin, and a fitting list has no scroll bar.
assert_eq!(s.eval::<i64>("return GetNumFactions()").unwrap(), 8);
assert!(
!shown(&mut s, "ReputationBar9"),
"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"));
// Switching away hides it again through the same one-page-at-a-time switch.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
assert!(!shown(&mut s, "ReputationFrame"), "one page at a time");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A header slot draws the fold icon its state calls for, and a click folds the group.**
///
/// Two laws in one: the minus/plus art is chosen from `isCollapsed` (ref `ReputationFrame.lua`
/// l.54-58), and the click has to REPAINT — `CollapseFactionHeader` is a pure model write in this
/// engine where the client's C binding raises `UPDATE_FACTION`, so a handler that only calls the
/// global leaves the folded rows on screen (ReputationFrame.xml's deviation 3).
/// The fold art follows `isCollapsed` (ReputationFrame.lua:54-58). The header's click only calls
/// the fold binding (ReputationFrame.xml:9-15), which raises `UPDATE_FACTION` as the reference's
/// does, and that event is the pane's repaint.
#[test]
fn a_header_row_paints_its_fold_icon_and_folds_on_click() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -280,16 +214,13 @@ fn a_header_row_paints_its_fold_icon_and_folds_on_click() {
"the two folded children leave the visible list"
);
// …and unfolding puts them straight back, through the same handler.
click_center(&mut s, "ReputationHeader1");
assert_eq!(text_of(&mut s, "ReputationBar2FactionName"), "Ironforge");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A bar row paints its name, its standing word and its `FACTION_BAR_COLORS[standingID]` fill**,
/// over a bar whose range is the rank window NORMALIZED — `barMax - barMin` and `barValue - barMin`
/// (ref `ReputationFrame.lua` l.79-82), which is the entire reason the engine reports those three
/// absolute.
/// The bar's range is the rank window normalized, `barMax - barMin` and `barValue - barMin`
/// (ReputationFrame.lua:79-82), which is why the binding reports all three absolute.
#[test]
fn a_bar_row_paints_name_standing_and_the_faction_bar_colour() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -321,8 +252,7 @@ fn a_bar_row_paints_name_standing_and_the_faction_bar_colour() {
"FACTION_BAR_COLORS[5] — the green every friendly-and-better rank shares"
);
// The hover swaps the standing word for the raw progress and lights the glow pair; leaving puts
// the word back (ref ReputationFrame.xml l.189-203).
// Hover shows progress and glow; leaving restores the word (ReputationFrame.xml:190-203).
assert!(
!shown(&mut s, "ReputationBar2Highlight1"),
"glow starts down"
@@ -354,9 +284,7 @@ fn a_bar_row_paints_name_standing_and_the_faction_bar_colour() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The 15 row slots are a window onto the list, and the scroll offset is what moves it.** The
/// faux kit re-binds fixed slots to a moving data offset rather than scrolling anything (decision
/// 0251), so this is the assertion that the slots really are re-bound and not just clipped.
/// The faux scroll kit re-binds the 15 fixed slots to a moving offset; nothing scrolls or clips.
#[test]
fn the_scroll_offset_rebinds_the_fixed_row_slots() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -385,11 +313,6 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() {
visible(&mut s, "ReputationListScrollFrameScrollBar"),
"21 rows in 15 slots raises the scroll bar"
);
// **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();
@@ -400,12 +323,8 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() {
assert_eq!(text_of(&mut s, "ReputationBar1FactionName"), "Faction 03");
assert_eq!(text_of(&mut s, "ReputationBar15FactionName"), "Faction 17");
// Past the end the REFERENCE does not clamp the offset — it guards the BINDING.
// `FauxScrollFrame_SetOffset` is two lines (`frame.offset = offset`), and the reference's own
// `ReputationFrame_Update` walks its slots under `if ( factionIndex <= numFactions )`, hiding
// the ones that fall off the end (ReputationFrame.lua). Our deleted kit clamped the offset
// itself, which is why this used to read back 6. A player cannot reach this state — the bar's
// range bounds a real scroll — so it is only ever an out-of-range programmatic ask (1860).
// Past the end the offset is stored unclamped (UIPanelTemplates.lua:239-241), and
// `ReputationFrame_Update` hides the slots past the list (ReputationFrame.lua:50,141-144).
s.run("FauxScrollFrame_SetOffset(ReputationListScrollFrame, 9) ReputationFrame_Update()")
.unwrap();
assert_eq!(
@@ -422,10 +341,8 @@ fn the_scroll_offset_rebinds_the_fixed_row_slots() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Clicking a bar opens the detail popup on that faction**, with its name, its description and
/// its three switches in the states the row's own flags call for — including the disabled, greyed
/// At War box a peace-forced faction gets (ref `ReputationFrame.lua` l.115-121). Clicking the same
/// bar again closes it, which is the reference's own toggle (l.152-153).
/// A peace-forced faction's At War box is disabled and greyed (ReputationFrame.lua:115-121), and
/// clicking the selected bar again closes the popup (ReputationFrame.lua:152-153).
#[test]
fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -433,7 +350,7 @@ fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
s.set_screen_size(1024.0, 768.0);
load_page(&s);
s.set_unit("player", Some(player(40)));
// Ironforge at war and peace-forced — the two flags the popup reads back off the row.
// Ironforge at war and peace-forced.
let mut st = state();
for e in &mut st.entries {
if e.name == "Ironforge" {
@@ -493,14 +410,11 @@ fn clicking_a_bar_opens_the_detail_popup_on_that_faction() {
assert!(!checked(&mut s, "ReputationDetailInactiveCheckBox"));
assert!(!checked(&mut s, "ReputationDetailMainScreenCheckBox"));
// The reference's toggle: the same bar again closes the popup rather than re-opening it.
click_center(&mut s, "ReputationBar2");
assert!(!shown(&mut s, "ReputationDetailFrame"), "clicked shut");
// **The whole ROW is the click target, not just the 137px bar.** The template's
// `HitRectInsets left="-126"` (ref `ReputationFrame.xml` l.61-63) is a NEGATIVE inset, which
// WIDENS the mouse rect back over the faction name — so clicking "Ironforge" selects it exactly
// as clicking its bar does. Driven from the name's own centre, which is outside the bar's rect.
// The whole row is the click target: `HitRectInsets left="-126"` (ReputationFrame.xml:61-63)
// widens the 137 px bar's mouse rect back over the faction name.
let name_x = s
.eval::<f32>(
"return (ReputationBar2FactionName:GetLeft() + ReputationBar2FactionName:GetRight()) / 2",
@@ -529,20 +443,9 @@ 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 how the Ironforge row drew.
///
/// 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 still draws), and a
/// toggleable one is the control that must not move.
/// The At War box has a `<NormalTexture>` and no `<DisabledTexture>`, and is disabled for a
/// peace-forced faction (ReputationFrame.lua:115-120). `SetState` (`0x779790`) hides the old
/// texture only when the new state has one, so the box stays drawn, under a grey tick at war.
#[test]
fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -553,9 +456,7 @@ fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
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;
@@ -582,7 +483,7 @@ fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
}
let up = "Interface\\Buttons\\UI-CheckBox-Up".to_string();
// Ironforge (row 2): peace-forced, at peace — the photographed case.
// Ironforge (row 2): peace-forced, at peace.
click_center(&mut s, "ReputationBar2");
assert_eq!(
s.eval::<i64>("return ReputationDetailAtWarCheckBox:IsEnabled()")
@@ -596,7 +497,7 @@ fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
"and it still draws its box — the sticky shown texture"
);
// Stormwind (row 3): peace-forced and at war — box plus the grey disabled tick.
// Stormwind (row 3): peace-forced and at war.
click_center(&mut s, "ReputationBar3");
assert_eq!(
box_art(&s),
@@ -607,7 +508,7 @@ fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
"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.
// Booty Bay (row 5), the control: toggleable.
click_center(&mut s, "ReputationBar5");
assert_eq!(
s.eval::<i64>("return ReputationDetailAtWarCheckBox:IsEnabled()")
@@ -618,13 +519,8 @@ fn the_at_war_box_keeps_its_art_while_it_is_disabled() {
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
/// art; at max level it REPLACES the XP bar, 13px tall on MainMenuBar's own top, wearing the dwarf
/// art instead so the swap reads as the same bar (ref `ReputationFrame.lua` l.184-230). The rested
/// tick goes with the XP bar it rode, which is one of the two guards that were cut from
/// `ExhaustionTick_Update` as unreachable while this bar could never show.
/// Below 60 the watch bar stacks on the XP bar, 8 px in its own end art; at 60 it replaces it,
/// 13 px in the dwarf art, and the rested tick hides (ReputationFrame.lua:184-230).
#[test]
fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -674,12 +570,8 @@ fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() {
8.0
);
// **`UIParent_ManageFramePositions`'s `reputation` branch fires for the first time.** It has
// been live in UIParent.xml since decision 0272 and gated on
// `ReputationWatchBar:IsShown() and MainMenuExpBar:IsShown()`, which that file's own header
// notes "still cannot fire" — it can now, and this is the check that it does. `PETACTIONBAR_YPOS`
// is the cheapest witness: an `isVar` row the pass writes as a plain global (baseY 97, plus the
// row's own `reputation = 9`), so it needs no frame loaded to read back.
// A stacked bar makes the manage pass add each row's `reputation` offset (UIParent.lua:1618):
// `PETACTIONBAR_YPOS`, a plain global, is baseY 97 plus 9 (UIParent.lua:1589).
assert_eq!(
s.eval::<f64>("return PETACTIONBAR_YPOS").unwrap(),
106.0,
@@ -736,34 +628,20 @@ fn the_watch_bar_shows_the_watched_factions_progress_and_swaps_at_max_level() {
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.
/// The "Show as Experience Bar" box calls `SetWatchedFactionIndex`, then
/// `ReputationWatchBar_Update` (ReputationFrame.xml:839-848), but watching is not optimistic: the
/// index is the server field `PLAYER_FIELD_WATCHED_FACTION_INDEX`, with no client mirror
/// (`0x4d6b60`), so the bar comes up only when the descriptor update lands.
#[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.
// The checkmark anchors past `GetStringWidth()`, which reads 0 with no measurer installed: a
// stand-in font, 6 units a character.
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.
// Ironforge: visible row 2, reputation slot 20.
click_center(&mut s, "ReputationBar2");
assert!(shown(&mut s, "ReputationDetailFrame"), "the popup opened");
assert!(
@@ -776,8 +654,8 @@ fn the_show_as_experience_bar_box_sends_the_watch_and_the_server_brings_the_bar_
"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.
// The box ticks before its handler runs, as in the reference, so the handler takes the
// `GetChecked()` branch that watches.
click_center(&mut s, "ReputationDetailMainScreenCheckBox");
assert_eq!(
s.take_reputation_sends(),
@@ -806,8 +684,7 @@ fn the_show_as_experience_bar_box_sends_the_watch_and_the_server_brings_the_bar_
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.
// The watched row grows a checkmark past the name's string width (ReputationFrame.lua:96-103).
assert!(
shown(&mut s, "ReputationBar2Check"),
"the watched row wears the checkmark"
@@ -821,15 +698,14 @@ fn the_show_as_experience_bar_box_sends_the_watch_and_the_server_brings_the_bar_
"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.
// "Ironforge" is nine characters: 54 at 6 a character.
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.
// Untick: stock passes 0 for none, and the send is `None`, not slot 0, which is a real faction.
click_center(&mut s, "ReputationDetailMainScreenCheckBox");
assert_eq!(
s.take_reputation_sends(),
+59 -232
View File
@@ -1,19 +1,9 @@
//! What a UI change costs on the **full shipped UI** — the headless twin of the live hover
//! recorder (`hover_log`), and the loop that made the tooltip-hover cost reproducible without a
//! play session.
//!
//! The live runs said a hover frame that ran the layout fixpoint cost ~10 ms against ~0.2 ms for
//! one that did not, but neither the synthetic tooltip probes nor the shipped `BagFrame.xml` hover
//! loop reproduced it: with content that does not MOVE, the change gate absorbs everything. The
//! missing ingredient is that a real hover sweep shows a *different item every frame* — line widths
//! that actually change. That is what [`tooltip_change_costs_a_whole_ui_solve`] drives, and with it
//! the cost reproduces headlessly and can be measured against a fix.
//! What a UI change costs on the full shipped UI, headless: the solves, gate walks and graph
//! derivations a tooltip hover or a moving region pays.
//!
//! Run with `cargo test --release -p benilla-app --lib -- --ignored --nocapture resolve_bench`;
//! `WOW_LAYOUT_PROF=1` adds the per-solve shape — and since decision 1350 its `solved=`/`swept=`
//! columns are the ones to read: they are the SCOPE of the solve, and 1350's whole claim is that
//! they stay a handful while `frames=`/`anchored=` grow without bound. A solve whose scope tracks
//! the graph again is precisely the regression this file exists to catch.
//! `WOW_LAYOUT_PROF=1` adds each solve's shape, whose `solved=`/`swept=` scope should stay small
//! while `frames=`/`anchored=` grow.
use std::time::Instant;
@@ -23,8 +13,7 @@ fn ms(t: Instant) -> f64 {
t.elapsed().as_secs_f64() * 1000.0
}
/// The deterministic stand-in for the app's font engine: every string has one size, so a measure
/// answer is stable across frames and only real text changes move the layout.
/// A deterministic font engine: 6 units a character, so only a real text change moves the layout.
fn answer_measures(s: &mut UiScript) -> usize {
let reqs = s.fontstrings_needing_measure();
let n = reqs.len();
@@ -45,14 +34,11 @@ fn answer_measures(s: &mut UiScript) -> usize {
n
}
/// The full shipped UI, loaded and settled — the graph a real hover actually pays for.
/// The full shipped UI, loaded and settled.
fn settled_default_ui() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
// The in-game UI materializes on world entry (1051), so a player always exists by the time the
// manifest loads — and the stock macro window's character tab formats `UnitName("player")`
// into its label inside its own OnLoad. A manifest load with no player is a state the client
// never reaches (decision 1848).
// The UI loads on world entry, so a player always exists by then.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -73,27 +59,21 @@ fn settled_default_ui() -> UiScript {
s
}
/// One frame in the app's own order (`extract::tick_script`): measure FIRST, then resolve.
/// One frame in the app's 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
/// `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
/// test asking "is anything in the shipped UI writing layout on its own?" has to let the shipped
/// UI actually run, or it answers a question nobody asked (decision 1385).
/// A frame that also ticks the VM, so the shipped UI's own `OnUpdate` handlers run, in
/// `tick_script`'s order: tick, measure, resolve.
fn app_frame_ticked(s: &mut UiScript, dt: f32) {
s.tick(dt);
answer_measures(s);
s.resolve();
}
/// A tooltip whose lines change width every call — the hover sweep across a bag grid, where each
/// slot holds a different item.
/// A tooltip whose lines change width every call, as in a hover sweep across a bag grid.
fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) {
s.run(&format!(
r#"
@@ -147,20 +127,14 @@ fn tooltip_change_costs_a_whole_ui_solve() {
);
}
/// The gate the measure-first order exists to hold: a tooltip content change costs the frame
/// **one** layout solve, not two.
///
/// The old order resolved, then measured, then had to resolve AGAIN because the answers moved the
/// anchor solve's read set — and on the shipped UI each of those walks 2,164 frames and sweeps
/// 6,297 regions per round, for a change that touches ten FontStrings inside one frame. Nothing
/// about the second solve is tooltip-sized, and nothing about it is cheap.
/// Measuring before resolving makes a tooltip content change cost one layout solve; measured
/// after, the answers would force a second whole-UI solve.
#[test]
fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
install_changing_tooltip(&s, "GateOwner", "gate_change");
// Settle the newly-created owner + the tooltip's first content.
for _ in 0..4 {
s.run("gate_change()").unwrap();
app_frame(&mut s);
@@ -177,22 +151,15 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
"10 content changes must cost 10 solves — one each. Two per change means the measure \
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`
// guards, and it is worth asserting separately because it arrives by a different road: a
// hover sweep churns tooltip CONTENT, so its per-frame writes are measure answers and
// re-anchors of pooled line regions rather than one moving texture. The bag-hover re-enter
// loop is the most common interactive path in the client; a derivation per frame here is
// 1.48 ms every frame the cursor sits over a bag.
// Nor may they derive the graph: a content change writes measure answers and re-anchors
// pooled line regions, nothing structural.
assert_eq!(
s.layout_derivations() - derives_before,
0,
"a settled tooltip whose CONTENT changes must not re-derive the layout graph — the line \
pool is already built, so nothing structural is happening (decision 1388)"
);
// …and the measure answers must have landed in that one solve: a frame that resolved before
// measuring leaves the fresh text unmeasured until the next frame, which is the visible half
// of the same bug (a tooltip plate one frame behind its content).
// The answers landed in that one solve, or the plate would lag its text by a frame.
assert_eq!(
answer_measures(&mut s),
0,
@@ -200,36 +167,20 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
);
}
/// **The idle law** (decision 1385): the settled shipped UI, with nothing happening, must cost
/// **zero** gate walks per frame — not one cheap one, zero.
///
/// The other guards in this file pin what the ENGINE charges for a change. This one pins that
/// nothing in the shipped UI is quietly making a change every frame in the first place, which is
/// the half a law about the engine cannot see. It is the tripwire for a new always-on toucher
/// being added to `assets/ui/` — the worst shape of this bug, because it costs on *every* frame of
/// *every* session rather than only while something is animating. The sweep behind 1385 found real
/// candidates for it (`TemporaryEnchantFrame`'s OnUpdate is resident for the whole session; the
/// measure round-trip used to bump the epoch on every same-size re-measure), so the shape is not
/// hypothetical.
///
/// A failure here does not name the culprit — `WOW_LAYOUT_TOUCH_TRACE=<secs>:<n>` does, by
/// backtracing the touch sites on a live run.
/// With nothing happening, the settled shipped UI costs zero gate walks per frame: this catches a
/// shipped handler writing a layout input every frame. `WOW_LAYOUT_TOUCH_TRACE=<secs>:<n>` names
/// the writer on a live run (`docs/CONTRIBUTING.md`, "Running it unattended").
#[test]
fn the_settled_shipped_ui_costs_no_gate_walk_on_a_quiet_frame() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
// Ticked frames, so the shipped UI's own OnUpdate handlers run — the whole point (see
// `app_frame_ticked`). A couple of frames of grace first: `settled_default_ui` stops as soon
// as the measure round-trip goes quiet, which is one edge earlier than the layout gate closing
// behind it, and the first ticked frames arm handlers that have never run.
// Grace frames: the layout gate closes one edge after measuring goes quiet, and the first
// ticks arm handlers that have never run.
for _ in 0..8 {
app_frame_ticked(&mut s, 1.0 / 60.0);
}
// POSITIVE CONTROL. "Zero walks" only means anything if the shipped UI's handlers actually
// ran — a tick that silently fired nothing would also read zero, and would make this test a
// green light for exactly the regression it exists to catch. `BuffFrameUpdateTime` is advanced
// by `BuffFrame_OnUpdate` on every frame (down by `elapsed`, or up by TOOLTIP_UPDATE_TIME when
// it crosses), so one tick must move it.
// Positive control: zero walks means nothing unless the handlers ran, and
// `BuffFrame_OnUpdate` moves `BuffFrameUpdateTime` every frame (`BuffFrame.lua:29-33`).
s.run("__probe_bfut = BuffFrameUpdateTime")
.expect("read the control");
app_frame_ticked(&mut s, 1.0 / 60.0);
@@ -253,25 +204,10 @@ fn the_settled_shipped_ui_costs_no_gate_walk_on_a_quiet_frame() {
);
}
/// **The castbar law** (decision 1385, ledger B283), on the full shipped UI: a region that moves
/// every frame must cost **one** whole-roster gate walk per frame.
///
/// This is the guard for the bug class 1383 named and the castbar then hit. `CastingBar.xml`'s
/// OnUpdate slides `CastingBarSpark` one `SetPoint` per frame for the length of every cast — the
/// reference's own architecture, and the classic addon idiom besides. Measured live at the
/// Stormwind gates it cost **+4.2 ms of CPU per frame** on a default UI: three walks per frame at
/// ~1.0–1.4 ms each (10,438 anchored regions re-hashed every walk), because the fingerprint was
/// hashed over the 0294 seeds as well as the inputs and so no walk could ever close tier 1.
///
/// Three things make this the falsifier rather than a smoke check:
/// * it runs on the **shipped** UI, so the whole-roster term is real — a synthetic two-frame model
/// shows the same *counts* and none of the cost;
/// * the Lua body ends in a frame getter, because that is what a live tick does. A later handler's
/// `GetWidth` forces the synchronous mid-tick solve; modelling the frame as a lone `resolve()`
/// hides two of the three walks;
/// * it asserts on `layout_gate_walks`, not `layout_solves`. A walk that concludes "nothing moved"
/// pays the identical preamble and never reaches the solve counter — one of the castbar's three
/// walks was exactly that, so the old counter under-reported the bug by a third.
/// A region moving every frame, as `CastingBarFrame_OnUpdate` moves the spark
/// (`CastingBarFrame.lua:114`), costs one whole-roster gate walk per frame on the shipped UI. The
/// Lua ends in a frame getter, as a later handler in a live tick would, forcing the mid-tick solve;
/// the count is walks, since a walk that finds nothing moved never reaches the solve counter.
#[test]
fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
benilla_formats::wow_data_or_skip!();
@@ -296,7 +232,7 @@ fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
)
.unwrap();
// Settle the newly-created bar and spark — a birth is legitimately a wide, multi-walk solve.
// Settle the new bar and spark: a birth is a wide, multi-walk solve.
for _ in 0..6 {
s.run("bench_spark_frame()").unwrap();
app_frame(&mut s);
@@ -329,34 +265,10 @@ fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
);
}
/// **The castbar law, part two** (decision 1388): those ten gate walks must cost **zero**
/// derivations of the layout graph.
///
/// 1385 got the moving spark from three whole-roster walks per frame down to one, and the test
/// above is its guard. It did nothing about what the surviving walk *costs*: measured at the
/// Stormwind pin (3,218 frames, 10,438 anchored regions) the preamble ran 1.48 ms, 79% of it in two
/// phases — re-hashing every anchored region (919 µs) and re-filtering every seed rect for liveness
/// (255 µs) — to rediscover a graph whose SHAPE had not changed. At 144 fps that is a fifth of the
/// frame, on every frame of every cast, and it is paid by anything that animates: floating combat
/// text moves up to twenty strings a frame, and a frame getter inside an OnUpdate (`GetWidth` calls
/// `settle`) buys another one each.
///
/// So the counter this asserts on is not `layout_gate_walks` but `layout_derivations`. A moving
/// region names its node, the ledger vouches for the cached roster and edges, and the resolve
/// re-hashes one node instead of 13,656. Zero is the whole claim: **one** derivation per frame
/// would mean a write site somewhere fell back to the conservative `touch_layout` and the ledger
/// is being poisoned every frame, which reads as a perfectly healthy walk count and costs the
/// entire 1.48 ms.
///
/// The positive control is not optional here. "Zero derivations" is exactly what a permanently
/// broken counter also reports, so the test first proves the counter can move — a frame BIRTH is
/// structural by construction and must derive.
///
/// It ticks the VM (`app_frame_ticked`) rather than driving the resolve alone, so the shipped UI's
/// own `OnUpdate` handlers run on every one of these frames. That is the difference between "one
/// synthetic write can use the ledger" and "the ledger survives the real UI": a single handler
/// anywhere in `assets/ui/` falling back to a conservative touch every frame would poison it for
/// everything else, and only a ticked frame can see that.
/// Those walks cost zero derivations of the layout graph: a moving region names its node, so the
/// resolve re-hashes one node, not the roster; a derivation per frame means a write site fell back
/// to the conservative `touch_layout`. The VM ticks, so the shipped handlers are held to it too,
/// and a birth must derive, which proves the counter moves.
#[test]
fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
benilla_formats::wow_data_or_skip!();
@@ -378,9 +290,7 @@ fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
)
.unwrap();
// The positive control, taken across the birth above: a new frame and a new region move the
// roster, so they MUST derive. If this reads zero the counter is dead and the real assertion
// below proves nothing.
// Positive control: a birth moves the roster, so it must derive.
let born_at = s.layout_derivations();
for _ in 0..6 {
s.run("bench_spark2_frame()").unwrap();
@@ -417,23 +327,15 @@ fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
);
}
/// The perf half of decision 1350, asserted as a COUNT: a tooltip content change must cost a
/// solve whose SCOPE is tooltip-sized, on a UI of 3,000 frames and 8,800 anchored regions.
///
/// Milliseconds are deliberately not the gate here. This exact cost has now been chased three
/// times — 0735 (the measure cache), 0771 (the double solve), and this — and on two of those the
/// ms column was contaminated by machine state (0713's stall class) while the work counts were
/// clean. The bound is also what makes the regression *reportable*: 1350's pin found the sweep's
/// cost tripling with no counter behind it, because `regions_swept` counted the entries the sweep
/// skipped as well as the ones it resolved.
/// A tooltip content change solves a tooltip-sized scope on the full shipped UI, gated on counts,
/// not milliseconds, which machine state skews.
#[test]
fn a_tooltip_content_change_solves_a_tooltip_sized_scope() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
install_changing_tooltip(&s, "ScopeSizeOwner", "scope_size_change");
// Settle the newly-created owner and the tooltip's first content — a birth is legitimately a
// wide solve (a new node has no cached rect to trust).
// Settle the new owner: a birth is a wide solve, with no cached rect to trust.
for _ in 0..6 {
s.run("scope_size_change()").unwrap();
app_frame(&mut s);
@@ -451,15 +353,9 @@ fn a_tooltip_content_change_solves_a_tooltip_sized_scope() {
}
}
/// The scoped resolve's falsifier (decision 1350): on the shipped UI, mid-sweep, a solve that
/// touched only the dirty closure must produce **exactly** the rects a from-scratch whole-graph
/// solve produces.
///
/// This is the gate for the whole change, and it is deliberately run on the frames that never
/// settle — a hover sweep changes content every frame, so `WOW_LAYOUT_VERIFY`'s settled-frame
/// comparison never fires there, and those are the only frames the scope was built for. The
/// comparison is `extract()`, not an internal rect map: a stale rect that no quad carries is not a
/// bug, and a stale rect that one does carry is the bug in the form the screen would show it.
/// Mid-sweep, a scoped solve yields exactly the quads a from-scratch whole-graph solve does. It
/// runs on frames that never settle, where `WOW_LAYOUT_VERIFY`'s settled-frame check never fires,
/// and compares `extract()`, so only a stale rect that paints counts.
#[test]
fn a_scoped_resolve_reproduces_the_whole_graph_solve() {
benilla_formats::wow_data_or_skip!();
@@ -467,7 +363,6 @@ fn a_scoped_resolve_reproduces_the_whole_graph_solve() {
install_changing_tooltip(&s, "ScopeOwner", "scope_change");
for step in 0..12 {
// A content change, resolved the way the app resolves it — scoped.
s.run("scope_change()").unwrap();
app_frame(&mut s);
let scoped = s.extract();
@@ -487,12 +382,8 @@ fn a_scoped_resolve_reproduces_the_whole_graph_solve() {
}
}
/// The steady-state cost of the per-frame measure sweep alone: the UI settled, every FontString
/// measured, every key a cache hit — what does *asking* cost? It is paid on EVERY frame, quiet
/// ones included, which made it a suspect on 1350's successor list — and this bench closed it:
/// 0.046 ms/sweep on the full shipped UI in release (2026-08-15; the ~1.0 ms once quoted was a
/// dev-build number). Kept as the lane's regression watch: if this climbs toward a milli-
/// second, the sweep's per-row clones/hash grew or the cache stopped hitting.
/// The per-frame measure sweep's steady-state cost, every key a cache hit: 0.046 ms a sweep in
/// release. Near a millisecond, the per-row clones or hash grew or the cache stopped hitting.
#[test]
#[ignore]
fn measure_sweep_steady_state_cost() {
@@ -514,26 +405,9 @@ fn measure_sweep_steady_state_cost() {
);
}
/// **The hover-shape law** (decision 1388, ledger B06): a tooltip line that changes ROLE between
/// WRAPPED and PLAIN must cost **zero** derivations of the layout graph.
///
/// `a_tooltip_content_change_costs_exactly_one_layout_solve` above asserts the same zero and could
/// not see this, because the tooltip it drives always has the same SHAPE — the same lines, the
/// same wrap flags, only the widths move. A real hover sweep changes shape on every step: one
/// item's line 2 is a wrapping "Use:" description, the next one's is a plain "Main Hand", and a
/// spell's line 2 is a plain mana cost where the next spell's is a wrapping tooltip body.
///
/// `append_line` re-pins the line's wrap column from that flag on every append, and the write is
/// gated on a real change — so it fires exactly on the shape flip and nowhere else. It reached the
/// conservative `Model::touch_layout` (it predates 1388 and was never migrated, being an internal
/// write rather than a `SetWidth` binding), and the whole graph was therefore re-derived on every
/// hover from one item to a differently-shaped one: every live frame's scale re-synced, every seed
/// rect re-filtered, every anchored region re-hashed and its edges rebuilt. Live at 12,465
/// anchored regions the preamble read `incr=0` on every content-change frame.
///
/// The positive control is not optional: "zero derivations" is what a permanently broken counter
/// reports too, so the flip is driven for a while BEFORE the window opens (a birth is legitimately
/// structural) and the counter is proved able to move.
/// A tooltip line flipping between wrapped and plain costs zero derivations: `tooltip::append_line`
/// re-pins the wrap column only on a real change, and names its region. A real hover changes
/// shape (a wrapped "Use:" line, then a plain "Main Hand"), which a content-only change never does.
#[test]
fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
@@ -561,8 +435,7 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
)
.unwrap();
// The positive control, taken across the owner's birth and the line pool's growth — both move
// the roster, which is the one thing a per-node ledger cannot describe, so both MUST derive.
// Positive control: the owner's birth and the line pool's growth move the roster and derive.
let born_at = s.layout_derivations();
for _ in 0..8 {
s.run("flip_change()").unwrap();
@@ -589,23 +462,8 @@ fn a_tooltip_line_flipping_wrapped_to_plain_costs_no_graph_derivation() {
);
}
/// **The hover-sweep law** (decision 1625, ledger B06): sweeping the cursor across a bag grid or a
/// spellbook page — a NEW tooltip OWNER on every step — must cost **zero** derivations of the
/// layout graph.
///
/// This is the shape the director's report actually has, and it is the one the guard above still
/// could not see: that one keeps a single owner and only changes the tooltip's content. A real
/// sweep does both, and the owner half is the more expensive of the two, because
/// `GameTooltip:SetOwner` RETARGETS the plate's anchor at the button under the cursor. 1388
/// classified a retarget as structural — an edge disappears and another appears, which no per-node
/// hash can describe — and answered it by throwing the cached graph away. That answer cost a full
/// whole-roster derivation on every slot crossed. 1625 keeps the graph and re-points the one
/// node's edges instead.
///
/// The correctness half of that patch is guarded where it can be *seen*, on rects, by
/// `benilla-ui`'s `a_retargeted_anchor_follows_its_new_target` (and by `WOW_LAYOUT_VERIFY` behind
/// every one of that crate's tests). This one guards the COST, on the shipped UI, where the roster
/// is big enough for the derivation to matter.
/// A sweep across owners (a new `SetOwner` target every step, as across a bag grid) costs zero
/// derivations: the retarget re-points the one node's edges instead of discarding the graph.
#[test]
fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
@@ -635,8 +493,7 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
)
.unwrap();
// The positive control: twelve owner births and the tooltip's line pool growing are all
// roster moves, so the settling pass MUST derive. Zero here would make the assertion vacuous.
// Positive control: twelve births and the line pool's growth move the roster and derive.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("sweep_change()").unwrap();
@@ -648,8 +505,7 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
here means `layout_derivations` never moves and the assertion below proves nothing."
);
// Two full laps of the twelve owners, so every step is a re-hover of a button the graph
// already knows: nothing structural is left to discover.
// Two laps of the twelve owners: every step re-hovers a button the graph already knows.
let derives_before = s.layout_derivations();
for _ in 0..24 {
s.run("sweep_change()").unwrap();
@@ -665,23 +521,10 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
);
}
/// **The action-bar hover law** (decision 1630, ledger B06): sweeping the cursor across ACTION BAR
/// buttons must cost **zero** derivations of the layout graph.
///
/// 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` (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
/// (`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
/// live probe: the director's own recording is what named it, every derive frame owned by a
/// `MultiBarBottomLeftButton*` or `BonusActionButton*`. The lesson worth keeping is in the guards
/// as much as the fix: a hover guard that only drives one of two anchor idioms is testing the
/// idiom, not the gesture.
/// An action-bar hover sweep costs zero derivations. With `UberTooltips` at its 1.12 default "1",
/// an action button anchors through `GameTooltip_SetDefaultAnchor` (`ActionButton.lua:365-367`):
/// `SetOwner(owner, "ANCHOR_NONE")`, which drops the tooltip's anchors (`0x52fe90` reaches
/// `0x52fec2 call 0x767ed0` for every mode but PRESERVE), then an explicit `SetPoint`.
#[test]
fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
@@ -713,7 +556,7 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
)
.unwrap();
// Positive control across the births and the line pool's growth — both structural.
// Positive control: the births and the line pool's growth are structural.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("bar_hover()").unwrap();
@@ -739,26 +582,10 @@ fn an_action_bar_hover_sweep_costs_no_graph_derivation() {
);
}
/// **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.
/// A hover that owns the tooltip with an anchor, then clears and re-points it by hand, costs zero
/// derivations: the bag-addon idiom of `SetOwner(item, "ANCHOR_RIGHT")`, then `ClearAllPoints()`,
/// `GetLeft()` and `SetPoint`. Clearing live anchors is a retarget onto the empty set, and the
/// `GetLeft()` settles the layout inside the handler.
#[test]
fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
benilla_formats::wow_data_or_skip!();
@@ -796,7 +623,7 @@ fn a_bag_addon_hover_sweep_costs_no_graph_derivation() {
)
.unwrap();
// Positive control across the births and the line pool's growth — both structural.
// Positive control: the births and the line pool's growth are structural.
let born_at = s.layout_derivations();
for _ in 0..40 {
s.run("bag_hover()").unwrap();
@@ -1,10 +1,5 @@
//! 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
//! both are pinned here: the ordinary press (nothing is shown until the engine reports back, which
//! it can only do after the readback) and the double press inside the 1.5 s fade (the previous
//! shot's line is still up, and `TakeScreenshot` takes it down *before* asking).
//! The stock screenshot status line (`WorldFrame.xml`'s `ScreenshotStatus`), which must never be
//! in the picture it announces.
use benilla_ui::script::UiScript;
@@ -45,9 +40,7 @@ fn alpha(s: &UiScript) -> f64 {
s.eval::<f64>("return ScreenshotStatus:GetAlpha()").unwrap()
}
/// **The bug, as a test.** A press asks the engine and shows NOTHING; the line appears only when
/// the engine reports back, which in the real client is frames later — so the frame that was
/// captured cannot contain it.
/// The line appears only on the engine's answer, frames after the capture.
#[test]
fn the_capture_is_asked_for_silently_and_only_the_answer_speaks() {
benilla_formats::wow_data_or_skip!();
@@ -72,9 +65,7 @@ fn the_capture_is_asked_for_silently_and_only_the_answer_speaks() {
assert_eq!(alpha(&s), 1.0);
}
/// **The second leak path, and the reason `TakeScreenshot` hides rather than waiting for the
/// fade.** Press again while the last confirmation is still on screen and it comes off BEFORE the
/// engine is asked — otherwise that shot would have "Screen Captured" printed across it.
/// `TakeScreenshot` hides a line still fading before it calls `Screenshot()` (`WorldFrame.lua:47`).
#[test]
fn a_second_press_inside_the_fade_clears_the_line_before_capturing() {
benilla_formats::wow_data_or_skip!();
@@ -91,12 +82,12 @@ fn a_second_press_inside_the_fade_clears_the_line_before_capturing() {
);
assert_eq!(s.take_screenshot_asks(), 1);
// The new answer restarts the fade at full rather than inheriting the old alpha.
// The new answer restarts at full alpha (`WorldFrame.lua:60`).
s.fire_event("SCREENSHOT_SUCCEEDED", Vec::new());
assert_eq!(alpha(&s), 1.0);
}
/// The 1.5 s fade: alpha falls with elapsed time and the frame takes itself off screen at the end.
/// `SCREENSHOT_STATUS_FADETIME` is 1.5 s (`WorldFrame.lua:44`).
#[test]
fn the_line_fades_out_over_the_reference_s_second_and_a_half() {
benilla_formats::wow_data_or_skip!();
@@ -114,7 +105,6 @@ fn the_line_fades_out_over_the_reference_s_second_and_a_half() {
assert!(!shown(&s), "and it is gone at the end of the fade");
}
/// The failure path says so, in the reference's own words.
#[test]
fn a_failed_capture_shows_the_failure_string() {
benilla_formats::wow_data_or_skip!();
+23 -91
View File
@@ -1,45 +1,14 @@
//! **What the host remembers about the VM, and how it forgets.**
//!
//! The UI VM does not live for the process — it is built at world entry and destroyed at the
//! character screen, once per login (decision 1290, the reference's `0x48fbf0` ↔ `0x490bd0`). That
//! makes every host-side "I already told the VM about this" a claim with an expiry date, and there
//! are two kinds of them:
//!
//! - **seeds** — a registry, a catalog, a keybinding set: pushed once, because pushing it is
//! expensive and its content does not change. A `bool` latch, or a `PostStartup` system, which is
//! the same latch written in the scheduler.
//! - **change memos** — "these are the action slots I last pushed", so an unchanged frame costs
//! nothing. A `Local<FeedMemory>`, near-universally.
//!
//! Both are *correct* against one VM and *silently wrong* against the next, and the failure has no
//! error path: the push simply does not happen, and the window is empty. Session 2's action bar,
//! bags, spellbook, keybinds and macros all failed exactly this way, and one of them — the CVar
//! table — failed worse than empty: `save_config` composes `config.toml` from the VM's snapshot, so
//! an unseeded VM would have written the player's settings file back out **stripped**.
//!
//! [`VmMemo`] is the one mechanism for both. It keys the memory on
//! [`UiScript::session`] — a VM-side identity, not a host-side counter someone must remember to
//! bump — so a memory written against a dead VM cannot be read at all. The failure mode inverts:
//! forgetting to use it is visible (the memo is a plain `Local` again), while using it and being
//! wrong costs one redundant push per login.
//! Host-side memory about the UI VM, which lives for one login: the reference builds it at world
//! entry and destroys it at the character screen (`0x48fbf0`, `0x490bd0`). A seed or change memo
//! kept past its VM silently skips the push into the next one; [`VmMemo`] keys it on
//! [`UiScript::session`], so against a new VM it reads as fresh.
use benilla_ui::script::UiScript;
/// A host-side memory about the UI VM — **valid only for the VM it was written against**.
///
/// Wrap the memo type and read it through [`VmMemo::get`]; against a VM other than the one that
/// last wrote it, the memo resets to `T::default()` first. The wrapped type is unchanged, so the
/// call sites keep their own shape:
///
/// ```ignore
/// mut memory: Local<VmMemo<FeedMemory>>,
/// // …
/// let memory = memory.get(&script);
/// if memory.pushed.get(&id) != Some(&slot) { /* push */ }
/// ```
/// A host-side memo valid only for the VM that wrote it: [`VmMemo::get`] resets it to
/// `T::default()` when read against another.
pub(crate) struct VmMemo<T> {
/// The VM this memory is about. `0` is "no VM" — [`UiScript::session`] hands out from 1, so a
/// freshly defaulted memo matches nothing, and the first read of every session is a miss.
/// The VM this memory is about; 0 is no VM, as [`UiScript::session`] counts from 1.
session: u64,
inner: T,
}
@@ -54,26 +23,19 @@ impl<T: Default> Default for VmMemo<T> {
}
impl<T: Default> VmMemo<T> {
/// The memory — **cleared first if this is a different VM** than the one that wrote it.
/// The memory, cleared first against a different VM.
pub(crate) fn get(&mut self, script: &UiScript) -> &mut T {
self.get_for(Some(script))
}
/// [`VmMemo::get`] for a system that also runs with **no VM** — the character screen, where the
/// session's Lua state does not exist (1290).
///
/// "No VM" is a session in its own right, and it is session `0`: the memory resets once on the
/// way into it and once on the way out, and holds in between. Reaching for a scratch default
/// each frame instead would quietly turn the memo off for the whole glue phase.
/// [`VmMemo::get`] for a system that also runs with no VM, at the character screen; no VM is
/// session 0, so the memo holds there too.
pub(crate) fn get_for(&mut self, script: Option<&UiScript>) -> &mut T {
self.get_reset_for(script).0
}
/// [`VmMemo::get`], also reporting whether the memory RESET on this read — i.e. this is the
/// first read against a new VM. A gated feed (decision 1439) keys its "must run" on exactly
/// this: with every input unchanged, a fresh VM still needs the full re-push, and the reset is
/// the only signal that says so. The flag is true at most once per session per memo, so a gate
/// that ORs it in costs nothing on the steady frames it exists to skip.
/// [`VmMemo::get`], plus whether this read reset it: a gated feed must re-push into a fresh VM
/// even when every input is unchanged.
pub(crate) fn get_reset(&mut self, script: &UiScript) -> (&mut T, bool) {
self.get_reset_for(Some(script))
}
@@ -90,8 +52,7 @@ impl<T: Default> VmMemo<T> {
}
impl VmMemo<bool> {
/// **True exactly once per VM** — the seed shape: `if seeded.claim(&script) { …push the
/// registry… }`.
/// True exactly once per VM, for a seed pushed once per login.
pub(crate) fn claim(&mut self, script: &UiScript) -> bool {
let done = self.get(script);
if *done {
@@ -107,8 +68,6 @@ impl VmMemo<bool> {
mod tests {
use super::*;
/// The property the whole mechanism rests on: a memo written against one VM is not readable
/// from the next, and a seed claimed in one session is claimable again in the one after.
#[test]
fn a_memo_does_not_survive_the_vm_it_was_written_against() {
let first = UiScript::new().expect("VM");
@@ -132,8 +91,6 @@ mod tests {
);
}
/// The gate half (1439): `get_reset` reports the reset exactly once per session flip — the
/// one frame a gated feed must run with every other input unchanged.
#[test]
fn get_reset_reports_each_session_flip_once() {
let first = UiScript::new().expect("VM");
@@ -163,17 +120,8 @@ mod tests {
assert!(!seeded.claim(&second));
}
/// **The rule, enforced instead of remembered.**
///
/// A feed that memoizes what it pushed into the VM and does *not* key that memo on the session
/// fails in the one way nothing catches: it pushes nothing into the new VM, the window is
/// empty, and no error is raised anywhere. There is no runtime signal to test for — which is
/// exactly why the check has to be structural.
///
/// So: a `Local<…>` in the parameter list of a system that also takes the VM must be a
/// [`VmMemo`], or be named in [`EXEMPT`] with the reason it is not host-memory-about-the-VM.
/// Adding a feed the ordinary way passes; adding one with a bare `Local` fails here, at the
/// line, before it can reach a login.
/// A `Local` in a system that takes the VM must be a [`VmMemo`] or be listed in [`EXEMPT`]: a
/// stale memo fails silently, so the check is structural.
#[test]
fn a_local_in_a_system_that_holds_the_vm_is_keyed_on_the_session() {
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
@@ -209,35 +157,20 @@ mod tests {
);
}
/// `Local`s that live in a system holding the VM but are **not memory about the VM**, each with
/// the reason. Keyed `(path under src/, parameter name)`.
/// `Local`s in VM-holding systems that are not memory about the VM, keyed
/// `(path under src/, parameter name)`.
const EXEMPT: &[(&str, &str)] = &[
// A raster fact (the screen seam), not a VM push. The measurer beside it deliberately
// 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.
// A raster fact; a fresh VM re-seats its measurer on `!has_text_measurer()` anyway.
("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.
// The plate driver's anti-overlap scratch, cleared at the top of every run.
("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,
// and a fresh VM re-seats on `!has_text_measurer()` regardless.
// The window's scale factor, which gates the same re-seat as `last_seam`.
("ui_script/extract/mod.rs", "last_dpi"),
// Pushed unconditionally every frame; there is nothing remembered to go stale.
("ui_script/mod.rs", "smoothed"),
// A fact about the CHAT ROSTER, not about the VM: whether the player has joined a
// zone-dependent defense channel (the world-state readout's `Type == 1` gate). It is
// recomputed whenever `ChannelState` changes and is the same answer either side of a
// login; the push it feeds is memoized beside it in a real `VmMemo`, which is what
// re-seats the new VM.
// A fact about the chat roster (a defense channel joined); its push has its own `VmMemo`.
("world_state_ui.rs", "defense_channel"),
// The cursor systems, both platform arms: these track the OS cursor and an `NSCursor` raw
// pointer. The OS keeps that state across the VM's death and rebirth, so re-seating them at
// a login would re-assert a cursor nothing changed. (`last_set` is the key we last handed
// the window — the same fact under the same name on both arms.)
// The cursor systems on both platforms track OS cursor state, which outlives the VM.
("cursor.rs", "was_looking"),
("cursor.rs", "rects_disabled"),
("cursor.rs", "decode_failed"),
@@ -247,8 +180,7 @@ mod tests {
use crate::test_support::{fn_parameter_lists, rust_files};
/// Every `name: Local<Ty>` in a parameter list, as `(name, Ty)` — angle-bracket matched, so a
/// nested generic comes back whole.
/// Every `name: Local<Ty>` in a parameter list as `(name, Ty)`, nested generics whole.
fn locals_in(params: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for (i, _) in params.match_indices("Local<") {
+75 -275
View File
@@ -1,105 +1,35 @@
//! **The return-shape gate** — `reference/1.12-shapes.tsv` against what this client actually
//! answers (decision 1842).
//! The return-shape gate: `reference/1.12-shapes.tsv`, a harvest of every binding the 1.12 client
//! registers, against what this client answers.
//!
//! `reference_surface` compares `_G` to `_G`, so a wrong *name* cannot land. Nothing compared
//! *shapes*, and that gap produced six decisions in two days — 1818, 1819, 1830, 1834, 1836 and
//! 1840 — every one of them a right name with a wrong arity, invisible to every gate we had and
//! 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 a harvest of the reference binary, vendored: 1722 rows over all 82 registrar
//! tables, one per registered binding, generated and differentially tested against a second
//! 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 = 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.
//!
//! ## Two things that make this narrower than it looks, both on purpose
//!
//! **Only query-shaped names are probed.** The gate calls each binding to count what it answers,
//! and calling an arbitrary global for its arity would mean calling `AbandonQuest`, `Quit` and
//! `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.** 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
//!
//! **60 names are registered from more than one table and 21 differ in arity across
//! registrations** — `GetBuildInfo` is arity 5 in the glue table `0x8373b8` and 3 in the in-game
//! core `0x83de68`; `GetAddOnInfo` is 8 and 7. This VM is the in-game one, so a name carrying rows
//! from both is read at its **non-glue** row, and a name whose remaining rows still disagree is
//! skipped rather than guessed at.
//!
//! That hazard is the reason the table is keyed on `fn` upstream, and it is a live merge hazard for
//! `1.12-globals.tsv`, which is name-keyed.
//! A 1.12 predicate answers `1` or `nil`, never a boolean (`IsPetAttackActive` aside), so kinds
//! are gated with arity. The table says per row what to trust: `arity_conf = exact`, kinds only
//! where `kinds_conf = agree`, never `argc_conf`. Only query verbs are probed, a call that raises
//! is skipped, and a name registered from several tables (`GetBuildInfo` is 5 in the glue table
//! `0x8373b8`, 3 in-game at `0x83de68`) is read at its non-glue row.
/// Names the gate knows are wrong and does not yet assert — **a list that may only shrink**.
///
/// **It is empty.** It held five entries whose miss-branch *contents* the shapes table does not
/// record — four globals and `Model:GetFogColor` — and 1845 answered all five at the bytes rather
/// than guessing them. The mechanism stays because the next divergence the gate finds will want it:
/// an entry needs an arity, a reason, and a dispatched question.
/// Globals known to answer the wrong arity: a name, the reference's arity and the reason. The
/// list may only shrink.
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 without their values. The bytes answer it — `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.
/// The kind gate's list, same rules: a name, the wrong kinds it answers and the reason. An entry
/// whose binding comes into agreement fails the gate.
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.
/// The widget kind gate's list, same rules as [`KINDS_NOT_YET_ASSERTED`].
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.
/// The widget arity gate's list, same rules as [`NOT_YET_ASSERTED`].
const WIDGET_NOT_YET_ASSERTED: &[&str] = &[];
/// The glue registrar table. This VM is the in-game one, so a duplicated name is read at its
/// other row (see the module doc).
/// other row.
const GLUE_TABLE: &str = "0x8373b8";
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`].
/// The `kinds` column verbatim, `|`-separated alternatives; empty unless trustworthy.
kinds: String,
}
@@ -107,8 +37,8 @@ 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`.
/// The `arity_conf = exact` rows of one `table_kind`: `global` is the registrar surface,
/// `baselib` the 36-entry Lua base array looped into `_G` at `0x811e28`.
fn rows_of_kind(kind: &str) -> Vec<Row> {
let tsv = concat!(
env!("CARGO_MANIFEST_DIR"),
@@ -123,20 +53,14 @@ fn rows_of_kind(kind: &str) -> Vec<Row> {
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). 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.
// A `delegated-push` row's callee pushes on its behalf, unseen by the push trace, so
// its kinds cover the binding's own ops only; its arity, read from `eax`, holds.
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 are advisory unless `kinds_conf` is `agree`, so such a row carries none.
kinds: if f.len() > 10 && f[10] == "agree" && !delegated {
f[9].to_string()
} else {
@@ -157,18 +81,13 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
all.len()
);
// Resolve the duplicated names the way the module doc describes.
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");
// **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.
// A seated player: the `Unit*` bindings raise on a nil token and answer a real one.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -182,7 +101,7 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
let mut mismatches: Vec<String> = Vec::new();
for (name, rs) in &by_name {
// Query verbs only — the gate calls what it measures.
// Query verbs only: the gate calls what it measures.
if !["Get", "Is", "Has", "Can", "Unit", "Num"]
.iter()
.any(|p| name.starts_with(p))
@@ -203,15 +122,8 @@ fn every_query_binding_answers_the_reference_s_return_arity() {
}
let want = first.arity;
// 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.
// Counted with 5.0's `arg.n` inside the `pcall`: `select` is not a 1.12 global
// (`lua50::install` removes it).
let call = if name.starts_with("Unit") {
format!(r#"{name}("player")"#)
} else {
@@ -243,17 +155,12 @@ 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.
// A floor, not a target: raise it when coverage rises, never lower it to fit a change.
assert!(
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,
// and leaving it would let the next real divergence hide behind it.
// The list may only shrink: an entry that now answers the reference's arity must come off.
let stale: Vec<&str> = NOT_YET_ASSERTED
.iter()
.map(|(n, ..)| *n)
@@ -280,49 +187,14 @@ 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.
/// The base-library probes, a name and its call arguments: these names follow no query-verb
/// convention. The arguments are what the 5.0 signatures accept, chosen to discriminate; `setfenv`
/// is left out because it would change the environment every later probe runs in.
const BASELIB_PROBES: &[(&str, &str)] = &[
("collectgarbage", ""),
("date", ""),
// The five shipped stubs — `xor eax,eax; ret` in the image (`0x7027e0`–`0x702820`), 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` and `debugtimestamp` are
// the reference's six `xor eax,eax; ret` stubs (`0x7027e0` to `0x702830`), safe to call.
("debugbreak", ""),
("debugdump", ""),
("debuginfo", ""),
@@ -336,9 +208,9 @@ const BASELIB_PROBES: &[(&str, &str)] = &[
("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)`.
// ── probes that take arguments ──────────────────────────────────────────────────────────
// Two arguments: only a multi-argument call tells 5.0's `lua_settop(L,1); return 1` from
// 5.1's `return lua_gettop(L)`.
("assert", "1, 2"),
("getglobal", "\"BenillaShapeGateAbsent\""),
("getmetatable", "{}"),
@@ -346,8 +218,7 @@ const BASELIB_PROBES: &[(&str, &str)] = &[
("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.
// These write, but namespaced or self-restoring, so every later probe sees the same VM.
("seterrorhandler", "geterrorhandler()"),
("setglobal", "\"BenillaShapeGateProbe\", 1"),
("setmetatable", "{}, nil"),
@@ -356,7 +227,7 @@ const BASELIB_PROBES: &[(&str, &str)] = &[
("type", "nil"),
];
/// The base library's own shrinking list — same rules as [`NOT_YET_ASSERTED`]. **Empty.**
/// The base library's list, same rules as [`NOT_YET_ASSERTED`].
const BASELIB_NOT_YET_ASSERTED: &[(&str, usize, &str)] = &[];
#[test]
@@ -401,9 +272,7 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
));
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.
// Kinds only where `rows_of_kind` kept them as trustworthy.
if r.kinds.is_empty() {
continue;
}
@@ -439,15 +308,13 @@ fn the_base_library_answers_the_reference_s_return_arity_and_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.
// A floor, not a target.
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.
// The kinds comparison has its own floor: the trust narrowing can 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"
@@ -460,16 +327,9 @@ fn the_base_library_answers_the_reference_s_return_arity_and_kinds() {
);
}
/// 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
/// widget family, and a method name can appear in several of them with different arities (the
/// `name-not-unique` case). So a widget method is probed on **its own class**, never on whichever
/// object happens to answer to the name.
///
/// Identified by each table's distinctive members: `GetTexCoord` is Texture's, `GetChecked` is
/// CheckButton's, `GetHyperlinkFormat` is SimpleHTML's, `GetScrollChild` is ScrollFrame's, and so
/// on. A table with no entry here is skipped, which costs coverage and never correctness.
/// The widget registrar tables with their class and an instance name. A method name can sit in
/// several tables at different arities, so each method is probed on its own table's class; an
/// unmapped table is skipped.
const WIDGET_PROBES: &[(&str, &str, &str)] = &[
("0x878ec0", "Frame", "PGFrame"),
("0x879d00", "Button", "PGButton"),
@@ -483,45 +343,28 @@ const WIDGET_PROBES: &[(&str, &str, &str)] = &[
("0x87b960", "MessageFrame", "PGMessage"),
("0x87b5c0", "ScrollingMessageFrame", "PGScrollMsg"),
("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.
// `PlayerModel` is `Model` plus three verbs, in a table of its own.
("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.
// The base every widget falls back to (`REGION_MAP_METHODS`' 19 names), probed on a Frame.
("0x87c9b8", "Frame", "PGRegionBase"),
];
/// The two region classes, which are made by a frame rather than by `CreateFrame`.
/// The classes `CreateFrame` cannot make: the two regions, made by a frame, and the font object.
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).
// The font object (`CreateFont`), not a FontString: 22 methods of its own.
("0x87c7c8", "CreateFont('PGFontObject')"),
];
/// **The widget half of the gate** (decision 1843) — the half that would have caught 1840's
/// `GetTexCoord` without a byte read.
///
/// Same rule as the global half: `arity_conf = exact` only, query verbs only, a call that raises is
/// skipped. The difference is the receiver: each row is probed on an instance of the class its
/// registrar table belongs to, because the same method name lives in several tables with different
/// shapes.
/// The widget arity gate: the global gate's rules, with each method called on an instance of its
/// own registrar table's class.
#[test]
fn every_widget_method_answers_the_reference_s_return_arity() {
let tsv = concat!(
@@ -598,9 +441,7 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
}
assert!(
// 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.
// A floor under the coverage of all 23 widget tables.
checked >= 110,
"the widget gate measured only {checked} methods — it has stopped covering anything"
);
@@ -612,11 +453,8 @@ fn every_widget_method_answers_the_reference_s_return_arity() {
);
}
/// 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.
/// Splits a `kinds` cell into alternatives of per-slot kinds: `(nil) | (number)` is two of one
/// slot, and `()` yields none, so the kind gates skip a verb that answers nothing.
fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
cell.split('|')
.map(|alt| {
@@ -632,21 +470,9 @@ fn kind_alternatives(cell: &str) -> Vec<Vec<String>> {
.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`.
/// Whether an observed Lua `type()` satisfies one reference slot. `string?` is string-or-nil:
/// `lua_pushstring` (`0x6f3890`) pushes nil for a NULL pointer, and the harvest writes plain
/// `string` only for a constant literal. `any` and `value` are slots the trace could not narrow.
fn slot_accepts(want: &str, got: &str) -> bool {
match want {
"any" | "value" => true,
@@ -655,25 +481,15 @@ fn slot_accepts(want: &str, got: &str) -> bool {
}
}
/// 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.
/// The Lua helper the kind gates probe through: the `type()` of every returned value, in order,
/// comma-joined. It counts with `arg.n`: a table constructor drops trailing nils, and `...` as a
/// value is a syntax error in 5.0 (no `TK_DOTS` arm in `simpleexp`), which this VM's parser keeps.
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.
/// Every registered global whose return kinds the reference states trustworthily, answered by
/// this client, under the arity gate's narrowings.
#[test]
fn every_query_binding_answers_the_reference_s_return_kinds() {
let all = rows();
@@ -736,7 +552,7 @@ fn every_query_binding_answers_the_reference_s_return_kinds() {
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.
// An arity fault is the arity gate's to report.
if !want.iter().any(|alt| alt.len() == got.len()) {
continue;
}
@@ -769,8 +585,7 @@ fn every_query_binding_answers_the_reference_s_return_kinds() {
}
}
// A floor, not a target — see the arity gate's. Raise it when coverage rises; never lower it
// to make a change fit.
// A floor, not a target.
assert!(
checked >= 150,
"the kind gate measured only {checked} bindings — it has stopped covering anything"
@@ -783,8 +598,7 @@ fn every_query_binding_answers_the_reference_s_return_kinds() {
);
}
/// The widget half of the kind gate — same rules, probed on an instance of each method's own
/// registrar class (see [`WIDGET_PROBES`]).
/// The widget kind gate, on an instance of each method's own registrar class.
#[test]
fn every_widget_method_answers_the_reference_s_return_kinds() {
let tsv = concat!(
@@ -878,7 +692,7 @@ fn every_widget_method_answers_the_reference_s_return_kinds() {
}
assert!(
// 119 at 2142 — the same widening. The floor it replaces was 40.
// A floor under the coverage of all 23 widget tables.
checked >= 110,
"the widget kind gate measured only {checked} methods — it has stopped covering anything"
);
@@ -890,27 +704,17 @@ fn every_widget_method_answers_the_reference_s_return_kinds() {
);
}
/// 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`.
/// Argument tuples the boolean gate tries in order until a call returns. A plausible wrong
/// argument can change which kinds come back, which is why the kind gate passes only a unit
/// token, but it never turns `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.
/// 1.12 has no boolean query: of the table's trustworthy rows only `IsPetAttackActive` and Lua's
/// own `rawequal` answer a `boolean`, and every other predicate pushes `1` or `nil`, which
/// `x == 1`, `tostring(x)` or a saved variable tell apart from `true`.
#[test]
fn no_query_binding_answers_a_lua_boolean() {
// The reference's own two, by name — the list may only shrink.
// The reference's own two; the list may only shrink.
const REFERENCE_BOOLEANS: &[&str] = &["IsPetAttackActive", "rawequal"];
let all = rows();
@@ -975,11 +779,8 @@ fn no_query_binding_answers_a_lua_boolean() {
);
}
/// 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.
/// The widget boolean gate. Its argument ladder reaches the predicates the kind gate skips for
/// raising without one, such as `HasScript("OnClick")` and `IsObjectType("Frame")`.
#[test]
fn no_widget_method_answers_a_lua_boolean() {
let tsv = concat!(
@@ -1007,8 +808,7 @@ fn no_widget_method_answers_a_lua_boolean() {
}
}
// 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.
// A widget predicate takes a script or object-type name more often than a number.
const WIDGET_ARGS: &[&str] = &["", "\"OnClick\"", "\"Frame\"", "1", "1, 1"];
let mut checked = 0usize;
@@ -1028,7 +828,7 @@ fn no_widget_method_answers_a_lua_boolean() {
{
continue;
}
// The reference's own boolean, and the only widget one: `IsPetAttackActive` is a global.
// No widget method answers a boolean in the reference, so none is exempt here.
let Some(obj) = made.get(table) else { continue };
for args in WIDGET_ARGS {
let probe = format!(
File diff suppressed because it is too large Load Diff
@@ -1,20 +1,7 @@
//! 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.
//!
//! What it pins is the **proficiency row**: `SkillFrame.lua`'s own `skillMaxRank == 1` gate draws
//! a full GRAY bar with NO rank text, and two different kinds of line land in it — the armor
//! proficiencies, which the server really does report as `1/1`, and the **single-rank** lines
//! (class skills, Dual Wield, racials, the per-mount riding lines), whose `skillMaxRank` the
//! engine overrides to `1` off `SkillRaceClassInfo.flags & 0x400` however high the server's own
//! descriptor is (`benilla-ui`'s `SkillEntry::mono`; `0x4d3610`'s `4d38b1` branch). A
//! 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 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!()`.
//! The stock Skills page (`SkillFrame.xml`), driven engine-only. `SkillFrame.lua`'s
//! `skillMaxRank == 1` gate paints a gray bar with no rank text: for armor proficiencies (1/1 from
//! the server) and for single-rank lines (`mono`), whose max the reference forces to 1 off
//! `SkillRaceClassInfo.flags & 0x400` whatever the server sends (`0x4d3610`, branch `0x4d38b1`).
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript, UnitState};
@@ -44,24 +31,13 @@ fn skill(
}
}
/// The Skills page, shown, with one line of each shape — a hunter's real vmangos numbers:
/// `Beast Mastery 300/300` (single-rank, `SkillRaceClassInfo` 0x410), `Defense 12/60` (a normal
/// weapon line), `Cloth 1/1` (an armor proficiency the server itself caps).
///
/// The window around the page is the reference's own since 1751, so the file list is
/// [`super::test_ui::CHARACTER_UI`] — which already carries `SkillFrame.xml` (stock
/// `CharacterFrame_ShowSubFrame` hides all five pages by name, unguarded) and the
/// `UIPanelTemplates.xml` this list learned by hand a year ago: a MISSING template is a loader
/// *warning*, not an error, so an under-loaded list passes and then fails later on geometry that
/// silently never got built (`SkillDetailScrollFrame` inherits `UIPanelScrollFrameTemplate`).
/// [`super::test_ui::load_ui_strict`] is that lesson made into a check.
/// The Skills page shown over a hunter's vmangos lines: single-rank `Beast Mastery 300/300`
/// (`SkillRaceClassInfo` flags 0x410), `Defense 12/60` and the server-capped `Cloth 1/1`.
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.
/// [`shown_skills_page`] with a text measurer seated before the load, as at world entry.
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);
@@ -71,11 +47,8 @@ fn shown_skills_page_with(measurer: Option<Box<dyn benilla_ui::script::TextMeasu
for f in super::test_ui::CHARACTER_UI {
super::test_ui::load_ui_strict(&s, f);
}
// A player behind the window, carrying BOTH halves of the race and class pairs:
// `UnitRace`/`UnitClass` answer `(localized, file)` or `nil, nil` — the binding `zip`s them —
// and stock `PaperDollFrame_SetLevel` formats level, race and class into `CharacterLevelText`
// unguarded (`PaperDollFrame.lua:100-104`) every time this window is shown. Ours never did,
// which is why this page could be opened with no player at all.
// Stock `PaperDollFrame_SetLevel` formats level, race and class unguarded on every show
// (`PaperDollFrame.lua:100-104`).
s.set_unit(
"player",
Some(UnitState {
@@ -100,7 +73,7 @@ fn shown_skills_page_with(measurer: Option<Box<dyn benilla_ui::script::TextMeasu
s
}
/// Row slot `i`'s rank text and bar color — the two things the proficiency gate decides.
/// Row slot `i`'s rank text and bar colour, the two things the proficiency gate decides.
fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
let text = s
.eval::<String>(&format!(
@@ -113,14 +86,7 @@ fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
(text, [c.0, c.1, c.2, c.3])
}
/// The quads **this page** draws — `s.extract()` narrowed to targets whose nearest named owner is
/// one of the page's own frames.
///
/// Not a convenience: since 1751 the window around the page is the reference's own, so a test VM
/// carries the whole [`super::test_ui::CHARACTER_UI`] — the action bar, the micro menu and the unit
/// frames are loaded and *visible*, and their quads land in the same extract. An unfiltered
/// "nothing draws at alpha 0.5" over that list asserts something about the action bar, not about
/// this page's troughs.
/// The quads owned by the page's `Skill*` frames, not the rest of `CHARACTER_UI`.
fn page_quads(s: &UiScript) -> Vec<benilla_ui::script::ExtractedQuad> {
s.extract()
.into_iter()
@@ -131,8 +97,7 @@ fn page_quads(s: &UiScript) -> Vec<benilla_ui::script::ExtractedQuad> {
.collect()
}
/// Row slot `i`'s **trough** colour — the `$parentBackground` texture behind the fill, which the
/// ref recolours per branch alongside the fill (`SkillFrame.lua:158` normal / `:167` proficiency).
/// Row slot `i`'s trough colour (`SkillFrame.lua:158` normal, `:167` proficiency).
fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
let c = s
.eval::<(f32, f32, f32, f32)>(&format!(
@@ -151,8 +116,7 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
"the Skills page is up"
);
// Visible rows: 1 header "Class Skills", 2 Beast Mastery, 3 header "Weapon Skills",
// 4 Defense, 5 header "Armor Proficiencies", 6 Cloth — one list slot each.
// Rows: 1 Class Skills, 2 Beast Mastery, 3 Weapon, 4 Defense, 5 Armor, 6 Cloth.
let (text, color) = row(&mut s, 2);
assert_eq!(
text, "",
@@ -165,13 +129,11 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
"over the proficiency branch's WHITE trough (ref SkillFrame.lua:167)"
);
// The armor proficiency reaches the same branch by the server's own 1/1.
let (text, color) = row(&mut s, 6);
assert_eq!(text, "", "Cloth is 1/1: no rank text");
assert_eq!(color, [0.5, 0.5, 0.5, 1.0], "gray too");
assert_eq!(row_bg(&mut s, 6), [1.0, 1.0, 1.0, 0.5], "white trough too");
// The control: a normal weapon line still reads its numbers, in blue.
let (text, color) = row(&mut s, 4);
assert_eq!(text, "12/60", "Defense keeps its rank text");
assert_eq!(color, [0.0, 0.0, 1.0, 0.5], "and the blue fill");
@@ -181,10 +143,8 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
"over the normal branch's DARK BLUE trough (ref SkillFrame.lua:158)"
);
// And what those troughs actually DRAW at. The template declares the texture `<Color 1,1,1,0.2>`
// — a real texel — and the vertex colour MULTIPLIES it, alpha included (the composition law,
// `benilla-ui` `script::tests::regions`): the proficiency's white reaches the screen at
// `0.2 x 0.5 = 0.1`, not 0.5. Every solid-colour quad this page emits is one of these troughs.
// The trough texture is `<Color 1,1,1,0.2>` and the vertex colour multiplies it, alpha
// included, so white draws at 0.2 x 0.5 = 0.1; every solid quad the page emits is a trough.
let solids: Vec<[f32; 4]> = page_quads(&s)
.iter()
.filter_map(|q| match &q.content {
@@ -209,7 +169,7 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
"nothing draws at the raw vertex alpha — that was the replace bug; got {solids:?}"
);
// Selecting the single-rank row paints the detail pane the same way (the shared PaintBar).
// The detail pane's bar takes the same gate (`SkillFrame.lua:370-376`).
s.run("SetSelectedSkill(2) SkillFrame_UpdateSkills()")
.unwrap();
assert_eq!(
@@ -227,36 +187,30 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **The director's reference screenshot, reproduced row for row** (decision 1091). A real
/// level-60 tauren hunter's `PLAYER_SKILL_INFO` block — read straight out of the live vmangos
/// `character_skills` rows the A/B was taken on — fed through the app's own display predicate
/// ([`crate::ui_char::skills_row`]) and the engine's grouping, against the REAL shipped DBCs.
///
/// What the reference client shows for exactly this block is the expectation below: four headers,
/// fourteen rows — and **no** `Dual Wield`, `Tauren Racial` or `GENERIC (DND)`, and no `Secondary
/// Skills` header, though the server sends all three lines at 300/300 like any other. Skips
/// without client data.
/// A level-60 tauren hunter's vmangos skill block, through [`crate::ui_char::skills_row`] and the
/// real DBCs, lists what the reference lists: four headers and fourteen lines, with no
/// `Dual Wield`, `Tauren Racial` or `GENERIC (DND)`, though the server sends them.
#[test]
fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines");
// (skill line, value, max) — Twohunter's rows, verbatim. Tauren (race 6) hunter (class 3), 60.
// (skill line, value, max) for a tauren (race 6) hunter (class 3) at level 60.
const BLOCK: &[(u16, u16, u16)] = &[
(44, 300, 300), // Axes
(46, 300, 300), // Guns
(50, 300, 300), // Beast Mastery — single-rank
(51, 300, 300), // Survival — single-rank
(50, 300, 300), // Beast Mastery: single-rank
(51, 300, 300), // Survival: single-rank
(95, 300, 300), // Defense
(109, 300, 300), // Language: Orcish
(115, 300, 300), // Language: Taurahe
(118, 300, 300), // Dual Wield — HIDDEN
(124, 300, 300), // Tauren Racial — HIDDEN
(118, 300, 300), // Dual Wield: hidden
(124, 300, 300), // Tauren Racial: hidden
(162, 300, 300), // Unarmed
(163, 300, 300), // Marksmanship — single-rank
(163, 300, 300), // Marksmanship: single-rank
(173, 300, 300), // Daggers
(183, 300, 300), // GENERIC (DND) — HIDDEN
(183, 300, 300), // GENERIC (DND): hidden
(226, 300, 300), // Crossbows
(413, 1, 1), // Mail
(414, 1, 1), // Leather
@@ -314,14 +268,13 @@ fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
let got: Vec<(&str, bool)> = rows.iter().map(|(n, h)| (n.as_str(), *h)).collect();
assert_eq!(got, expected, "the pane, row for row");
// The three the server sends and the client never lists.
for hidden in ["Dual Wield", "Tauren Racial", "GENERIC (DND)"] {
assert!(
!got.iter().any(|(n, _)| *n == hidden),
"{hidden} must not appear"
);
}
// And the class lines still read as proficiencies despite their 300/300 descriptor.
// Class lines read as proficiencies despite their 300/300.
assert_eq!(
s.eval::<i64>("local _,_,_,_,_,_,mx = GetSkillLineInfo(2) return mx")
.unwrap(),
@@ -330,11 +283,8 @@ fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
);
}
/// The page's own **CLOSE** button (decision 1496). `SkillFrameCancelButton` is live in the
/// reference — the XML comment that swallows its `SkillFrameAcceptButton` neighbour closes one
/// line above it (ref `SkillFrame.xml` l.337/339) — and the director's screenshot of an empty
/// bottom-right seat is what that misread cost. Pins the button's existence, the ref's own seat
/// (80x22 centred on the page's TOPLEFT + (305,-422)), and that it closes the window.
/// `SkillFrameCancelButton` is live: the XML comment around `SkillFrameAcceptButton` closes just
/// above it (`SkillFrame.xml:338-339`). It is 80x22, centred at the page's TOPLEFT + (305, -422).
#[test]
fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -351,7 +301,6 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
"and shown — nothing in the ref's SkillFrame.lua ever hides it"
);
// The ref's geometry, read page-relative so the assertion is the ref's own numbers.
let (page_top, page_left) = s
.eval::<(f64, f64)>("return SkillFrame:GetTop(), SkillFrame:GetLeft()")
.unwrap();
@@ -372,15 +321,8 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
"CENTER of the page's TOPLEFT at (305,-422), 80x22 (ref l.339-348)"
);
// Its label is the CLOSE global string's seat, in the panel-button gold.
// **"Close", not "CLOSE".** An XML `text=` attribute is a GlobalStrings LOOKUP, not a literal
// (`loader::Loader::resolve_text` — `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 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.
// `text=` looks up GlobalStrings (`0x703bf0`), else keeps the raw attribute (`0x778c31`,
// `0x771032`); `CLOSE` is "Close" (`GlobalStrings.lua:760`).
let label = page_quads(&s)
.iter()
.find_map(|q| match &q.content {
@@ -398,7 +340,7 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
"GameFontNormal — the UIPanelButtonTemplate face's own normal font"
);
// And it does what the ref's OnClick does: page down, window down.
// The stock `OnClick` hides the page and the window (`SkillFrame.xml:351-355`).
s.run("SkillFrameCancelButton:Click()").unwrap();
s.resolve();
assert!(
@@ -412,17 +354,14 @@ fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_windo
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The **ALL** fold's face and seat (decision 1496). Both halves of the director's report: the
/// label is the row font — `GameFontHighlight`, WHITE at 12 — not `GameFontNormalSmall`'s yellow
/// 10, and the button rides 3px BELOW the tab cap's centre (the ref's own `(-3,-3)` off the left
/// cap), not 3px above it, which is where the offset copied from `TrainerFrame.xml` put it.
/// The fold inherits `SkillLabelTemplate`, the row font, and sits at `(-3, -3)` off the left cap's
/// right edge (`SkillFrame.xml:292-300`).
#[test]
fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
let _data = benilla_formats::wow_data_or_skip!();
let s = shown_skills_page();
// "All", not "ALL" — the same GlobalStrings lookup as the CLOSE button above
// (`ALL = "All"`, `GlobalStrings.lua:45`). The face is what this test is about, not the word.
// Its `OnLoad` sets `ALL`, which is "All" (`SkillFrame.xml:305`, `GlobalStrings.lua:45`).
let label = page_quads(&s)
.iter()
.find_map(|q| match &q.content {
@@ -468,11 +407,8 @@ 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:
/// `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).
/// Stock `SkillFrameExpandButtonFrame` sizes itself once, in `OnLoad`, to the label's width + 45
/// (`SkillFrame.xml:314`), so the measurer must be seated before the load.
#[test]
fn the_expand_tab_fits_its_label_at_load() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -483,7 +419,7 @@ fn the_expand_tab_fits_its_label_at_load() {
7.0 * 3.0 + 45.0,
"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.
// The middle slab spans the two caps, so the art follows the fit.
let (mid_l, mid_r, cap_r_l) = s
.eval::<(f64, f64, f64)>(
"return SkillFrameExpandTabMiddle:GetLeft(), SkillFrameExpandTabMiddle:GetRight(), \
@@ -496,7 +432,6 @@ fn the_expand_tab_fits_its_label_at_load() {
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!(
@@ -507,16 +442,9 @@ fn the_expand_tab_fits_its_label_at_load() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **B370 — the list reaches its last rows.** The symptom: the Skills tab stops 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.
/// `SkillListScrollFrame` is 220 tall (`SkillFrame.xml:468`), so its overflow `n * 15 - 220` is
/// 40px short of the bar's `(n - 12) * 15`; the reference stores the scroll value unclamped
/// (`0x786db0`), so the bar's end shows the last line.
#[test]
fn the_list_reaches_its_last_row_at_the_bars_end() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -537,8 +465,7 @@ fn the_list_reaches_its_last_row_at_the_bars_end() {
..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.
// 22 lines under 3 headers, 25 rows: enough that the tail lies past the overflow.
let mut entries = Vec::new();
for (i, name) in [
"Axes",
@@ -607,8 +534,7 @@ fn the_list_reaches_its_last_row_at_the_bars_end() {
"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.
// The control: the overflow is 40px shorter than the bar's range.
let (_, bar_max) = s
.eval::<(f64, f64)>("return SkillListScrollFrameScrollBar:GetMinMaxValues()")
.unwrap();
@@ -622,8 +548,7 @@ fn the_list_reaches_its_last_row_at_the_bars_end() {
"the frame is taller than its twelve rows"
);
// The knob dragged to the end: bar → SetVerticalScroll → <OnVerticalScroll> →
// FauxScrollFrame_OnVerticalScroll → SkillFrame_UpdateSkills.
// The bar to its end, through `<OnVerticalScroll>` to `SkillFrame_UpdateSkills`.
s.run(
"local _, hi = SkillListScrollFrameScrollBar:GetMinMaxValues() \
SkillListScrollFrameScrollBar:SetValue(hi)",
@@ -1,16 +1,10 @@
//! The shipped **spellbook window** driven end-to-end, engine-only (no Bevy): the real
//! `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).
//! The stock spellbook window (`SpellBookFrame.xml`) over a small synthetic book, engine only.
use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
use super::test_ui::load_ui as load_xml;
/// Two tabs' worth of a small book: "Fire" (Fireball, Fire Blast — slots 0-1) and "Frost" (Frost
/// Armor — slot 2), the flat `slots` in tab order. `offset` is each tab's 0-based start index into
/// `slots` (`benilla-ui`'s own book-id seam doc) — tab 1's is 0, tab 2's is 2 (right after tab 1's
/// two spells).
/// Fire (two spells) and Frost (one); `offset` is a tab's 0-based start in the flat `slots`.
fn book() -> SpellBookState {
SpellBookState {
tabs: vec![
@@ -62,8 +56,7 @@ fn book() -> SpellBookState {
}
}
/// The centre of a laid-out frame, for a real mouse click through the hit-test — the file's own
/// idiom, lifted out of `shipped_spellbook_drives_end_to_end` so the pet test shares it.
/// The centre of a laid-out frame, for a click through the hit test.
fn center(s: &UiScript, name: &str) -> (f32, f32) {
let l: f32 = s.eval(&format!("return {name}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {name}:GetRight()")).unwrap();
@@ -80,10 +73,8 @@ 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.
/// The stock spellbook in manifest order, over the multibar grids and `UpdateMicroButtons` its show
/// and hide call (`SpellBookFrame.lua:94-104`, `:186-203`).
pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(w, h);
@@ -122,9 +113,6 @@ pub(super) fn spellbook_ui(w: f32, h: f32) -> UiScript {
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 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() {
benilla_formats::wow_data_or_skip!();
@@ -133,7 +121,7 @@ fn shipped_spellbook_loads_clean() {
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, "ScrollTemplates.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");
@@ -141,7 +129,6 @@ fn shipped_spellbook_loads_clean() {
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");
@@ -160,11 +147,6 @@ fn shipped_spellbook_loads_clean() {
}
}
/// The whole contract in one end-to-end drive: `ToggleSpellBook` (the 'P' binding's entry point)
/// opens through `ShowUIPanel`, tab 1's page renders names + ranks, a plain click casts (drains
/// `take_spell_casts`, no cursor payload), a shift-click picks it up instead (the modifier
/// mirror), and the held spell places on an action button through the SAME slice-4 machinery a
/// bar-to-bar drag uses — packing kind 0x00 (SPELL) with the spell id.
#[test]
fn shipped_spellbook_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
@@ -178,12 +160,8 @@ fn shipped_spellbook_drives_end_to_end() {
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(s.eval::<bool>("return SpellBookFrame:IsVisible()").unwrap());
// Tab 1 ("Fire", selected by default — SpellBookFrame_OnLoad's own SkillLineTab_OnClick(1)):
// book id 1 (SpellButton1, id="1") shows Fireball; book id 2 (SpellButton3,
// id="2" — the SECOND row of column 1, not the second on-screen button) shows Fire Blast —
// the ref's own column-major id assignment (`id="1"`/`"7"`/`"2"`/`"8"`/…, this file's grid
// comment), not left-to-right on-screen order. SpellButton2 (id="7", book id 7) is
// past this 2-spell tab's end and stays disabled/hidden.
// Button ids run column-major (`SpellBookFrame.xml:387-486`): SpellButton3 is id 2, and
// SpellButton2 is id 7, past the two-spell Fire tab that `OnLoad` selects.
assert_eq!(
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
@@ -208,14 +186,12 @@ fn shipped_spellbook_drives_end_to_end() {
s.resolve();
let (x1, y1) = center(&s, "SpellButton1");
// A plain click CASTS (drains the intent) — never picks up.
s.mouse_button(x1, y1, "LeftButton", true);
s.mouse_button(x1, y1, "LeftButton", false);
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(s.take_spell_casts(), vec![133]);
assert!(s.cursor_payload().is_none(), "a cast never picks up");
// A shift-click PICKS UP instead (the modifier mirror) — never casts.
s.set_modifiers(true, false, false);
s.mouse_button(x1, y1, "LeftButton", true);
s.mouse_button(x1, y1, "LeftButton", false);
@@ -231,13 +207,8 @@ fn shipped_spellbook_drives_end_to_end() {
("spell", "spell", 133)
);
// Place the held spell onto action button 1 — the SAME slice-4 machinery
// (`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.
// An empty slot is hidden (`ActionButton.lua:214-215`) until a held payload opens the grid;
// the engine fires `ACTIONBAR_SHOWGRID` on the next tick.
s.tick(0.016);
assert!(
s.eval::<bool>("return ActionButton1:IsVisible()").unwrap(),
@@ -252,10 +223,8 @@ fn shipped_spellbook_drives_end_to_end() {
assert_eq!(s.take_action_sets(), vec![(1, 133)]); // 0x00<<24 | 133
}
/// The cooldown pie through the REAL shipped XML (the once-deferred half of slice 5): a pushed
/// per-slot triple + `SPELL_UPDATE_COOLDOWN` arms the button's Cooldown widget mid-sweep (ref
/// SpellButton_UpdateButton l.359-360), and an on-hold triple (enable 0 — Stealth/Feign Death
/// parked until SMSG_COOLDOWN_EVENT) keeps the widget hidden and dims the icon to 40% (l.361-365).
/// A running cooldown arms the button's sweep (`SpellBookFrame.lua:359-360`); an on-hold one
/// (enable 0, as Stealth until `SMSG_COOLDOWN_EVENT`) draws none and dims the icon (`:361-365`).
#[test]
fn shipped_spellbook_shows_the_cooldown_pie() {
benilla_formats::wow_data_or_skip!();
@@ -266,13 +235,12 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
s.tick(10.0); // GetTime = 10
// A running 10 s cooldown started at t=6 (6 s left): the event re-read arms the sweep.
// A 10 s cooldown started at t=6, 6 s left.
let mut b = book();
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`.
// `CooldownFrame_SetTimer` arms sequence 0; the next paint's `OnUpdateModel` scrubs it.
super::test_ui::cooldown_facts(&mut s);
s.tick(0.0);
s.resolve();
@@ -285,7 +253,7 @@ fn shipped_spellbook_shows_the_cooldown_pie() {
"4 s elapsed of 10 ⇒ the sweep sits at 40 %: sequence 0 at 400 ms"
);
// An on-hold triple: no sweep (CooldownFrame_SetTimer's enable gate), the icon dims to 40%.
// On hold: `CooldownFrame_SetTimer`'s enable gate draws no sweep, and the icon dims to 0.4.
let mut b = book();
b.slots[0].cooldown = Some((6_000, 10_000, false));
s.set_spellbook(b);
@@ -316,29 +284,15 @@ 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: 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.
/// An empty slot is a disabled button that keeps its `UI-Quickslot2` ring (whitened at
/// `SpellBookFrame.lua:337`): created enabled (`0x7786a0` ends in `SetState(NORMAL)`), and
/// `SetState` (`0x779790`) has no step that takes the ring off on `Disable()`. `SetChecked(0)`
/// unchecks, so there is no `CheckButtonHilight` glow.
#[test]
fn shipped_spellbook_empty_slot_draws_its_background_and_socket_ring() {
benilla_formats::wow_data_or_skip!();
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.)
// One spell: SpellButton5, id 3, takes the `id > offset + numSpells` disable path.
let mut b = book();
b.tabs.truncate(1);
b.tabs[0].num_spells = 1;
@@ -365,14 +319,14 @@ fn shipped_spellbook_empty_slot_draws_its_background_and_socket_ring() {
],
"an empty slot keeps its socket ring and gains no checked glow"
);
// The reference passes SetChecked(0) — numeric coercion, not Lua truthiness.
// `SetChecked(0)` unchecks: a number is coerced, not tested for Lua truthiness.
assert!(!s
.eval::<bool>("return SpellButton5:GetChecked() and true or false")
.unwrap());
}
/// The SetChecked coercion table, pinned from the reference's own call sites (SpellBookFrame.lua
/// l.132/134/268/296-303/336): 1/"true"/true check; 0/"false"/nil/non-numeric strings uncheck.
/// `SetChecked`'s coercion over the arguments the stock call sites pass
/// (`SpellBookFrame.lua:132`, `:134`, `:268`, `:296-303`, `:336`).
#[test]
fn set_checked_uses_blizzard_bool_coercion() {
benilla_formats::wow_data_or_skip!();
@@ -399,8 +353,7 @@ fn set_checked_uses_blizzard_bool_coercion() {
}
}
/// A hunter's pet book: Growl (autocast ON, on cooldown), Claw (autocast OFF), Avoidance (a
/// passive — not autocastable). Three spells so the tab row raises and the page has content.
/// A pet book: Growl (autocast on, cooling down), Claw (autocast off), Avoidance (passive).
fn pet_book() -> benilla_ui::script::PetBookState {
benilla_ui::script::PetBookState {
token: Some("PET".into()),
@@ -437,10 +390,6 @@ fn pet_book() -> benilla_ui::script::PetBookState {
}
}
/// **The pet tab, end to end through the shipped XML** (decision 1032): the toggle row appears
/// only once there are pet spells, clicking the pet tab switches the book, the page renders the
/// pet's own spells with their autocast overlay, the skill-line strip goes away, the title becomes
/// 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() {
benilla_formats::wow_data_or_skip!();
@@ -448,7 +397,7 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
// ── No pet: no toggle row, and the pet book cannot be opened at all (ref l.11-14) ──────────
// ── No pet: no toggle row, and no pet book (`SpellBookFrame.lua:11-14`) ──────────────────
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(
@@ -496,8 +445,7 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
"the pet book has no skill lines — the whole strip hides (ref l.124)"
);
// The page: book ids are the button ids themselves on this book, so button 1 is Growl and
// button 3 (id="2") is Claw — the same column-major id map the spell book uses.
// The pet book's ids are the button ids (`SpellBookFrame.lua:460-461`): SpellButton3 is Claw.
assert_eq!(
s.eval::<String>("return SpellButton1SpellName:GetText()")
.unwrap(),
@@ -508,7 +456,8 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.unwrap(),
"Claw"
);
// The autocast overlay follows GetSpellAutocast's FIRST return (can it), not the second.
// The overlay follows `GetSpellAutocast`'s first return (can it), the shine model its second
// (is it on) (`SpellBookFrame.lua:367-377`).
assert!(s
.eval::<bool>("return SpellButton1AutoCastable:IsVisible()")
.unwrap());
@@ -517,17 +466,15 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.unwrap(),
"a passive is not autocastable"
);
// …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 SpellButton1AutoCast:IsVisible()")
.unwrap());
assert!(!s
.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
// offset, which is what makes them concentric with the shine below (1393).
// Deviation (SpellBookAdapters.xml): the brackets are 71.53 square and the shine 37x37 at
// scale 1.48, both centred on the button, so the two share a centre; the reference's are 60,
// and 36x36 at CENTER (1,1) and 1.22 (`SpellBookFrame.xml:121-147`), which reads off-centre.
let br: Vec<f32> = [
"GetWidth()",
"GetHeight()",
@@ -553,10 +500,6 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
br[3]
);
// 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| {
@@ -586,14 +529,13 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
);
// ── The clicks ────────────────────────────────────────────────────────────────────────────
// Left: a pet cast, on the pet queue and NOT the player's.
click(&mut s, "SpellButton1", "LeftButton");
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(s.take_pet_spell_casts(), vec![2649]);
assert!(s.take_spell_casts().is_empty());
assert!(s.take_pet_spell_autocasts().is_empty());
// Right: autocast, never a cast (ref l.284-285).
// Right: autocast, never a cast (`SpellBookFrame.lua:284-285`).
click(&mut s, "SpellButton3", "RightButton");
assert!(
s.errors().is_empty(),
@@ -606,8 +548,9 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
"a right-click on the pet page must not also cast"
);
// ── The two reference QUIRKS, asserted on purpose (decisions 1030/1032) ───────────────────
// The pet page ignores its page number: `SpellBook_GetSpellID`'s pet arm is a bare `return id`.
// ── Two reference quirks, pinned ───────────────────────────────────────────────────────────
// The pet page ignores its page number: `SpellBook_GetSpellID`'s pet arm is a bare `return id`
// (`SpellBookFrame.lua:460-461`).
assert_eq!(s.eval::<i64>("return SpellBook_GetSpellID(1)").unwrap(), 1);
s.run(r#"SPELLBOOK_PAGENUMBERS["pet"] = 2"#).unwrap();
assert_eq!(
@@ -615,7 +558,8 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
1,
"DO NOT FIX: the ref's pet arm has no page term (decision 1032)"
);
// …and the Next arrow writes the SPELL book's counter, leaving the pet page where it was.
// …and Next writes `SPELLBOOK_PAGENUMBERS[selectedSkillLine]` on both books
// (`SpellBookFrame.lua:423`), so the pet page stays put.
s.run(r#"SPELLBOOK_PAGENUMBERS["pet"] = 1"#).unwrap();
click(&mut s, "SpellBookNextPageButton", "LeftButton");
assert!(s.errors().is_empty(), "page errors: {:?}", s.errors());
@@ -632,7 +576,7 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
"so the page does not turn"
);
// ── The pet leaves: the window closes and reverts (ref l.147-151) ─────────────────────────
// ── The pet leaves: the window closes and reverts (`SpellBookFrame.lua:147-151`) ─────────
s.set_pet_book(benilla_ui::script::PetBookState::default());
s.fire_event("SPELLS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "teardown errors: {:?}", s.errors());
@@ -645,31 +589,14 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.unwrap());
}
/// The spellbook's **macro-editor fork**, pinned in both directions (report B248).
///
/// The reference's contract, read off the 1.12.1 install's own `SpellBookFrame.lua:271-283` and
/// `MacroFrame.lua:93-97`, is narrow and easy to "fix" into a later expansion's:
///
/// * only a **shift**-click reaches the editor at all — a plain left OR **right** click still
/// casts, exactly as it does with no macro window on screen;
/// * what lands is a whole **`/cast <name>[(<rank>)]` line**, not the bare name (the bare-name
/// insert is 2.x's `ChatEdit_InsertLink`, a different client — and `MACRO_HELP_TEXT_LINE5`,
/// which advertises this feature, says "*Shift* click");
/// * it is **appended with no separator** — the reference's own `GetText()..line` — so two
/// shift-clicks run together on one line;
/// * a **passive** contributes nothing, and neither does a shift-click while the name/icon
/// popup has the body box hidden;
/// * with the editor hidden the shift-click is a **pickup** again.
///
/// DO NOT "FIX" the `/cast` prefix away (the standing rule, decision 1030): B248 reported it as
/// wrong, and the install says it is what 1.12.1 does.
/// The macro-editor fork (`SpellBookFrame.lua:271-283`, `Blizzard_MacroUI.lua:122-126`): only a
/// shift-click writes, appending a whole `/cast <name>[(<rank>)]` line with no separator. The
/// `/cast` line is 1.12's; the bare-name insert is a later client's.
#[test]
fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The macro window's own strings (the app runs the real `GlobalStrings.lua`; `macro_tests`'
// harness is the source of this list).
s.run(
r#"
CREATE_MACROS = "Create Macros"
@@ -687,8 +614,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
"#,
)
.unwrap();
// The stock macro window's character tab formats `UnitName("player")` into its label in its
// own OnLoad, so the player exists before the load (decision 1848).
// The macro window's character tab formats `UnitName("player")` in its OnLoad.
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
@@ -707,13 +633,8 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// `ShowMacroFrame` lives here since 1848.
"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
// — which is exactly the silent drift that record names.
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
@@ -727,16 +648,13 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
"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).
// `ExhaustionTick_Update` reads `ReputationWatchBar` (`MainMenuBar.lua:52`, `:69`), which
// `ReputationFrame.xml` declares, after the templates its check boxes inherit.
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.
// The multibar grids the spellbook toggles, whose file wants the options window's uvars.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
@@ -746,15 +664,13 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
] {
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).
// A LoadOnDemand addon, seated off the chain and loaded by stock `MacroFrame_LoadUI`
// (`UIParent.lua:178`), as the app does.
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
// column-major grid puts on SpellButton5.
// [`book`] plus a passive third Fire spell: id 3, on SpellButton5 in the column-major grid.
let mut b = book();
b.tabs[0].num_spells = 3;
b.tabs[1].offset = 3;
@@ -772,9 +688,8 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
// The reference selects NOTHING on open — `MacroFrame_Update` only highlights an existing
// selection, it never assigns one — so the details pane stays down until a macro is clicked.
// Our retired file auto-selected the first (decision 1848).
// Nothing is selected on open (`MacroFrame_Update` never assigns a selection), so the details
// pane waits for a click.
s.run("MacroButton1:Click()").unwrap();
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
@@ -786,7 +701,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
);
assert_eq!(body(&s), "");
// ── B248's reported gesture: a plain RIGHT click still CASTS, and writes nothing ──────────
// ── A plain right click still casts and writes nothing ─────────────────────────────────────
click(&mut s, "SpellButton1", "RightButton");
assert!(
s.errors().is_empty(),
@@ -801,12 +716,11 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
);
assert!(s.cursor_payload().is_none());
// …and so does a plain left click, the other half of the same arm.
click(&mut s, "SpellButton1", "LeftButton");
assert_eq!(s.take_spell_casts(), vec![133]);
assert_eq!(body(&s), "");
// ── The reference gesture: shift-click APPENDS a whole `/cast` line, and never casts ──────
// ── Shift-click appends a whole `/cast` line and never casts ──────────────────────────────
s.set_modifiers(true, false, false);
click(&mut s, "SpellButton1", "LeftButton");
assert!(
@@ -827,8 +741,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
s.cursor_payload().is_none(),
"with the editor open a shift-click writes instead of picking up"
);
// The dirty flag is set by the box's own `OnTextChanged`, which is deferred to the drain — the
// write landed in the buffer synchronously, the notification did not (decision 1831).
// The box's `OnTextChanged` sets the dirty flag, deferred to the next drain.
s.tick(0.0);
assert!(
s.eval::<bool>("return MacroFrame.textChanged == 1")
@@ -836,10 +749,9 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
"the write goes through the box's own OnTextChanged, so the window is dirty"
);
// A second one runs straight on: the reference appends with NO separator (`GetText()..line`),
// which is why a two-spell macro built this way needs the player to break the line himself.
// DO NOT "fix" this into a newline join.
click(&mut s, "SpellButton3", "RightButton"); // shift outranks the button — ref l.271 vs l.284
// A second line runs straight on: the reference appends with no separator (`GetText()..line`,
// `Blizzard_MacroUI.lua:124`).
click(&mut s, "SpellButton3", "RightButton"); // shift outranks the button
assert_eq!(
body(&s),
"/cast Fireball(Rank 1)/cast Fire Blast(Rank 1)",
@@ -847,14 +759,14 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
);
assert!(s.take_spell_casts().is_empty());
// A PASSIVE writes nothing at all — and does not fall through to a pickup either (ref l.274's
// guard sits INSIDE the macro-frame arm).
// A passive writes nothing and does not pick up: its guard sits inside the macro arm
// (`SpellBookFrame.lua:274`).
let before = body(&s);
click(&mut s, "SpellButton5", "LeftButton");
assert_eq!(body(&s), before, "a passive is not a castable line");
assert!(s.cursor_payload().is_none());
// The name/icon popup hides the body box; the ref's AddMacroLine gates on exactly that.
// The name/icon popup hides the body box, which `MacroFrame_AddMacroLine` checks.
s.run("MacroNewButton_OnClick()").unwrap();
assert!(!s.eval::<bool>("return MacroFrameText:IsVisible()").unwrap());
click(&mut s, "SpellButton1", "LeftButton");
@@ -864,7 +776,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
s.run("MacroFrame_Update()").unwrap();
assert_eq!(body(&s), before, "nothing landed while the popup was up");
// ── Editor hidden: the shift-click is a PICKUP again (ref l.281-282's else) ───────────────
// ── Editor hidden: shift-click picks up again (`SpellBookFrame.lua:281-282`) ───────────────
s.run("HideUIPanel(MacroFrame)").unwrap();
click(&mut s, "SpellButton1", "LeftButton");
s.set_modifiers(false, false, false);
@@ -1,7 +1,5 @@
//! The shared StaticPopup engine (decision 0308 §3 — the ref's registry + Show + OnUpdate
//! 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.
//! The stock StaticPopup engine (`StaticPopup.lua`): timeout, StartDelay, cancels, Escape, the
//! countdown text and addressing an instance by data.
use benilla_ui::script::UiScript;
@@ -23,8 +21,7 @@ fn setup() -> UiScript {
s
}
/// timeout: the dialog counts down and expires into OnCancel(data, "timeout") + hide
/// (ref StaticPopup_OnUpdate l.1713-1726).
/// Expiry runs OnCancel with "timeout", then hides the dialog (`StaticPopup.lua:1716`).
#[test]
fn timeout_expires_into_a_timeout_cancel() {
benilla_formats::wow_data_or_skip!();
@@ -57,10 +54,8 @@ fn timeout_expires_into_a_timeout_cancel() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// StartDelay: button1 is disabled while the delay counts down through delayText, then the real
/// text swaps in (with the stashed text_arg1) and the button enables (ref l.1684-1690 +
/// l.1764-1776). RECOVER_CORPSE is a real which on the delay-text list, so the countdown text
/// itself is exercised too.
/// StartDelay holds button1 disabled, then swaps the real text in (`StaticPopup.lua:1764`); only
/// the kinds named at `StaticPopup.lua:1778`, RECOVER_CORPSE among them, render its countdown.
#[test]
fn start_delay_gates_button1_then_swaps_the_text_in() {
benilla_formats::wow_data_or_skip!();
@@ -103,8 +98,7 @@ fn start_delay_gates_button1_then_swaps_the_text_in() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// cancels: showing X hides a visible X.cancels with an "override" cancel (ref l.1470-1485) —
/// the death arc's RESURRECT-cancels-DEATH chain.
/// Showing a dialog cancels a visible `cancels` kind with "override" (`StaticPopup.lua:1475`).
#[test]
fn showing_a_dialog_cancels_its_named_victim_with_override() {
benilla_formats::wow_data_or_skip!();
@@ -136,8 +130,7 @@ fn showing_a_dialog_cancels_its_named_victim_with_override() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// ESC only closes hideOnEscape dialogs (ref StaticPopup_EscapePressed l.1879-1893): a
/// non-escapable entry — the DEATH release popup's law — survives ToggleGameMenu.
/// Escape closes only `hideOnEscape` dialogs (`StaticPopup.lua:1879`).
#[test]
fn escape_skips_dialogs_without_hide_on_escape() {
benilla_formats::wow_data_or_skip!();
@@ -154,7 +147,6 @@ fn escape_skips_dialogs_without_hide_on_escape() {
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"a non-hideOnEscape dialog ignores ESC (the DEATH popup law)"
);
// The same entry marked escapable closes.
s.run(
r#"StaticPopup_Hide("TEST_STICKY")
StaticPopupDialogs["TEST_STICKY"].hideOnEscape = 1
@@ -169,8 +161,7 @@ fn escape_skips_dialogs_without_hide_on_escape() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The DEATH-family per-tick countdown text (ref l.1729-1762): a DEATH entry whose OnShow seeds
/// `timeleft` re-renders "%d %s until release" every tick, minutes above 60 s.
/// DEATH's text re-renders every tick, in minutes from 60 s up (`StaticPopup.lua:1727`).
#[test]
fn the_death_countdown_text_rerenders_each_tick() {
benilla_formats::wow_data_or_skip!();
@@ -204,17 +195,8 @@ fn the_death_countdown_text_rerenders_each_tick() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`StaticPopup_Hide`'s and `StaticPopup_FindVisible`'s second argument** (decisions 1793).
///
/// Both took `which` alone until the arity scan found the drift. The reference takes `(which,
/// data)`, and the difference is invisible without a test: Lua discards a surplus argument without
/// complaint, so a stock caller's `StaticPopup_Hide("CONFIRM_BATTLEFIELD_ENTRY", i)` — the shape
/// `BattlefieldFrame.lua:120` uses — would have hidden every instance of that dialog instead of
/// the one it named, and said nothing.
///
/// The `multiple` gate is the reference's own and is why `data` is not a plain equality: a kind
/// declared `multiple` can have several instances up at once and is matched by data; every other
/// kind has at most one, so its data is never consulted.
/// `StaticPopup_FindVisible` matches data only for a `multiple` kind (`StaticPopup.lua:1421`);
/// `StaticPopup_Hide` given data hides only the instance carrying it (`StaticPopup.lua:1706`).
#[test]
fn hide_and_find_address_one_instance_by_data_only_for_a_multiple_dialog() {
benilla_formats::wow_data_or_skip!();
@@ -231,8 +213,7 @@ fn hide_and_find_address_one_instance_by_data_only_for_a_multiple_dialog() {
.unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
// A `multiple` kind is addressed by data — both ways round, so this cannot pass by finding
// whichever instance happens to be first.
// Both ways round, so finding the first instance alone cannot pass.
for (data, want) in [("alpha", "a"), ("beta", "b")] {
assert!(
s.eval::<bool>(&format!(
@@ -242,37 +223,31 @@ fn hide_and_find_address_one_instance_by_data_only_for_a_multiple_dialog() {
"FindVisible picks the instance carrying {data:?}"
);
}
// …and a data nothing carries finds nothing, rather than the first instance.
assert!(s
.eval::<bool>(r#"return StaticPopup_FindVisible("T_MULTI", "nobody") == nil"#)
.unwrap());
// Hiding by data takes ONE instance and leaves its sibling up.
s.run(r#"StaticPopup_Hide("T_MULTI", "alpha")"#).unwrap();
assert!(s
.eval::<bool>("return not a:IsShown() and b:IsShown()")
.unwrap());
// No data hides every instance of the kind — which is what every one-argument caller means.
// No data hides every instance of the kind.
s.run(r#"StaticPopup_Hide("T_MULTI")"#).unwrap();
assert!(s.eval::<bool>("return not b:IsShown()").unwrap());
// A NON-multiple kind ignores data entirely: the reference gates the comparison on
// `info.multiple`, so a wrong data still finds it.
assert!(s
.eval::<bool>(r#"return StaticPopup_FindVisible("T_ONE", "wrong") == c"#)
.unwrap());
// An unknown kind answers nil rather than scanning — the reference's own early out.
// An unknown kind answers nil (`StaticPopup.lua:1416`).
assert!(s
.eval::<bool>(r#"return StaticPopup_FindVisible("T_NOPE") == nil"#)
.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.
/// Five stock `UIParent.lua` event arms raise their dialogs, which call engine verbs.
#[test]
fn the_verb_dialogs_open_from_their_events_and_call_their_verbs() {
benilla_formats::wow_data_or_skip!();
@@ -280,7 +255,6 @@ fn the_verb_dialogs_open_from_their_events_and_call_their_verbs() {
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!(
@@ -294,7 +268,6 @@ fn the_verb_dialogs_open_from_their_events_and_call_their_verbs() {
);
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);
@@ -306,18 +279,13 @@ fn the_verb_dialogs_open_from_their_events_and_call_their_verbs() {
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).
// The shipped dialog accepts on show and its one button cancels (`StaticPopup.lua:1224`).
assert!(
s.take_area_spirit_accepts() >= 1,
"showing the dialog IS the accept"
@@ -1,16 +1,10 @@
//! The summon confirm (decision 1747, ConfirmSummon.xml): the dialog `ui_summon`'s feed raises,
//! the countdown line the popup engine composes from the four engine globals, the combat lock on
//! its Accept, and the one call that becomes `CMSG_SUMMON_RESPONSE`.
//!
//! This dialog is the only one in the folder whose event carries **no arguments** — everything on
//! screen is read back out of the engine every tick — so these tests drive the getters' snapshot
//! (`set_summon_confirm`) rather than an event payload, which is exactly how the app drives it.
//! The stock summon confirm, `CONFIRM_SUMMON`, raised by `ui_summon`'s feed. The event has no
//! args: the popup reads the summoner, area and time left from engine getters every tick, so these
//! tests drive the getters' snapshot.
use benilla_ui::script::{SummonConfirmUiState, UiScript, UnitState};
/// A `"player"` snapshot that exists and is (or is not) fighting — the one field this dialog's
/// OnUpdate reads. `UnitAffectingCombat` answers on `exists && in_combat`, so the flag alone would
/// not be enough.
/// `UnitAffectingCombat` needs `exists` as well as `in_combat`.
fn player(in_combat: bool) -> UnitState {
UnitState {
exists: true,
@@ -21,8 +15,7 @@ fn player(in_combat: bool) -> UnitState {
use super::test_ui::load_ui as load_xml;
/// The app's own pre-state: a live offer from Twomage, out of Stormwind City, with the server's
/// full two-minute window still on the clock.
/// A live offer with the server's full two-minute window left.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -44,13 +37,8 @@ fn setup() -> UiScript {
s
}
/// The whole arc: `CONFIRM_SUMMON` (no args) raises the dialog, the popup engine's countdown tick
/// composes the line from the three getters, and Accept queues the one `ConfirmSummon()` that
/// becomes `CMSG_SUMMON_RESPONSE`.
///
/// The **text starts blank** and is filled by the tick — that is the engine's countdown contract
/// (`StaticPopup_Show` writes `" "` for this `which`), and the reason this dialog needs the
/// per-tick branch at all rather than `StaticPopup_Show`'s arguments.
/// `StaticPopup_Show` writes " " for a countdown kind (`StaticPopup.lua:1567`) and each tick
/// composes the line; Accept's `ConfirmSummon()` becomes `CMSG_SUMMON_RESPONSE`.
#[test]
fn the_confirm_names_the_summoner_and_accept_queues_the_response() {
benilla_formats::wow_data_or_skip!();
@@ -83,8 +71,7 @@ fn the_confirm_names_the_summoner_and_accept_queues_the_response() {
);
}
/// Under a minute the line counts in **seconds**, and the unit word singularises at one — the
/// engine's shared `StaticPopupTimeUnit`, reached through this dialog's own four-argument format.
/// Under a minute the line counts seconds, singular at one (`StaticPopup.lua:1748`).
#[test]
fn the_countdown_line_switches_to_seconds_and_singularises() {
benilla_formats::wow_data_or_skip!();
@@ -102,9 +89,7 @@ fn the_countdown_line_switches_to_seconds_and_singularises() {
"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
// lands on 0.7 — inside the last second, which `ceil` reports as 1 and the unit word must
// therefore singularise.
// 45 - 0.1 - 44.2 leaves 0.7 s, which `ceil` shows as 1.
s.run("StaticPopup_OnUpdate(StaticPopup1, 44.2)").unwrap();
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
@@ -113,10 +98,7 @@ fn the_countdown_line_switches_to_seconds_and_singularises() {
);
}
/// **A name still in flight paints blank and then fills itself in.** The reference does not hold
/// the event back for the name (the event has no arguments to hold), so the first frames render
/// `""` and the getter's next answer lands on the very next tick — which is only true because the
/// countdown branch re-reads the getters rather than caching `StaticPopup_Show`'s arguments.
/// A name still resolving paints blank; the tick re-reads the getters, so it fills in on arrival.
#[test]
fn a_summoner_whose_name_is_still_resolving_fills_in_on_a_later_tick() {
benilla_formats::wow_data_or_skip!();
@@ -149,9 +131,7 @@ fn a_summoner_whose_name_is_still_resolving_fills_in_on_a_later_tick() {
);
}
/// Accept is **disabled in combat and re-enabled out of it**, with the dialog staying up — this
/// entry's OnUpdate is a lock, not a teardown, which is what makes it different from every other
/// confirm in the folder. Cancel is never touched.
/// OnUpdate disables Accept in combat but leaves the dialog up (`StaticPopup.lua:1346`).
#[test]
fn combat_locks_accept_without_taking_the_dialog_down() {
benilla_formats::wow_data_or_skip!();
@@ -197,9 +177,7 @@ fn combat_locks_accept_without_taking_the_dialog_down() {
assert_eq!(s.take_summon_confirms(), 1);
}
/// Cancel and ESC both send **nothing**, and so does letting the clock run out: 1.12 has no
/// decline opcode and no `CancelSummon`, so every path but Accept is silence plus the server's own
/// expiry.
/// 1.12 has no decline opcode and no `CancelSummon`: only Accept sends.
#[test]
fn declining_and_expiring_both_send_nothing() {
benilla_formats::wow_data_or_skip!();
@@ -216,8 +194,7 @@ fn declining_and_expiring_both_send_nothing() {
);
assert_eq!(s.take_summon_confirms(), 0, "ESC is silent");
// The window running out: the popup engine's own timeout leg hides the dialog, and this entry
// has no OnCancel, so nothing at all goes to the wire.
// Expiry hides the dialog, and the entry has no OnCancel.
s.set_summon_confirm(SummonConfirmUiState {
summoner: "Twomage".into(),
area: "Stormwind City".into(),
@@ -232,9 +209,7 @@ fn declining_and_expiring_both_send_nothing() {
assert_eq!(s.take_summon_confirms(), 0, "and expiring is silent too");
}
/// The three getters answer the reference's own no-request values — two empty strings and a zero —
/// rather than nil, on a VM nothing has ever pushed to. An addon (or the dialog's own `format`)
/// concatenating a nil here would raise.
/// With no offer the getters answer "", "" and 0, as the reference does, never nil.
#[test]
fn the_getters_answer_empties_before_anything_is_pending() {
let s = UiScript::new().unwrap();
@@ -254,9 +229,7 @@ fn the_getters_answer_empties_before_anything_is_pending() {
);
}
/// `GetSummonConfirmTimeLeft()` answers whole **seconds, truncated** — the binding's own
/// `/1000` (`0x48b660`), not a round. It matters because the dialog seeds its countdown from this
/// one call: a rounded-up seed would show one second more than the server is holding.
/// The binding truncates ms to whole seconds (`0x48b660`); the dialog seeds its countdown from it.
#[test]
fn the_time_left_getter_truncates_to_whole_seconds() {
let mut s = UiScript::new().unwrap();
@@ -1,14 +1,11 @@
//! 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`.
//! The stock guild tabard designer (`TabardFrame.xml`): the open event seeds and shows it, the rows
//! cycle the design, Save follows `CanSaveTabardNow`, and closing calls `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.
/// The player in a guild at `rank`, 0 the master; `GetGuildInfo("player")` reads the unit's guild.
fn harness(rank: u32) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -38,7 +35,7 @@ fn harness(rank: u32) -> UiScript {
for file in [
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"ScrollTemplates.xml", // ours: the scroll kits
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
@@ -56,10 +53,8 @@ fn harness(rank: u32) -> UiScript {
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.
/// `MSG_TABARDVENDOR_ACTIVATE` fires `OPEN_TABARD_FRAME`; the handler seeds the design from the
/// guild record and shows the panel (`TabardFrame.lua:25`).
#[test]
fn the_open_event_seeds_the_design_paints_the_cells_and_shows_the_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -99,7 +94,6 @@ fn the_open_event_seeds_the_design_paints_the_cells_and_shows_the_window() {
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
@@ -110,7 +104,7 @@ fn the_open_event_seeds_the_design_paints_the_cells_and_shows_the_window() {
.unwrap();
assert_eq!(s.tabard_design().unwrap()[4], 7);
// Save: the intent carries the five; the pending latch greys the button through the event.
// Save sends the five values; the pending latch greys the button (`TabardFrame.lua:38`).
s.run("TabardFrameAcceptButton:Click()").unwrap();
assert_eq!(
s.take_tabard_intents(),
@@ -127,7 +121,7 @@ fn the_open_event_seeds_the_design_paints_the_cells_and_shows_the_window() {
"the latch greys the button"
);
// Cancel closes through the panel manager; the OnHide calls CloseTabardCreation.
// Cancel hides the panel, whose OnHide calls `CloseTabardCreation` (`TabardFrame.xml:625`).
s.run("TabardFrameCancelButton:Click()").unwrap();
s.resolve();
assert!(!s.eval::<bool>("return TabardFrame:IsVisible()").unwrap());
@@ -135,8 +129,7 @@ fn the_open_event_seeds_the_design_paints_the_cells_and_shows_the_window() {
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")`.
/// No guild or a rank above 0: the no-guild greeting and no Save (`TabardFrame.lua:97`).
#[test]
fn a_non_master_gets_the_no_guild_greeting_and_no_save() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -155,7 +148,6 @@ fn a_non_master_gets_the_no_guild_greeting_and_no_save() {
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());
@@ -1,14 +1,12 @@
//! The class trainer's respec confirm (decision 1580, TalentWipeConfirm.xml): the dialog
//! `ui_talent_wipe`'s feed raises, the money frame that carries the cost, its Accept, and the range
//! poll that takes it away — driven exactly as that feed and the app's NPC-session guard drive it.
//! The stock respec confirm, `CONFIRM_TALENT_WIPE`, driven as `ui_talent_wipe`'s feed and the
//! app's NPC range guard drive it.
use benilla_ui::script::{ScriptValue, UiScript};
use super::test_ui::load_ui as load_xml;
/// The app's own pre-state: a question is pending and in range, which is what
/// `CheckTalentMasterDist()` reports while the dialog is up. MoneyFrame.xml loads FIRST because
/// the dialog's coin row inherits `SmallMoneyFrameTemplate` — the TOC's own order (1580).
/// A respec question pending and in range, so `CheckTalentMasterDist()` holds. MoneyFrame.xml
/// loads before StaticPopup.xml, whose coin row inherits `SmallMoneyFrameTemplate`.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -26,10 +24,7 @@ fn setup() -> UiScript {
s
}
/// `CONFIRM_TALENT_WIPE(cost)` raises the dialog, and Accept queues the one `ConfirmTalentWipe()`
/// that becomes the outbound `MSG_TALENT_WIPE_CONFIRM`. The whole point of the arc: before this
/// wiring the trainer's line produced no dialog and no packet at all — the packet that asks was
/// parsed as an unknown opcode and dropped.
/// Accept's one `ConfirmTalentWipe()` becomes the outbound `MSG_TALENT_WIPE_CONFIRM`.
#[test]
fn the_confirm_shows_and_accept_queues_the_wipe() {
benilla_formats::wow_data_or_skip!();
@@ -54,9 +49,7 @@ fn the_confirm_shows_and_accept_queues_the_wipe() {
);
}
/// The cost rides the event's `arg1` into the dialog's MONEY frame, not into its text — so the
/// coin row is what tells the player a respec costs 1g 50s. This is the first StaticPopup entry to
/// raise one, and the engine's `hasMoneyFrame` leg is what shows it.
/// The cost, `arg1`, goes to the dialog's money frame, not its text (`UIParent.lua:536`).
#[test]
fn the_cost_lands_in_the_money_frame() {
benilla_formats::wow_data_or_skip!();
@@ -86,8 +79,7 @@ fn the_cost_lands_in_the_money_frame() {
"…50 silver"
);
// A second, dearer question repaints the same row — the cost climbs with every reset, so a
// stale number here would be the one thing the dialog exists to say, said wrong.
// The cost climbs with every reset; a second question repaints the row.
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
s.fire_event("CONFIRM_TALENT_WIPE", vec![ScriptValue::Int(50_000)]);
assert_eq!(
@@ -97,9 +89,7 @@ fn the_cost_lands_in_the_money_frame() {
);
}
/// Cancel and ESC both send **nothing**: declining a respec is silent on the wire (there is no
/// decline opcode — the question is one direction of a two-way opcode and the answer is the
/// other), so the only observable is that no confirm was queued.
/// There is no decline: `MSG_TALENT_WIPE_CONFIRM` asks one way and answers the other.
#[test]
fn declining_sends_nothing() {
benilla_formats::wow_data_or_skip!();
@@ -117,10 +107,7 @@ fn declining_sends_nothing() {
);
}
/// Walking away takes the question off screen: the entry's OnUpdate polls
/// `CheckTalentMasterDist()`, which the app drives from the shared NPC-session range guard — the
/// same guard, and the same byte-verified distance, that the innkeeper's question runs on. While it
/// holds, ticking changes nothing; the frame it goes false, the dialog hides itself.
/// The popup's OnUpdate hides it once `CheckTalentMasterDist()` fails (`StaticPopup.lua:1297`).
#[test]
fn leaving_the_trainers_range_hides_the_confirm() {
benilla_formats::wow_data_or_skip!();
@@ -1,16 +1,5 @@
//! The target frame's aura rows — the **reference's own** `Interface\FrameXML\TargetFrame.xml`
//! off the player's patch chain (decision 1751, which retired our `assets/ui/UnitFrames.xml`
//! transcription) — against its `TargetDebuffButton_Update` law (ref TargetFrame.lua l.263-387).
//! The stock XML/Lua is the unit under test; the app-side feed (`crate::ui_aura`'s target half) is
//! stubbed by pushing an [`AuraState`] list through [`UiScript::set_auras`] and firing the events
//! the feed fires — `PLAYER_TARGET_CHANGED` on a switch, `UNIT_AURA "target"` on a list change.
//!
//! Under test: the friend/hostile row swap (buffs first vs debuffs first), the 21→17 shrink when
//! the debuff count reaches the wrap (6, no target-of-target frame), the dispel-tinted border, the
//! stack count, and the hide-on-empty lifecycle. Files load in `benilla.toc`'s order, which is the
//! reference's own (BuffFrame 40 → CombatFeedback 41 → UnitFrame 43 → PlayerFrame 44 → PartyFrame
//! 45 → TargetFrame 46 → PetFrame 47), so `DebuffTypeColor` is defined ahead of the row that
//! indexes it and `RefreshBuffs` ahead of the party rows that call it from their own OnLoad.
//! The stock target frame's aura rows (`TargetFrame.lua:263`) under a stubbed feed.
//! BuffFrame.xml loads first for the `DebuffTypeColor` and `RefreshBuffs` the frames use.
use benilla_ui::script::{AuraState, QuadContent, ScriptValue, UiScript, UnitState};
@@ -22,8 +11,8 @@ fn harness() -> UiScript {
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, "Interface\\FrameXML\\GameTooltip.xml"); // TOOLTIP_DEFAULT_* for the dropdowns
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // TargetFrameDropDown's template
load_xml(&s, "Interface\\FrameXML\\Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
@@ -32,9 +21,8 @@ fn harness() -> UiScript {
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\\BonusActionBarFrame.xml");
// UIParent.xml comes first: `UnitPopup.lua:47` reads its `ITEM_QUALITY_COLORS` at file scope.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, "Interface\\FrameXML\\UnitPopup.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
@@ -47,15 +35,9 @@ fn harness() -> UiScript {
load_xml(&s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetFrame.xml");
// **Settle the target-of-target frame before any of this measures a row.** Stock
// `TargetofTargetFrame` carries no `hidden=` (ref TargetFrame.xml l.515), so it loads SHOWN,
// and the only thing that takes it down is `TargetofTarget_Update` — which
// `TargetFrame_OnEvent` runs *after* `TargetDebuffButton_Update` (ref TargetFrame.lua l.63-66,
// the update sitting inside `TargetFrame_Update` at l.51). So the very first target a freshly
// loaded client acquires lays its aura rows out for the 5-wide wrap, and only the next
// `TargetDebuffButton_Update` corrects them. One no-target `PLAYER_TARGET_CHANGED` runs that
// ladder to its end — exactly what deselecting once does — and leaves the frame in the state
// it holds for every target after the first, which is the state these tests are about.
// `TargetofTargetFrame` loads shown (`TargetFrame.xml:515`) and hides only after the rows
// lay out (`TargetFrame.lua:66`), so a first target lays its rows out for the 5-wide wrap;
// one no-target change leaves it in the state every later target sees.
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s
}
@@ -85,13 +67,12 @@ fn debuff(spell_id: u32, name: &str, count: u8, debuff_type: Option<&str>) -> Au
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
count,
debuff_type: debuff_type.map(Into::into),
// The 1.12 wire carries no duration for another unit (decision 0257 B6).
// The 1.12 wire carries no duration for another unit's auras.
duration: 0.0,
expiration_time: 0.0,
helpful: false,
cancelable: false,
// Only the PLAYER cache carries `untilCancelled`, and only `GetPlayerBuff` reads it
// (decision 0257 / `benilla::ui_aura`); a target's rows have no such record.
// Only the player's auras carry `untilCancelled`, read by `GetPlayerBuff` alone.
until_cancelled: false,
channeled: false,
}
@@ -155,8 +136,7 @@ fn a_hostile_target_draws_debuffs_first_with_tint_and_count() {
assert!(shown(&s, "TargetFrameBuff1"), "the buff shows");
assert!(!shown(&s, "TargetFrameBuff2"), "no second buff");
// Hostile: the debuff row opens at the frame's BOTTOMLEFT (5,32); buffs seat under Debuff7
// (the not-shown target-of-target leg, ref l.329).
// Hostile: debuffs open at BOTTOMLEFT (5, 32), buffs under Debuff7 (`TargetFrame.lua:329`).
let (p, rel, rp, x, y) = anchor(&s, "TargetFrameDebuff1");
assert_eq!(
(p.as_str(), rel.as_str(), rp.as_str(), x, y),
@@ -183,8 +163,7 @@ fn a_hostile_target_draws_debuffs_first_with_tint_and_count() {
""
);
// The Magic-tinted border drew (DebuffTypeColor["Magic"] = 0.20, 0.60, 1.00); Rend's untyped
// border wears the "none" red (0.80, 0, 0).
// `DebuffTypeColor` Magic is 0.20, 0.60, 1.00 and "none" 0.80, 0, 0 (`BuffFrame.lua:10`).
s.resolve();
let tints: Vec<[f32; 4]> = s
.extract()
@@ -250,12 +229,12 @@ fn reaching_the_wrap_shrinks_the_first_row_to_17px() {
.collect();
target(&mut s, 2, debuffs);
// 6 debuffs ≥ wrap (6): 17px icons, 19px borders — the reference resizes only the FIRST row.
// 6 debuffs reach the wrap: 17px icons, 19px borders, first row only (`TargetFrame.lua:349`).
assert_eq!(size(&s, "TargetFrameDebuff1"), (17.0, 17.0));
assert_eq!(size(&s, "TargetFrameDebuff1Border"), (19.0, 19.0));
assert_eq!(size(&s, "TargetFrameBuff1"), (17.0, 17.0));
// Dropping below the wrap grows them back — the feed re-fires UNIT_AURA on the change.
// Below the wrap they grow back; the feed re-fires UNIT_AURA on the change.
s.set_auras("target", Some(vec![debuff(1000, "D0", 1, None)]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
assert_eq!(size(&s, "TargetFrameDebuff1"), (21.0, 21.0));
@@ -270,7 +249,6 @@ fn clearing_the_list_or_the_target_hides_the_buttons() {
target(&mut s, 2, vec![debuff(589, "Pain", 1, Some("Magic"))]);
assert!(shown(&s, "TargetFrameDebuff1"));
// The last debuff expires: the feed pushes the emptied list and re-fires.
s.set_auras("target", Some(vec![]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
assert!(
@@ -278,7 +256,7 @@ fn clearing_the_list_or_the_target_hides_the_buttons() {
"an emptied list hides the button"
);
// Deselect: the frame (and every child button) hides; the token clears without a UNIT_AURA.
// Deselect: the frame and its buttons hide; the token clears without a UNIT_AURA.
target(&mut s, 2, vec![debuff(589, "Pain", 1, Some("Magic"))]);
assert!(shown(&s, "TargetFrameDebuff1"));
s.set_unit("target", None);
+17 -48
View File
@@ -1,16 +1,12 @@
//! The shipped taxi-map window driven end-to-end, engine-only (no Bevy): the real
//! `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.
//! The stock taxi map (`TaxiFrame.xml`) fed a two-node snapshot shaped as `crate::ui_taxi`'s feed
//! pushes it: `TAXIMAP_OPENED` shows it and builds the node buttons, a click takes the node, and
//! `TAXIMAP_CLOSED` hides it.
use benilla_ui::script::{ScriptValue, TaxiNodeType, TaxiUiNode, TaxiUiState, UiScript};
use super::test_ui::load_ui as load_xml;
/// Load the taxi window + its deps into a fresh script, screen sized.
/// The taxi window and its dependencies.
fn taxi_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -22,23 +18,17 @@ fn taxi_script() -> UiScript {
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 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\\GameTooltip.xml"); // TaxiNodeOnButtonEnter's tooltip
load_xml(&s, "Interface\\FrameXML\\UIErrorsFrame.xml");
// UIErrorsFrame takes `DrawOneHopLines`' refusal; a missing `ERR_TAXINOPATHS` draws blank.
// Without UIPanelTemplates, `TaxiCloseButton` loses its `UIPanelCloseButton` handler.
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\TaxiFrame.xml");
s
}
/// Seat the flight master as the `"npc"` unit. Stock `TaxiFrame_OnEvent` fills its label from
/// `UnitName("npc")` (TaxiFrame.lua:27), where our deleted transcription read the `TAXIMAP_OPENED`
/// argument — so a fixture that only fires the event leaves the reference's label blank.
/// Seat the flight master as "npc": the label reads `UnitName("npc")` (`TaxiFrame.lua:27`).
fn seat_flight_master(s: &mut UiScript, name: &str) {
let npc = benilla_ui::script::UnitState {
name: Some(name.into()),
@@ -47,8 +37,8 @@ fn seat_flight_master(s: &mut UiScript, name: &str) {
s.set_unit("npc", Some(npc));
}
/// A two-node snapshot: Stormwind (Current) and the verified Sentinel Hill hop (Reachable, 110
/// copper, one route segment) — [`crate::ui_taxi::build_nodes`]'s exact real-data shape.
/// Stormwind (Current) and the Sentinel Hill hop (Reachable, 110 copper, one route), as
/// `ui_taxi`'s `build_nodes` builds them.
fn menu() -> TaxiUiState {
TaxiUiState {
art: "Interface\\TaxiFrame\\TAXIMAP0".into(),
@@ -71,11 +61,6 @@ fn menu() -> TaxiUiState {
}
}
/// The whole taxi window minus Bevy: hidden by default, `TAXIMAP_OPENED` shows it with the flight
/// master's name (the event-arg deviation TaxiFrame.xml's header note flags), paints exactly the
/// pushed node buttons (the rest of the 50-slot static pool stays hidden — the CreateFrame/template
/// deviation), a click on the reachable node drains through `TakeTaxiNode`, and `TAXIMAP_CLOSED`
/// hides it again.
#[test]
fn shipped_taxi_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
@@ -93,24 +78,17 @@ fn shipped_taxi_frame_drives_end_to_end() {
assert!(s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
assert_eq!(
// The reference names the flight-master label `TaxiMerchant` and fills it from
// `UnitName("npc")` (TaxiFrame.lua:27) — NOT from the event's argument, which is what our
// transcription's `TaxiNameText` read. So the fixture seats the npc unit above.
s.eval::<String>("return TaxiMerchant:GetText()").unwrap(),
"Dungar Longdrink"
);
// The two pushed nodes show, and there is no third button AT ALL — the reference builds its
// node buttons on demand, one `CreateFrame("Button", "TaxiButton"..i, TaxiRouteMap,
// "TaxiButtonTemplate")` per node (TaxiFrame.lua:39), where our transcription pre-made a fixed
// pool and hid the tail. Same thing on screen, a different mechanism behind it — so the
// expectation moves from "hidden" to "absent".
// Node buttons are created on demand, one per node (`TaxiFrame.lua:39`): no third exists.
assert!(s.eval::<bool>("return TaxiButton1:IsVisible()").unwrap());
assert!(s.eval::<bool>("return TaxiButton2:IsVisible()").unwrap());
assert!(s.eval::<bool>("return TaxiButton3 == nil").unwrap());
assert!(s.eval::<bool>("return TaxiButton50 == nil").unwrap());
// A click on node 2 (Sentinel Hill) drains through TakeTaxiNode.
// A click on node 2, Sentinel Hill, calls `TakeTaxiNode`.
s.resolve();
let (cx, cy) = s
.eval::<(f32, f32)>("return TaxiButton2:GetCenter()")
@@ -120,18 +98,14 @@ fn shipped_taxi_frame_drives_end_to_end() {
assert_eq!(s.take_taxi_node(), vec![2]);
assert!(s.take_taxi_node().is_empty(), "drained");
// TAXIMAP_CLOSED hides the window.
s.set_taxi(None);
s.fire_event("TAXIMAP_CLOSED", vec![]);
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 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 `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.
/// With no reachable node, `DrawOneHopLines` posts `ERR_TAXINOPATHS` and hides the window
/// (`TaxiFrame.lua:161`).
#[test]
fn no_single_hop_destination_posts_the_error_and_closes() {
benilla_formats::wow_data_or_skip!();
@@ -153,8 +127,6 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// OnShow ran DrawOneHopLines, which found zero single-hop nodes: the red error line posted
// and the window hid itself again — the reference's own reaction to a dead-end flight point.
assert!(!s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
s.resolve();
let quads = s.extract();
@@ -170,8 +142,7 @@ fn no_single_hop_destination_posts_the_error_and_closes() {
);
}
/// The close button's own click path: `CloseTaxiMap()` queues the intent this engine drains, and
/// the window hides immediately (no one-frame lag) — the Gossip/Trainer close-button precedent.
/// The close button hides the window, whose OnHide calls `CloseTaxiMap()` (`TaxiFrame.xml:167`).
#[test]
fn close_button_queues_the_intent_and_hides() {
benilla_formats::wow_data_or_skip!();
@@ -184,9 +155,7 @@ fn close_button_queues_the_intent_and_hides() {
);
assert!(s.eval::<bool>("return TaxiFrame:IsVisible()").unwrap());
// The reference's close button is a plain `UIPanelCloseButton` (TaxiFrame.xml:133) — it has no
// handler of its own, so the click goes through the template's, which hides the parent panel.
// Ours carried a named `BenillaTaxiCloseButton_OnClick`; that went with the file.
// `TaxiCloseButton` is a plain `UIPanelCloseButton` (`TaxiFrame.xml:133`).
s.run("TaxiCloseButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.take_taxi_close());
+119 -367
View File
@@ -1,44 +1,22 @@
//! **The tests' interface loader — one copy, both stores** (decision 1751).
//!
//! Half a dozen test files carried an identical private `load_xml` that read
//! `assets/ui/<name>` off disk, parsed it, and asserted the loader reported no errors — each with
//! the comment "duplicated so this file is self-contained". That was fine while `assets/ui` was
//! the only place an interface file could come from. It is not any more: a migrated window's file
//! lives in the player's own patch chain, so a test that wants bag windows has to name
//! `Interface\FrameXML\ContainerFrame.xml`, and six copies of a disk-only reader cannot say that.
//!
//! [`load_ui`] is that reader, generalised the same way [`super::manifest::load_manifest`] is:
//! **a bare filename is a file we ship, a path is one off the player's install.** The two are told
//! apart by [`super::reference_ui::is_chain_entry`], the manifest's own rule, so a test loads the
//! entries in the order `benilla.toc` names them and gets what the client gets.
//!
//! It reads the SOURCE TREE rather than the compiled-in copy, deliberately: these tests exist to
//! catch a mistake in a file somebody just edited, and `content::read`'s dev-build probe already
//! prefers disk for the same reason.
//! The tests' interface loader: a bare filename is a file we ship under `assets/ui`, a path one off
//! the player's patch chain ([`super::reference_ui::is_chain_entry`], the manifest's rule). It
//! reads the source tree, not the compiled-in copy.
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).
///
/// `entry` is a manifest entry: `"BagFrame.xml"` for one of ours, or
/// `"Interface\\FrameXML\\ContainerFrame.xml"` for the reference's own — which also brings its
/// `<Script file="ContainerFrame.lua"/>` off the chain, exactly as it does in a real load.
///
/// **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.
/// Load one interface file into `s`, panicking on any loader error; returns the frames it built
/// (0 for a `.lua`). A chain entry needs client data, so a test naming one opens with
/// `benilla_formats::wow_data_or_skip!()`.
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.
/// The cooldown indicator's file, 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.
/// The cooldown indicator's file facts (`benilla-extract m2seq`), handed to the engine as the app
/// does: sequence 0 (id 0, 1000 ms, clamp) is the sweep `CooldownFrame_OnUpdateModel` scrubs,
/// sequence 1 (id 1, 1000 ms, clamp) the finish flash whose end hides the frame.
pub(super) fn cooldown_facts(s: &mut UiScript) {
use benilla_ui::widget::{ModelFileFacts, SequenceFacts};
let seq = |anim_id, duration_ms| SequenceFacts {
@@ -56,9 +34,8 @@ pub(super) fn cooldown_facts(s: &mut UiScript) {
);
}
/// 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.
/// The play head `(anim_id, cursor_ms)` of the shown cooldown pane `owner` names, as the tile
/// renderer samples it.
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 {
@@ -78,9 +55,8 @@ pub(super) fn cooldown_play(s: &UiScript, owner: &str) -> Option<(u16, u32)> {
})
}
/// [`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.
/// [`cooldown_play`] for whichever cooldown pane is shown: `ContainerFrame_GenerateFrame` numbers
/// a bag's slots from the far end, so a one-item test asks for the sweep, not 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 {
@@ -96,35 +72,19 @@ pub(super) fn cooldown_play_any(s: &UiScript) -> Option<(u16, u32)> {
})
}
/// [`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
/// is built and simply has none of the template's art. So an under-loaded dependency list passes
/// [`load_ui`] and then loses a window's whole skin silently — which is why four of the social
/// windows' test modules grew this check by hand. It is one function now rather than four copies,
/// and any test may ask for it.
/// [`load_ui`], and a missing template fails too: the loader only warns, and the art is lost.
pub(super) fn load_ui_strict(s: &UiScript, entry: &str) -> usize {
load_entry(s, entry, true, false)
}
/// [`load_ui`], and **no loader warning of any kind is tolerated**.
///
/// Stricter than [`load_ui_strict`], which only fails a missing template. A file whose own
/// assignment is "this loads perfectly clean" wants the whole warnings channel asserted empty — a
/// stale unknown-attribute or dropped-script warning is exactly the drift that check exists to
/// catch. It was a fourth private disk-only reader in `group_loot_tests.rs` until 1838; a
/// disk-only reader cannot name a chain file, which is what that test now loads.
/// [`load_ui`], and any loader warning fails.
pub(super) fn load_ui_no_warnings(s: &UiScript, entry: &str) -> usize {
load_entry(s, entry, false, true)
}
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.
// The app registers its CVars before any file loads: stock `UIOptionsFrame.xml` reads two
// camera CVars at `OnLoad` and raises on a nil. A value a test already set stands.
s.register_cvars(crate::cvars::registered_pairs());
let path = entry.replace('\\', "/");
let bytes = read(&path).unwrap_or_else(|| panic!("{entry}: not found"));
@@ -136,9 +96,8 @@ 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).
// Seated before the load: `MultiActionBarFrame_OnLoad` indexes `UIOptionsFrameCheckButtons`
// during this load walk.
if path
.rsplit('/')
.next()
@@ -147,14 +106,8 @@ fn load_entry(s: &UiScript, entry: &str, strict_templates: bool, no_warnings: bo
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.
// Our options window's Graphics rows read stock `OptionsFrameSliders` (`OptionsFrame.lua`) at
// OnLoad; the manifest loads it one seat above ours, so a kit seats it before our file.
if path
.rsplit('/')
.next()
@@ -206,32 +159,9 @@ 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 (`0x701bd0`: 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).
/// What a kit owes stock `UIParent.xml`: no-op stand-ins, seated when it loads, for what its
/// `<OnUpdate>`, `UIParent_OnEvent` and `ShowUIPanel` call from files a kit may stop short of. A
/// `function X()` is a plain global write, so the real definition replaces a stand-in in any order.
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
@@ -333,32 +263,9 @@ pub(super) const UIPARENT_STAND_INS: &str = r#"
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).
/// What a kit owes stock `UIOptionsFrame.xml`: its `VARIABLES_LOADED` arm
/// (`UIOptionsFrame.lua:193-227`) calls six functions from files a kit may not load, and a kit
/// fires the event itself. Functions, so seating them at load is safe in any order.
pub(super) const UIOPTIONS_STAND_INS: &str = r#"
BuffButtons_UpdatePositions = BuffButtons_UpdatePositions or function() end
FCF_Set_SimpleChat = FCF_Set_SimpleChat or function() end
@@ -373,6 +280,9 @@ pub(super) const UIOPTIONS_STAND_INS: &str = r#"
UpdatePartyMemberBackground = UpdatePartyMemberBackground or function() end
"#;
/// What a kit owes stock `MultiActionBars.xml`: its OnLoad (`MultiActionBars.lua:10`) writes five
/// rows into `UIOptionsFrameCheckButtons`, which `UIOptionsFrame.xml` declares eighteen toc rows
/// earlier; `or {}` keeps the real table when that file loaded first.
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
@@ -387,6 +297,11 @@ pub(super) const MULTI_ACTION_BAR_STAND_INS: &str = r#"
end
"#;
/// What a kit owes the stock micro-button row: `UpdateMicroButtons`
/// (`MainMenuBarMicroButtons.lua:20-84`) reads ten panels, `KeyRingButton` and `IsBagOpen`
/// unguarded. Hidden, unnamed stand-ins are seated on its first call, not at load: a named frame's
/// publish never overwrites (`0x701bd0`), so an early one would shadow the real frame.
/// `tests/common/mod.rs` carries the same chunk.
pub(super) const MICRO_BUTTON_STAND_INS: &str = r#"
local real = UpdateMicroButtons
function UpdateMicroButtons()
@@ -402,9 +317,8 @@ pub(super) const MICRO_BUTTON_STAND_INS: &str = r#"
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.
/// One file's bytes, off the chain for a path or from this crate's `assets/ui` for a bare name;
/// also the loader's `<Include>`/`<Script file=>` provider.
pub(super) fn read(req: &str) -> Option<Vec<u8>> {
if super::reference_ui::is_chain_entry(req) {
return super::reference_ui::read(req);
@@ -413,43 +327,9 @@ pub(super) fn read(req: &str) -> Option<Vec<u8>> {
std::fs::read(dir.join(req)).ok()
}
/// The files a test needs before it can open a bag window, in manifest order: the four templates
/// stock `ContainerFrame.xml` inherits, then the reference's own file, then our bag bar.
///
/// Named as a list rather than folded into a `setup()` because the callers all want to add their
/// own files around it (a merchant, the bank, the action bar art), and the ORDER is the thing
/// being reused — it is `benilla.toc`'s, trimmed to what the bags actually reach for.
/// What the **loot window** needs before `Interface\FrameXML\LootFrame.xml` will load and behave —
/// the same shape as [`BAG_UI`], for the same reason, and it grew for 1751 exactly as that one did.
///
/// Three of these are load-bearing in a way that is invisible if you leave them out:
///
/// * **`GlobalStrings.lua`** — the stock file concatenates `GROUP` and `GIVE_LOOT` while building
/// the master-loot menu. The app loads the player's own copy ahead of the whole manifest at VM
/// setup; a bare `UiScript::new()` does not. Our deleted `LootFrame.xml` carried
/// `X = X or "…"` fallbacks for precisely these; the stock file carries none.
/// * **`ItemButtonTemplate.xml`** — stock `LootButtonTemplate` inherits it, and a missing template
/// is a loader *warning*, not an error. Leave it out and every row loads clean with no art and
/// no `$parentIconTexture`, which reads as a pass until an assertion looks for an icon.
/// * **`PartyFrame.xml`** — for `MAX_PARTY_MEMBERS`, which stock `LootFrame.lua:217` does
/// arithmetic on at LOAD time, not at click time. Its home is the reference's own
/// (`PartyMemberFrame.lua:1`), and it wants the dropdown kit and `UnitPopup` ahead of it — the
/// manifest's order (167 → 185 → 262), reproduced rather than short-circuited. Setting the
/// constant by hand would pass and teach nothing about the real load.
///
/// Needs client data, like [`BAG_UI`]: open with `benilla_formats::wow_data_or_skip!()`.
/// What the **vendor window** needs before `Interface\FrameXML\MerchantFrame.xml` will load and
/// behave — the same shape as [`BAG_UI`] and [`LOOT_UI`], and it grew for 1751 the same way.
///
/// The two that fail in the ways worth naming:
///
/// * **`BasicControls.xml`** — for `TEXT()`, the reference's identity-function wrapper, which
/// stock `MerchantFrame.lua:70` calls while building every row. Absent, it raises on the first
/// `MerchantFrame_UpdateMerchantInfo`, which is the first thing the window does when it shows.
/// * **`Interface\FrameXML\ItemButtonTemplate.xml`** — the stock rows' `$parentItemButton`
/// inherits it, and a missing template is a loader *warning*: the rows load clean with no art.
///
/// Needs client data: open with `benilla_formats::wow_data_or_skip!()`.
/// What stock `MerchantFrame.xml` needs to load and behave, including `BasicControls.xml` for the
/// `TEXT()` that `MerchantFrame.lua:70` calls on first show and `ItemButtonTemplate.xml`, whose
/// absence only warns. Needs client data: open with `benilla_formats::wow_data_or_skip!()`.
pub(super) const MERCHANT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
@@ -460,8 +340,7 @@ pub(super) const MERCHANT_UI: &[&str] = &[
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"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).
// The stock window tab, whose `<OnShow>` needs the `UIPanelTemplates` pair above it.
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
@@ -469,14 +348,9 @@ pub(super) const MERCHANT_UI: &[&str] = &[
"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!()`.
/// Stock `GossipFrame.xml`'s dependencies in `benilla.toc` order, shared with `ui_gossip`'s feed
/// tests. The greeting pane inherits `UIPanelScrollFrameTemplate`, and a missing template only
/// warns, losing the scrollbar silently. Needs client data.
pub(crate) const GOSSIP_UI: &[&str] = &[
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
@@ -490,13 +364,17 @@ pub(crate) const GOSSIP_UI: &[&str] = &[
r"Interface\FrameXML\StaticPopup.xml",
];
/// What stock `LootFrame.xml` needs to load and behave. `GlobalStrings.lua` because a bare VM
/// lacks what the app loads at setup (the master-loot menu reads `GROUP` and `GIVE_LOOT`),
/// `ItemButtonTemplate.xml` because a missing template only warns, and `PartyFrame.xml` because
/// `LootFrame.lua:217` reads `MAX_PARTY_MEMBERS` at load. Needs client data.
pub(super) const LOOT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
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\UIParent.xml", // UIParent.lua: the panel slot manager and the fades
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
@@ -504,104 +382,55 @@ pub(super) const LOOT_UI: &[&str] = &[
"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` 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.
// `UnitPopup.lua` reads `ITEM_QUALITY_COLORS` at file scope (`UnitPopup.lua:47-49`), so its
// declarer, `UIParent.lua:65`, precedes 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
// four rows that each raise on their own OnLoad — the loader reports it, but only because
// `load_ui_strict` looks; a plain load would have gone quiet.
// Each `PartyMemberFrame<N>` and its pet frame run `UnitFrame_Initialize` (UnitFrame.lua) at
// OnLoad, which calls `SetTextStatusBarText` (TextStatusBar.lua).
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\UnitFrame.xml",
// …and `RefreshBuffs`, which `PartyMemberFrame.lua:60` calls from each row's OnLoad. Its home
// is BuffFrame.lua, which the chain's BuffFrame.xml sources — the reference's own toc has
// BuffFrame at 40 and PartyFrame at 45 for exactly this reason.
// `RefreshBuffs`, which each party row's OnLoad reaches (`PartyMemberFrame.lua:60`), is in
// BuffFrame.lua: the reference's toc has BuffFrame at 40 and PartyFrame at 45.
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
];
/// What the **character window** needs before `Interface\FrameXML\CharacterFrame.xml`,
/// `PaperDollFrame.xml` and `PetPaperDollFrame.xml` will load and behave — the same shape as
/// [`BAG_UI`] / [`LOOT_UI`] / [`MERCHANT_UI`], and grown the same way (decision 1751).
///
/// This one is the longest of the four, and the reason is `CharacterFrame_OnLoad`: it is the only
/// migrated window whose LOAD-time body reaches outside its own file, and it reaches into four
/// others at once —
///
/// * **`Interface\FrameXML\TextStatusBar.lua`** for `SetTextStatusBarTextPrefix`, which it calls
/// three times before doing anything else. Its `.xml` twin comes too, because the pet page's XP
/// bar inherits the `TextStatusBar` template it declares and a missing template is a loader
/// *warning* — the bar would load with no art and no text region and read as a pass.
/// * **`Interface\FrameXML\PlayerFrame.xml`** for `PlayerFrameHealthBar` / `PlayerFrameManaBar`,
/// the frames those three calls name, and **`PetFrame.xml`** for `PetFrameHealthBar` /
/// `PetFrameManaBar`, which `CharacterFrame_OnShow` shows the text on. Both call
/// `UnitFrame_Initialize` in their OnLoad and `PlayerFrame` also calls `CombatFeedback_Initialize`,
/// 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.)
/// * **`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
/// that same OnLoad.
///
/// And two more that only bite later, which is exactly why they are written down:
///
/// * **`Interface\FrameXML\HonorFrame.xml`** — `PaperDollFrame_SetLevel` and `_SetGuild` write
/// `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.
/// * **`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!()`.
/// What stock `CharacterFrame.xml`, `PaperDollFrame.xml` and `PetPaperDollFrame.xml` need to load
/// and behave. `CharacterFrame_OnLoad` reaches `TextStatusBar.lua`, `PlayerFrame.xml`,
/// `MainMenuBar.xml` (`MainMenuExpBar`) and the `UIPanelTemplates` pair; two bite only on show:
/// `HonorFrame.xml`, whose labels `PaperDollFrame.lua:103` and `:122` write, and
/// `MainMenuBarMicroButtons.xml` (`MicroButtonTooltipText` on a tab hover). Needs client data.
pub(super) const CHARACTER_UI: &[&str] = &[
// The reference's own localized strings. The stock file has no `X = X or "…"` fallbacks —
// `PaperDollFrame_OnLoad` sets seven labels from them at LOAD, and `PaperDollFrame_SetStats`
// concatenates `SPELL_STAT0_NAME`..`4` on every repaint.
// The stock strings, which `PaperDollFrame_OnLoad` reads at load; there are no fallbacks.
"Interface\\FrameXML\\GlobalStrings.lua",
"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).
// `GetText`, the gendered-string helper `ReputationFrame.lua:65` calls for each standing.
r"Interface\FrameXML\LocaleProperties.lua",
"Interface\\FrameXML\\BasicControls.xml", // TEXT(), which every one of those label sets goes through
"Interface\\FrameXML\\BasicControls.xml", // TEXT(), which those labels go through
"Interface\\FrameXML\\ItemButtonTemplate.xml", // PaperDollItemSlotButtonTemplate's base
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate — the model panes' turntable
r"Interface\FrameXML\UIParent.xml", // Model_OnLoad/_Rotate*/_OnUpdate: the model 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).
// The unit frames' dropdowns call `UIDropDownMenu_Initialize` at load, so the kit and the menu
// table precede them.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.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
// their OnLoad. Neither file declares a frame; both are pure script.
// `UnitFrame_Initialize` and `CombatFeedback_Initialize`, called by the frames below at load.
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
// `PetFrame.xml`'s four `PetFrameBuff*` inherit `PartyBuffButtonTemplate`, which lives here.
// The manifest never names this file: it arrives through `PartyFrame.xml`'s
// `<Include file="PartyFrameTemplates.xml"/>`, and the reference's own toc puts PartyFrame (45)
// ahead of TargetFrame (46) and PetFrame (47) for exactly that reason. This kit wants the
// template and not four party member frames, so it takes the included file directly.
// `PetFrame.xml`'s debuff buttons inherit `PartyBuffButtonTemplate`, declared here; the
// manifest reaches this file only through `PartyFrame.xml`'s `<Include>`.
"Interface\\FrameXML\\PartyFrameTemplates.xml",
"Interface\\FrameXML\\PetFrame.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
@@ -609,40 +438,28 @@ pub(super) const CHARACTER_UI: &[&str] = &[
"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
// (`CharacterFrame.lua:25-32`), unguarded. A missing page is `attempt to index a nil value` on
// the very first `ToggleCharacter` — load-clean, then dead on the first click. Their own
// template dependencies come with them.
"ScrollTemplates.xml", // SkillFrame's faux list + trough
// Every page must exist before the window opens: `CharacterFrame_ShowSubFrame` hides each
// `CHARACTERFRAME_SUBFRAMES` page it is not showing, unguarded (`CharacterFrame.lua:25-32`).
"ScrollTemplates.xml", // our scroll kit
r"Interface\FrameXML\UIPanelTemplates.lua",
r"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).
// The stock window tab, whose `<OnShow>` needs the `UIPanelTemplates` pair above it.
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.
// ReputationFrame's detail check boxes inherit its `OptionsCheckButtonTemplate`.
"Interface\\FrameXML\\OptionsFrameTemplates.xml",
"Interface\\FrameXML\\CharacterFrame.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
"Interface\\FrameXML\\PetPaperDollFrame.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).
// `updateContainerFrameAnchors`, which `ReputationWatchBar_Update` calls when the bar moves
// (`ReputationFrame.lua:248`): in the reference the bar's presence reflows the bag row.
"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.
/// The manifest slice stock `FriendsFrame.xml` and `RaidFrame.xml` reach at load or on show, in its
/// order; [`load_social_ui`] adds the raid tab's LoadOnDemand addon.
pub(super) const SOCIAL_UI: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
@@ -662,8 +479,7 @@ pub(super) const SOCIAL_UI: &[&str] = &[
"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).
// The stock window tab, whose `<OnShow>` needs the `UIPanelTemplates` pair above it.
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\OptionsFrameTemplates.xml",
"Interface\\FrameXML\\ReputationFrame.xml",
@@ -685,9 +501,8 @@ pub(super) const SOCIAL_UI: &[&str] = &[
"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!`.
/// Load [`SOCIAL_UI`], then the raid tab's LoadOnDemand addon as the app reaches it: seated off the
/// chain and loaded by stock `RaidFrame_LoadUI`. Needs client data.
pub(super) fn load_social_ui(s: &mut UiScript) {
for f in SOCIAL_UI {
load_ui_strict(s, f);
@@ -696,115 +511,79 @@ pub(super) fn load_social_ui(s: &mut UiScript) {
s.run("RaidFrame_LoadUI()").unwrap();
}
/// The files a test needs before it can open a bag window, in `benilla.toc` order, trimmed to what
/// the bags reach for. Needs client data: open with `benilla_formats::wow_data_or_skip!()`.
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
// (`ui_script/mod.rs`, `setup_script`); a test VM has to say so itself. Not optional since
// 1751's third window: stock `MainMenuBarBagButtons.lua`'s hovers pass these straight into
// `GameTooltip:SetText`, and `SetText(nil)` raises rather than showing an empty plate. Our
// 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.
// The stock strings: the bar's hovers pass them to `GameTooltip:SetText`, which raises on nil.
"Interface\\FrameXML\\GlobalStrings.lua",
"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.
// `TEXT()`, which the backpack button's OnEnter and `BagSlotButton_OnEnter` call.
"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`: the twelve `ContainerFrame`s are its children, `updateContainerFrameAnchors`
// anchors each open bag to its parent, and `OpenAllBags` returns unless `UIParent` is visible.
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"ScrollTemplates.xml", // our scroll kit
"Interface\\FrameXML\\ItemButtonTemplate.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.
// `MainMenuBar.xml` and its templates: the bag bar's `parent="MainMenuBarArtFrame"` resolves
// at load, and `MainMenuBar_UpdateKeyRing` puts the keyring on the bar.
"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.
// `UpdateMicroButtons`, which the keyring's OnHide and OnShow call
// (`ContainerFrame.lua:117`, `:137`).
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// The dialog engine, after the UIPanelCloseButton it inherits (1960).
// The dialog engine, after the `UIPanelCloseButton` it inherits.
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
// exactly as it does in the manifest. Stock `BagSlotButtonTemplate`'s OnLoad *is*
// `PaperDollItemSlotButton_OnLoad()`, which gives each bag button its inventory-slot id
// (20..23 via `GetInventorySlotInfo`), its six event registrations and its first paint.
//
// The whole paper-doll file, because that is where the reference declares both — our
// `ItemSlotButtonTemplates.xml` held a transcribed copy of the template only because our own
// character window loaded too late to declare it, and it is deleted (decision 1751). Its
// companion `Interface\\FrameXML\\CharacterFrame.xml` is deliberately NOT here: this list is
// the bags, `PaperDollFrame` only names `CharacterFrame` in `parent=` (a missing parent is a
// loader warning, not an error), and `CharacterFrame_OnLoad` would drag in the unit frames,
// the XP bar and the text-status-bar file for a window no bag test opens.
// `BagSlotButtonTemplate` inherits `PaperDollItemSlotButtonTemplate`, and its OnLoad
// (`PaperDollItemSlotButton_OnLoad`) gives each bag button its inventory-slot id, 20..23.
// `CharacterFrame.xml` stays out: a missing `parent=` only warns.
"Interface\\FrameXML\\PaperDollFrame.xml",
// The bag BAR itself, the reference's own since 1751's third window: MainMenuBarBackpackButton,
// CharacterBag0..3Slot, KeyRingButton, `BagSlotButtonTemplate`, and `KEYRING_CONTAINER`.
// The stock bag bar, with `BagSlotButtonTemplate` and `KEYRING_CONTAINER`.
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
// `StackSplitFrame` is not optional either: the reference's own
// `ContainerFrameItemButton_OnClick` calls `StackSplitFrame:Hide()` on EVERY plain click
// (ContainerFrame.lua l.581) before the pickup, and opens it on the shift fork.
// `ContainerFrameItemButton_OnClick` hides `StackSplitFrame` on every plain click
// (`ContainerFrame.lua:581`) and opens it on the shift fork.
"Interface\\FrameXML\\StackSplitFrame.xml",
// …nor is the chat edit box. The reference's SHIFT arm opens with
// `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.
// The chat edit box: the shift arm tests `ChatFrameEditBox:IsShown()`
// (`ContainerFrame.lua:568`) to choose between linking the item and splitting the stack.
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"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
// `UIParent.xml`.
// Our adapters over the stock container files, loaded after them and the bar so they win.
"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
// scenery for a bag test — it is a hard dependency of the reference's own layout pass. It is
// one in the real client too; the manifest just satisfies it far below the bags. The
// reference's own file since 1751's second window.
// (`ContainerFrame.lua:505`) on every open and close, so the bank window is a hard dependency.
"Interface\\FrameXML\\BankFrame.xml",
];
/// The name of the `ContainerFrame` currently showing bag `id`, or `None` if it is not open.
///
/// **Ask, never assume.** The reference recycles twelve windows across every container
/// (`ContainerFrame_GetOpenFrame`), so which one a bag lands in depends on what else is open —
/// there is no `BenillaBagFrame2` to name any more, and pinning `ContainerFrame3` in a test would
/// pin a coincidence. `IsBagOpen` is the reference's own published scan.
/// The name of the `ContainerFrame` showing bag `id`, asked of `IsBagOpen`: the reference recycles
/// twelve windows across every container (`ContainerFrame_GetOpenFrame`).
pub(super) fn bag_window(s: &UiScript, id: i64) -> Option<String> {
s.eval::<Option<i64>>(&format!("return IsBagOpen({id})"))
.unwrap()
.map(|i| format!("ContainerFrame{i}"))
}
/// Is bag `id` open? [`bag_window`]'s predicate half.
/// Is bag `id` open?
pub(super) fn bag_open(s: &UiScript, id: i64) -> bool {
bag_window(s, id).is_some()
}
/// The item button in bag `id`'s open window that holds game slot `slot`.
///
/// Asked of the buttons' own `GetID`, never derived: `ContainerFrame_GenerateFrame` numbers them
/// backwards (`index = size - j + 1`, so `…Item1` is the bag's LAST slot, bottom-right), and a
/// window generated for a different bag size numbers them differently.
/// The item button in bag `id`'s open window that holds game slot `slot`, found by `GetID`:
/// `ContainerFrame_GenerateFrame` numbers the buttons backwards (`index = size - j + 1`).
pub(super) fn bag_slot_button(s: &UiScript, id: i64, slot: u32) -> String {
let w = bag_window(s, id).unwrap_or_else(|| panic!("bag {id} is not open"));
s.eval::<String>(&format!(
@@ -830,20 +609,14 @@ pub(super) fn centre_of(s: &mut UiScript, name: &str) -> (f32, f32) {
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
/// Move the mouse onto the centre of `name` — the whole engine path (hit test → `OnEnter`), never
/// `s.run("Handler(button)")`.
///
/// **This is not a style preference any more.** The reference's own handlers read `this`
/// (`ContainerFrameItemButton_OnClick(button, ignoreModifiers)` takes the MOUSE button as its first
/// argument and gets the frame from `this`), and only the engine sets `this`. A migrated window's
/// tests therefore drive the mouse, which is also the stronger test — it puts the
/// `RegisterForClicks` gate and the template's own script wiring under test.
/// Move the mouse onto the centre of `name` through the engine's hit test and `OnEnter`: stock
/// handlers read `this`, which only the engine sets.
pub(super) fn hover(s: &mut UiScript, name: &str) {
let (x, y) = centre_of(s, name);
s.mouse_move(x, y);
}
/// Move the mouse well clear of everything — the `OnLeave` half of [`hover`].
/// Move the mouse well clear of everything: the `OnLeave` half of [`hover`].
pub(super) fn unhover(s: &mut UiScript) {
s.mouse_move(-500.0, -500.0);
}
@@ -856,36 +629,16 @@ pub(super) fn click(s: &mut UiScript, name: &str, button: &str) {
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.
/// Seat stock `WorldFrame.xml` and `MirrorTimer.xml`: `WorldFrame_OnUpdate` loops to
/// `MIRRORTIMER_NUMTIMERS`, which `MirrorTimer.lua` declares, and raises every tick without it.
/// Its other loop reads `STATICPOPUP_NUMDIALOGS`, which every caller already loads.
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.
/// A completed left click on the game world, at a point the loaded `WorldFrame` owns: searched on
/// a grid, since a harness's windows may cover any fixed point.
pub(super) fn world_click(s: &mut UiScript) {
let (x, y) = world_point(s);
s.mouse_move(x, y);
@@ -896,7 +649,7 @@ pub(super) fn world_click(s: &mut UiScript) {
);
}
/// [`world_click`]'s search. Separate so the failure can say what it looked at.
/// [`world_click`]'s search.
fn world_point(s: &mut UiScript) -> (f32, f32) {
s.resolve();
let r: Vec<f32> = s
@@ -929,8 +682,7 @@ fn world_point(s: &mut UiScript) -> (f32, f32) {
}
/// 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!`.
/// `UIParentLoadAddOn(name)` loads it as the app does. Needs client data.
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)))
@@ -1,22 +1,14 @@
//! The tooltip ANCHOR law over the real shipped XMLs — where each hover SEATS the plate.
//!
//! The world/unit-frame/action-bar rows of the law all route through the ref's
//! `GameTooltip_SetDefaultAnchor` (ref GameTooltip.lua l.73-77): the screen's bottom-right
//! corner, `-CONTAINER_OFFSET_X - 13` in from the right, `CONTAINER_OFFSET_Y` up from the
//! bottom. These tests exist because the wiring had two silent holes only the live game showed
//! (the world tooltip parked ON the character): `GameTooltip.xml` never wired
//! `<OnTooltipSetDefaultAnchor>`, and the `UIParent` GLOBAL the ref handler passes didn't exist
//! — engine tests stubbed the handler, so nothing asserted the real files' geometry. Everything
//! here loads the shipped XMLs and asserts resolved rects / anchors, never a stub.
//! Where each hover seats the tooltip, over the stock files. World, unit-frame and action-bar
//! hovers use `GameTooltip_SetDefaultAnchor` (`GameTooltip.lua:73-77`): the screen's bottom-right
//! corner, `-CONTAINER_OFFSET_X - 13` in from the right and `CONTAINER_OFFSET_Y` up.
use benilla_ui::script::{AuraState, UiScript, UnitState};
use super::test_ui::load_ui as load_xml;
/// A 1024×768 screen with the anchor law's three fixed files (fonts, the real UIParent, the
/// real GameTooltip) plus `extra`. `CONTAINER_OFFSET_X/Y` hold their UIParent.xml load values
/// (0 / 70) — the manage pass only runs from the app's post-load bootstrap, so the expected
/// default corner in every test here is x = 1024−13 = 1011, y = 70.
/// A 1024x768 screen with fonts, `UIParent` and `GameTooltip`, plus `extra`. The kit seeds
/// `CONTAINER_OFFSET_X/Y` with the stock 0 and 70 (`ContainerFrame.lua:11-12`), so with no bar
/// raised the default corner is x = 1024 - 13 = 1011, y = 70.
fn harness(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -25,16 +17,14 @@ fn harness(extra: &[&str]) -> UiScript {
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).
// `FACTION_BAR_COLORS`, which `GameTooltip_UnitColor` indexes on every unit hover
// (`ReputationFrame.lua:3`).
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.
// The stock tooltip sizes from its lines, so its rect needs a text measurer. Detailed tips
// default on in 1.12 (`UIOptionsFrame.lua:100`); this kit loads no options window.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s
@@ -53,8 +43,7 @@ fn wolf() -> UnitState {
}
}
/// UIParent is a real, named, full-screen frame (ref UIParent.xml l.5) — the Lua global
/// resolves, and its rect IS the screen.
/// `UIParent` is a named, full-screen frame (`UIParent.xml:5`).
#[test]
fn uiparent_is_a_real_full_screen_frame() {
benilla_formats::wow_data_or_skip!();
@@ -71,11 +60,9 @@ fn uiparent_is_a_real_full_screen_frame() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The world mouseover seats the plate at the DEFAULT corner — the engine fires
/// `OnTooltipSetDefaultAnchor`, the shipped handler (ref GameTooltip.lua l.73-77 via
/// ref GameTooltipTemplate.xml l.617-619) anchors BOTTOMRIGHT to UIParent at (−13, 70).
/// THE regression test for the "tooltip on my character" bug: without the wiring the plate
/// kept its load-time position instead.
/// A world hover seats the plate at the default corner: the engine fires
/// `OnTooltipSetDefaultAnchor` (`GameTooltipTemplate.xml:617-619`), which anchors BOTTOMRIGHT to
/// `UIParent` at (-13, 70).
#[test]
fn world_hover_seats_the_default_corner() {
benilla_formats::wow_data_or_skip!();
@@ -93,21 +80,16 @@ fn world_hover_seats_the_default_corner() {
assert!(ok, "world tooltip sits at the screen's bottom-right corner");
}
/// Unit-frame hovers take the SAME default corner (ref UnitFrame_OnEnter l.56 calls
/// GameTooltip_SetDefaultAnchor, not an owner anchor).
///
/// Leave drops the plate AT ONCE, not on the fade ramp: `UnitFrame_OnLeave` l.84-88 branches on
/// `SHOW_NEWBIE_TIPS`, and 1.12's default is on (0661/0663) — `FadeOut` is the tips-off arm. The
/// world mouseover keeps the ramp; this is the unit *frame*.
/// Unit-frame hovers take the default corner too (`UnitFrame.lua:56`). With detailed tips on, the
/// 1.12 default, leaving hides the plate at once; `FadeOut` is the tips-off arm
/// (`UnitFrame.lua:84-88`).
#[test]
fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
benilla_formats::wow_data_or_skip!();
// The kit + popups precede the unit frames (their DropDown children's OnLoad), app order.
// The dropdown kit and popups precede the unit frames, whose dropdowns initialize at OnLoad.
let mut s = harness(&[
// The stock unit frames resolve GlobalStrings at LOAD (`CombatFeedback.lua` l.7-17,
// `UnitFrame.lua` l.1-6) and `UnitFrame_OnEnter` passes `PARTY_OPTIONS_LABEL` /
// `PLAYER_OPTIONS_LABEL` (l.60/63) straight into `GameTooltip:SetText`, which raises on
// nil. The app loads this file ahead of the whole manifest; so does the fixture.
// The unit frames read GlobalStrings at load, and `UnitFrame_OnEnter` passes
// `PARTY_OPTIONS_LABEL` to `SetText`, which raises on nil.
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -123,10 +105,8 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
"Interface\\FrameXML\\PetFrame.xml",
]);
s.set_unit("target", Some(wolf()));
// The reference's handler takes no arguments and reads `this` (ref UnitFrame.lua l.45), which
// is exactly how the stock frames wire it: `<OnEnter>UnitFrame_OnEnter();</OnEnter>`
// (ref TargetFrame.xml l.505, PlayerFrame.xml l.435). Our deleted transcription's
// `BenillaUnitFrame_OnEnter(frame)` adapter went with the file.
// `UnitFrame_OnEnter` takes no arguments and reads `this` (`UnitFrame.lua:47`), as the stock
// `<OnEnter>` calls it (`TargetFrame.xml:505`).
s.run("this = TargetFrame UnitFrame_OnEnter() this = nil")
.unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
@@ -142,14 +122,13 @@ fn unit_frame_hover_takes_the_default_corner_and_drops_on_leave() {
ok,
"unit-frame tooltip sits at the default corner, owned by the frame"
);
// A wolf is no player, so the hover is the ordinary unit readout — the fork below never fires.
// A wolf is not player-controlled, so the hover is the ordinary unit readout.
assert!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap()
.contains("Wolf"),
"a non-player target still gets the unit lines"
);
// Leave: gone on the spot, no ramp to wait out.
s.run("this = TargetFrame UnitFrame_OnLeave() this = nil")
.unwrap();
let hidden: bool = s.eval("return not GameTooltip:IsShown()").unwrap();
@@ -160,21 +139,14 @@ 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.
/// While the creature query is in flight a world hover is titled `UNKNOWNOBJECT`, read from the
/// chain's GlobalStrings (`0x703bf0`) so a translated install translates it; the answer then
/// 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.
// Before `SMSG_CREATURE_QUERY_RESPONSE`: the descriptor is in, the name and type word are not.
s.set_unit(
"mouseover",
Some(UnitState {
@@ -196,7 +168,6 @@ fn a_pending_name_hover_titles_the_chains_unknownobject() {
"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!(
@@ -207,22 +178,15 @@ fn a_pending_name_hover_titles_the_chains_unknownobject() {
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
/// target.
///
/// This is the most behaviour-changing thing in the newbie-tip arc, so it is pinned from both
/// sides: the explanation replaces the readout, and a non-player target still gets the readout
/// (the sibling test above).
/// With detailed tips on, a unit frame explains its right-click menu and returns before `SetUnit`
/// (`UnitFrame.lua:58-66`): your own portrait always, and any other frame but a party member's
/// while the target is another player-controlled unit.
#[test]
fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
benilla_formats::wow_data_or_skip!();
let mut s = harness(&[
// The stock unit frames resolve GlobalStrings at LOAD (`CombatFeedback.lua` l.7-17,
// `UnitFrame.lua` l.1-6) and `UnitFrame_OnEnter` passes `PARTY_OPTIONS_LABEL` /
// `PLAYER_OPTIONS_LABEL` (l.60/63) straight into `GameTooltip:SetText`, which raises on
// nil. The app loads this file ahead of the whole manifest; so does the fixture.
// The unit frames read GlobalStrings at load, and `UnitFrame_OnEnter` passes
// `PARTY_OPTIONS_LABEL` to `SetText`, which raises on nil.
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
@@ -259,8 +223,8 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
"it RETURNS before SetUnit — no health/level lines underneath"
);
// A player target takes the other arm. `player` stays a wolf here on purpose: the ref reads
// UnitIsPlayer on the "target" token alone, never on the hovered frame's own unit.
// A player target takes the other arm, which reads `UnitPlayerControlled("target")` alone
// (`UnitFrame.lua:62`), never the hovered frame's unit: `player` stays a wolf.
s.set_unit(
"target",
Some(UnitState {
@@ -281,14 +245,9 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// Action-bar hovers take the default corner too — ref ActionButton_SetTooltip l.366-372
/// branches on the UberTooltips CVar, whose stock default is "1" (byte-read from WoW.exe
/// 0x48fdd9 / default string 0x82e748; see `cvars::REGISTERED`). An empty slot renders nothing,
/// but the anchor must already be seated — asserted through GetPoint, resolved rect or not.
///
/// Registered, not merely absent: before B230 the CVar was not in the table at all, so `GetCVar`
/// answered nil and the "1" leg was reached by accident rather than by value. This seeds the real
/// table so the pass means what it says.
/// Action-bar hovers take the default corner: `ActionButton_SetTooltip` branches on `UberTooltips`
/// (`ActionButton.lua:366-372`), whose stock default is "1" (`0x48fdd9`, string `0x82e748`). An
/// empty slot draws nothing, so the anchor is read through `GetPoint`.
#[test]
fn action_button_hover_takes_the_default_corner() {
benilla_formats::wow_data_or_skip!();
@@ -321,16 +280,10 @@ fn action_button_hover_takes_the_default_corner() {
);
}
/// The other leg of that same branch, live since B230 registered the CVar: with `UberTooltips`
/// off, an action button's plate leaves the screen corner and seats BESIDE the button — LEFT for
/// the three bars the reference lists (`MultiBarBottomRight`, `MultiBarRight`, `MultiBarLeft`: the
/// ones at or against the right edge, whose plates have to open toward the centre), RIGHT for
/// everything else, including the main bar. All three exist here since 1219/1500; the set used to
/// be the first alone.
///
/// The two anchors are read off the resolved SetPoint pair, which is what `SetOwner` actually
/// writes: ANCHOR_RIGHT = the plate's BOTTOMLEFT on the button's TOPRIGHT, ANCHOR_LEFT its mirror
/// (`script/tooltip/verbs.rs`).
/// With `UberTooltips` off an action button's plate seats beside it: `ANCHOR_LEFT` under
/// `MultiBarBottomRight`, `MultiBarRight` and `MultiBarLeft`, `ANCHOR_RIGHT` elsewhere
/// (`ActionButton.lua:369-373`). `SetOwner` writes `ANCHOR_RIGHT` as the plate's BOTTOMLEFT on the
/// button's TOPRIGHT, and `ANCHOR_LEFT` as its mirror.
#[test]
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
benilla_formats::wow_data_or_skip!();
@@ -385,8 +338,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
"ANCHOR_RIGHT — the main bar is not in the ref's LEFT set"
);
// 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.
// The LEFT set is by parent frame, not visibility: the vertical bars are hidden here.
for bar in ["MultiBarBottomRight", "MultiBarRight", "MultiBarLeft"] {
s.run(&format!("this = {bar}Button1 ActionButton_SetTooltip()"))
.unwrap();
@@ -397,7 +349,6 @@ 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("this = MultiBarBottomRightButton1 ActionButton_SetTooltip()")
.unwrap();
@@ -408,10 +359,8 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
}
/// The stance bar's own leg of the same branch (ref BonusActionBarFrame.xml l.40-45 — ANCHOR_RIGHT
/// with no bar fork of its own), pinned separately because it is a different file's handler and a
/// different tooltip verb; it was collapsed to the "1" leg alongside the action bar's and
/// un-collapsed with it.
/// The stance buttons' own branch (`BonusActionBarFrame.xml:40-45`): the corner when
/// `UberTooltips` is on, `ANCHOR_RIGHT` when off.
#[test]
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
benilla_formats::wow_data_or_skip!();
@@ -463,9 +412,8 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
}
/// Buff hovers hang BELOW the button — ref BuffFrame.xml l.37 is ANCHOR_BOTTOMLEFT (the buff
/// row lives at the screen's top-right): the tooltip's TOPRIGHT seats on the button's
/// BOTTOMLEFT.
/// Buff hovers hang below the button: `ANCHOR_BOTTOMLEFT` (`BuffFrame.xml:37`) seats the plate's
/// TOPRIGHT on the button's BOTTOMLEFT.
#[test]
fn buff_hover_hangs_below_left_of_the_button() {
benilla_formats::wow_data_or_skip!();
@@ -500,14 +448,11 @@ fn buff_hover_hangs_below_left_of_the_button() {
channeled: false,
}]),
);
// The reference's own event, which the buff buttons register for (`ui_aura` fires it beside
// the Era-shaped UNIT_AURA on the same rebuild).
// The event the stock buff buttons register (`BuffFrame.lua:113`).
s.fire_event("PLAYER_AURAS_CHANGED", vec![]);
s.resolve();
// Through the template's real `<OnEnter>` — the reference keeps the SetOwner/SetPlayerBuff pair
// inline there rather than in a named function, so the handler body itself is what this drives.
// `this` is set by hand because the engine sets it only when it *fires* a handler (`0x704d50`);
// calling the compiled function directly does not, and the body reads `this`, not its argument.
// The template's inline `<OnEnter>`, called directly: the engine sets `this` only when it
// fires a handler (`0x704d50`), so it is set by hand.
s.run("this = BuffButton0; BuffButton0:GetScript(\"OnEnter\")(BuffButton0)")
.unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
@@ -524,15 +469,9 @@ fn buff_hover_hangs_below_left_of_the_button() {
);
}
/// **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.
/// The cursor-seated GameObject plate is owned: the reference's publisher sets the owner through
/// the SetOwner core (`0x492a01` calls `0x52ffe0(owner, 6, 0, 0)`, whose `0x53000c` writes
/// `+0x314`). The cursor arm fires no `OnTooltipSetDefaultAnchor`, so no Lua sets it.
#[test]
fn a_cursor_seated_gameobject_plate_is_owned() {
benilla_formats::wow_data_or_skip!();
@@ -546,18 +485,9 @@ fn a_cursor_seated_gameobject_plate_is_owned() {
);
}
/// **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.
/// An addon `OnShow` hook that calls `GameTooltip:Show()` (as `!Questie`'s does) leaves the plate
/// up: `:Show()` is the existence gate `0x530a80`, which hides a plate with no owner or no lines
/// (`0x530a60`), and the publisher sets the owner before the plate shows.
#[test]
fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
benilla_formats::wow_data_or_skip!();
@@ -576,17 +506,10 @@ fn a_cursor_seated_gameobject_plate_survives_an_addons_on_show_hook() {
);
}
/// **The CORNER arm must end up owned too — the other half of the existence gate** (decision 2259).
///
/// The reference's corner arm (`0x492a42`) writes no owner itself; the owner is restored purely by
/// the `+0x444` handler, `OnTooltipSetDefaultAnchor` → `GameTooltip_SetDefaultAnchor(this,
/// UIParent)` → `SetOwner(UIParent, "ANCHOR_NONE")`. And the owner really is 0 on the way in:
/// `Tooltip::Hide 0x530a60` *is* `SetOwner(NULL, 0, 0, 0)`, so every hover starts un-owned.
///
/// That makes this test the precondition for narrowing the placement fork at all. Moving an object
/// from the cursor arm to the corner arm is only safe while the corner arm produces an OWNED,
/// SHOWN plate — otherwise those objects would build their lines and then hide, which is precisely
/// the failure 2255 had just fixed on the cursor arm.
/// The corner arm (`0x492a42`) writes no owner itself: its `+0x444` handler,
/// `OnTooltipSetDefaultAnchor`, sets it through `GameTooltip_SetDefaultAnchor`'s
/// `SetOwner(UIParent, "ANCHOR_NONE")`. Every hover starts unowned, since `Tooltip::Hide`
/// (`0x530a60`) is `SetOwner(NULL, 0, 0, 0)`.
#[test]
fn a_corner_seated_gameobject_plate_is_owned_and_shown() {
benilla_formats::wow_data_or_skip!();
@@ -600,8 +523,7 @@ fn a_corner_seated_gameobject_plate_is_owned_and_shown() {
assert!(shown, "and it is on screen; errors: {:?}", s.errors());
}
/// And it survives the same addon hook the cursor arm had to: `!Questie`'s `OnShow` handler ends in
/// `GameTooltip:Show()`, and `:Show()` is the existence gate `0x530a80`.
/// The corner plate survives the same `OnShow` hook through the existence gate `0x530a80`.
#[test]
fn a_corner_seated_gameobject_plate_survives_an_addons_on_show_hook() {
benilla_formats::wow_data_or_skip!();
@@ -1,21 +1,7 @@
//! 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`.
//!
//! **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
//! (`SetMerchantCompareItem 0x536080`). 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 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).
//! The shopping-compare and chat-link tooltips over the stock files. 1.12.1 has no hover compare:
//! `SHOW_COMPARE_TOOLTIP` (event 377) has no fire site, so `PaperDollFrame.lua:621` is dead code
//! and the vendor and auction rows are the plates' only consumers (`SetMerchantCompareItem`,
//! `0x536080`). The stock hover handlers read `this`, so the tests move the mouse.
use benilla_ui::script::{
ContainerSlot, ContainerState, InvSlotView, InventorySlots, ItemTemplateView, UiScript,
@@ -24,10 +10,8 @@ use benilla_ui::script::{
use super::test_ui::{bag_slot_button, hover, BAG_UI, CHARACTER_UI};
/// The two shared lists overlap heavily — `GlobalStrings`, the fonts, `UIParent`, the tooltip,
/// `Cooldown`, the action bar and `Interface\FrameXML\PaperDollFrame.xml` are in both — and
/// loading one file twice redeclares its frames. So a list is walked once and anything already
/// loaded is skipped, which also keeps the ORDER the first list asked for.
/// Loads each file once across the overlapping lists, in first-seen order: loading a file twice
/// redeclares its frames.
fn load_once(s: &UiScript, seen: &mut Vec<&'static str>, files: &[&'static str]) {
for f in files {
if seen.contains(f) {
@@ -38,12 +22,7 @@ fn load_once(s: &UiScript, seen: &mut Vec<&'static str>, files: &[&'static str])
}
}
/// The window set the compare flow crosses **without a bag window**: the whole character window
/// (the listener's doll slots), plus [`ROUTER_UI`].
///
/// **Needs client data.** It always did — `Interface\FrameXML\ItemRef.xml` has been a chain entry
/// since it was pointed there — but the comment here claimed "deliberately install-free" until
/// 1751 made the claim impossible to miss. Callers open with `wow_data_or_skip!`.
/// The character window plus [`ROUTER_UI`], no bag window; needs client data.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -55,21 +34,16 @@ fn harness() -> UiScript {
s
}
/// The two reference files this harness carries beyond the character window: `MerchantFrame.xml`,
/// 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).
/// `MerchantFrame.xml`, then `ItemRef.xml` with its `ItemRefTooltip`, in `FrameXML.toc` order.
const ROUTER_UI: [&str; 4] = [
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
"ScrollTemplates.xml", // our scroll kits
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\ItemRef.xml",
];
/// A level-60 player carrying **both** halves of the race and class pairs: `UnitRace`/`UnitClass`
/// answer `(localized, file)` or `nil, nil` — the binding `zip`s them — and stock
/// `PaperDollFrame_SetLevel` formats all three into `CharacterLevelText` unguarded
/// (`PaperDollFrame.lua:100-104`) on every show of the character window.
/// Race and class carry both halves: stock `PaperDollFrame_SetLevel` formats them unguarded on
/// every show of the character window (`PaperDollFrame.lua:101`).
fn player() -> UnitState {
UnitState {
exists: true,
@@ -82,13 +56,8 @@ fn player() -> UnitState {
}
}
/// [`harness`] with the REFERENCE's bag windows (1751) — the hover source. `BAG_UI` is the ordered
/// set a test needs before it can open one; `CHARACTER_UI` leads because the manifest seats the
/// character block above the containers (`FrameXML.toc` 53-58 → 65) and because it carries
/// `PaperDollFrame.xml`, which `MainMenuBarBagButtons` inherits its `BagSlotButtonTemplate` body
/// from.
///
/// **Needs client data**: both lists name chain entries, so callers open with `wow_data_or_skip!`.
/// [`harness`] with the stock bag windows. `CHARACTER_UI` leads, as in `FrameXML.toc`, because
/// `BagSlotButtonTemplate` inherits `PaperDollItemSlotButtonTemplate` from `PaperDollFrame.xml`.
fn harness_with_bags() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -100,8 +69,7 @@ fn harness_with_bags() -> UiScript {
s
}
/// An equipped helm in the head slot + a better helm in the backpack, both with templates —
/// the compare pair.
/// The compare pair: a helm in the head slot and a better one in the backpack.
fn seed_items(s: &mut UiScript) {
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
@@ -181,9 +149,7 @@ fn seed_items(s: &mut UiScript) {
);
}
/// 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.
/// `SetItemRef` shows `ItemRefTooltip` with `ANCHOR_PRESERVE`, keeping its XML seat, `BOTTOM` +80.
#[test]
fn item_ref_tooltip_renders_a_chat_link() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -203,7 +169,7 @@ fn item_ref_tooltip_renders_a_chat_link() {
)
.unwrap();
assert!(ok, "the link tooltip shows at its parked seat");
// The close button (ref ItemRefCloseButton): HideUIPanel drops it.
// What `ItemRefCloseButton`'s `OnClick` runs.
s.run("HideUIPanel(ItemRefTooltip)").unwrap();
assert!(
!s.eval::<bool>("return ItemRefTooltip:IsShown()").unwrap(),
@@ -212,17 +178,15 @@ fn item_ref_tooltip_renders_a_chat_link() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// Hovering the EQUIPPED item itself (a paper-doll slot) renders the INSTANCE — the live
/// 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).
/// A doll slot hovers through `SetInventoryItem` (`PaperDollFrame.lua:741`), so it shows the
/// instance's live durability, not the template's maximum.
#[test]
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()));
seed_items(&mut s);
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
// A broken helm: instance pair (0, 40), the template at full.
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
duration_ms: None,
@@ -257,20 +221,14 @@ fn doll_hover_renders_the_live_instance() {
},
);
// Arm a compare first through a BAG hover (the stale-arm hazard the doll hover must clear) —
// the reference's own `ContainerFrameItemButton_OnEnter` since 1751, reached by moving the
// mouse onto the button rather than by calling the handler.
// A bag hover first arms a compare, which the doll hover must clear.
s.run("MainMenuBarBackpackButton:Click()").unwrap();
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();
// 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
// OFF the bag button is part of what this test needs anyway.
hover(&mut s, "CharacterHeadSlot");
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let found: String = s
@@ -290,27 +248,16 @@ fn doll_hover_renders_the_live_instance() {
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.
/// `MerchantItemButton`'s `OnEnter` (`MerchantFrame.xml:67`) seats plate 1 at the tooltip's
/// `TOPRIGHT` (0, -10) and plate 2 off plate 1. The compare call passes p4 = 0, so a plate is the
/// worn item's ordinary tooltip under a gray `Currently Equipped` line.
#[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.
// The ring under plate 1 is epic with flavour text, both of which the plate keeps.
for (slot, id, name, quality) in [
(11usize, 7000u32, "Old Loop", 4u32),
(12, 7001, "Older Loop", 1),
@@ -349,7 +296,6 @@ fn shipped_merchant_row_raises_the_compare_plates() {
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
@@ -371,7 +317,6 @@ fn shipped_merchant_row_raises_the_compare_plates() {
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() \
@@ -387,7 +332,7 @@ fn shipped_merchant_row_raises_the_compare_plates() {
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.
// The plate's small-font ladder is 10 px; the main tooltip's header face is larger.
let ok: bool = s
.eval(
"local _, sh = ShoppingTooltip1TextLeft1:GetFont() \
@@ -399,7 +344,6 @@ fn shipped_merchant_row_raises_the_compare_plates() {
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(
@@ -411,8 +355,7 @@ fn shipped_merchant_row_raises_the_compare_plates() {
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// An armour template in one `InventoryType` — the CLASS is what the selection law compares by
/// (the class test at `0x536262` in `SetMerchantCompareItem 0x536080`).
/// An armour template; `SetMerchantCompareItem` selects by item class (`0x536262`).
fn armor_template(name: &str, inventory_type: u32) -> ItemTemplateView {
ItemTemplateView {
name: name.into(),
@@ -1,43 +1,19 @@
//! `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
//! untouched). It is a test of the *template*, which is a different surface with a different
//! consumer: `GameTooltipTemplate` is the single most-wanted missing template in the addon corpus
//! — 27 addons name it in an XML `inherits=` and two more reach it through `CreateFrame`
//! (`addon_harness::inherits_demand` / `template_demand` over the 218-addon corpus).
//!
//! Every shape below is a real addon's, quoted, not one invented to be convenient:
//!
//! - **The declaration** is `<GameTooltip name="…" inherits="GameTooltipTemplate">` — all 27 use
//! the `<GameTooltip>` element (not `<Frame>`), none use a comma-separated `inherits` list, and
//! the two `CreateFrame` sites both pass `kind = "GameTooltip"` with `parent = nil`
//! (BetterCharacterStats/helper.lua l.3, BigWigs/Raids/Naxxramas/Loatheb.lua l.75).
//! - **The `$parent` names are the point.** 120 corpus lines read
//! `getglobal(tip:GetName().."TextLeft"..i)` or the hardcoded equivalent; a template whose
//! children came out named after the *template* would be useless, and a tooltip with no plate is
//! invisible. Those are the two failures these tests exist to catch.
//! - **The bare `GameTooltip_OnLoad()`** an addon writes in its own `<OnLoad>` (TipBuddy.xml
//! l.1402, atsw.xml l.3417) is the reference's zero-argument spelling; our corpus convention
//! takes the frame as a leading parameter, and nothing lets us edit an addon.
//! - **`$parentStatusBar` is load-bearing for exactly one addon and completely for it**: TipBuddy
//! both `parent=`s and `relativeTo=`s a `TipBuddyTooltipStatusBar` it never declares
//! (TipBuddy.xml l.2320, l.2591) — a name only the template can publish.
//! The stock `GameTooltipTemplate` as addons use it: declared with `inherits=` or through
//! `CreateFrame`, its lines and status bar named after the caller, its plate drawn. Each shape
//! below is a corpus addon's, cited per test.
use benilla_ui::script::UiScript;
use super::test_ui::load_ui as load_xml;
/// An **addon's** document text, loaded after the shipped files exactly as `LoadAddOn` would —
/// the template registry is persistent, so `inherits=` reaches what `GameTooltip.xml` registered.
/// Returns the loader's own report so a test can assert on warnings as well as errors.
/// An addon's XML text, loaded after the stock files as `LoadAddOn` would: the template registry
/// persists, so `inherits=` reaches `GameTooltipTemplate`.
fn load_addon_xml(s: &UiScript, text: &str) -> benilla_ui::loader::LoadReport {
let doc = benilla_ui::framexml::parse(text).unwrap();
benilla_ui::loader::load(s, &doc, &|_| None)
}
/// Fonts + UIParent + the tooltip file, which is where both templates live.
/// Fonts, `UIParent` and the tooltip file, which includes both tooltip templates.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -54,10 +30,8 @@ fn harness() -> UiScript {
s
}
/// Answer every pending FontString measure the way the app's font atlas does — 6 px/char, 14 px a
/// line. A tooltip that declares no `<Size>` (which is 43 of the corpus's 44 `<GameTooltip>`
/// elements, and the reference template too) has NO rect at all until its text is measured, so
/// without this the auto-size never runs and nothing resolves, let alone draws.
/// Answers every pending FontString measure at 6 px a character, 14 px a line. A tooltip with no
/// `<Size>`, the stock template included, has no rect until its text is measured.
fn answer_measures(s: &mut UiScript) {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
@@ -67,10 +41,8 @@ fn answer_measures(s: &mut UiScript) {
s.set_measured_text_unwrapped(&answers);
}
/// Every texture path the resolved frame tree actually draws — `<Texture>` regions AND the
/// `<Backdrop>` plate pieces, which are a quad kind of their own (`QuadContent::Backdrop`, emitted
/// from the frame's own draw slot rather than from a region). The plate is the whole point here,
/// so a filter that saw only `Texture` would report an empty list for a perfectly drawn tooltip.
/// Every texture path drawn: `<Texture>` regions and the `<Backdrop>` plate, which is its own
/// quad kind (`QuadContent::Backdrop`).
fn drawn_textures(s: &mut UiScript) -> Vec<String> {
s.resolve();
answer_measures(s);
@@ -85,23 +57,13 @@ fn drawn_textures(s: &mut UiScript) -> Vec<String> {
.collect()
}
/// **The test this file exists for.** `MyTipTextLeft1` must exist; `GameTooltipTemplateTextLeft1`
/// must not.
///
/// The failure it pins is not "no tooltip": it is a tooltip whose lines are called
/// `GameTooltipTemplateTextLeft1`, so `getglobal(tip:GetName().."TextLeft1")` is nil, so the
/// addon's very next line dies — with the declaration itself having succeeded. 120 corpus lines
/// write that idiom.
///
/// Our line pairs are engine-created (`script/tooltip.rs` `ensure_lines`) rather than declared in
/// the template the way the reference's are, so they are named from the frame's own name by
/// construction — this asserts that the template route does not disturb it, and that the template
/// itself never publishes a line region.
/// An addon tooltip's lines are named after the caller (`MyTipTextLeft1`), never the template, so
/// the corpus idiom `getglobal(tip:GetName().."TextLeft1")` resolves.
#[test]
fn an_addon_tooltip_from_the_template_names_its_lines_after_the_caller() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// AtlasLoot/Core/AtlasLoot.xml l.576's declaration, renamed.
// AtlasLoot's declaration (`AtlasLoot.xml:576`), renamed.
let report = load_addon_xml(
&s,
r#"<Ui><GameTooltip name="MyTip" inherits="GameTooltipTemplate" parent="UIParent" hidden="true"/></Ui>"#,
@@ -120,7 +82,7 @@ fn an_addon_tooltip_from_the_template_names_its_lines_after_the_caller() {
report.warnings
);
// AtlasLoot.lua l.3220-3236's own sequence: own the frame, clear, add lines, show.
// `AtlasLoot.lua:3220-3236`'s sequence: own the frame, clear, add lines, show.
s.run(
r#"MyTip:SetOwner(UIParent, "ANCHOR_RIGHT")
MyTip:ClearLines()
@@ -136,7 +98,7 @@ fn an_addon_tooltip_from_the_template_names_its_lines_after_the_caller() {
2,
"SetText is line 1, AddLine is line 2"
);
// The idiom, verbatim — MikScrollingBattleText.lua l.1824's spelling.
// The idiom as `MikScrollingBattleText.lua:1824` spells it.
assert_eq!(
s.eval::<String>(r#"return getglobal(MyTip:GetName().."TextLeft1"):GetText()"#)
.unwrap(),
@@ -162,11 +124,7 @@ fn an_addon_tooltip_from_the_template_names_its_lines_after_the_caller() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **plate** is what an addon actually gets from the template, and losing it is silent: the
/// declaration succeeds, the lines populate, and the tooltip renders as floating text over the
/// world with no backdrop behind it.
///
/// Strip `<Backdrop>` out of `GameTooltipTemplate` and this is the assertion that goes red.
/// An addon tooltip draws the template's backdrop plate and border (`GameTooltipTemplate.xml:4`).
#[test]
fn an_addon_tooltip_from_the_template_gets_the_plate() {
benilla_formats::wow_data_or_skip!();
@@ -195,8 +153,7 @@ 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 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.
// The template's `$parentStatusBar` publishes under the caller's name.
assert!(
s.eval::<bool>(r#"return getglobal("MyTipStatusBar") ~= nil"#)
.unwrap(),
@@ -205,15 +162,8 @@ fn an_addon_tooltip_from_the_template_gets_the_plate() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// TipBuddy's shape: an addon re-declares `<OnLoad>` to add its own line and opens it with the
/// **reference's** zero-argument `GameTooltip_OnLoad();` (TipBuddy.xml l.1402, atsw.xml l.3417).
///
/// Our helpers take the frame as a leading parameter (the corpus-wide convention shift noted at
/// the head of `GameTooltip.xml`), so a bare call arrives with `self = nil` — a load-time error,
/// and the plate it was about to tint never gets its colours. Nothing lets us edit an addon, so
/// the helper reads `this` when the argument is absent, which is what the reference reads anyway.
///
/// Asserted from both sides: no error, and the tint actually landed.
/// An addon's own `<OnLoad>` calls `GameTooltip_OnLoad()` with no argument (`TipBuddy.xml:1402`);
/// the stock helper reads `this` (`GameTooltip.lua:79-82`) and tints the plate.
#[test]
fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
benilla_formats::wow_data_or_skip!();
@@ -241,11 +191,8 @@ fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The tint the bare call was there to apply (ref-GameTooltip.lua l.79-82's two colours),
// QUANTIZED: the reference's backdrop colour field is a packed `0xAARRGGBB` byte quad and the
// setter converts `×255 + 0.5` through `__ftol` (`SetBackdropColor 0x777d30`), so
// `0.09` stores as 23 and reads back as `23/255`. This used to compare against `0.09` exactly,
// which was our lossless `[f32; 4]` showing through a store the client cannot make.
// The tint, quantized: `SetBackdropColor` (`0x777d30`) stores a byte per channel, truncating
// `x * 255 + 0.5`, so 0.09 reads back as 23/255.
let q = |x: f32| f32::from((x * 255.0 + 0.5) as u8) / 255.0;
let (r, g, b) = s
.eval::<(f32, f32, f32)>("return MyTip:GetBackdropColor()")
@@ -256,23 +203,18 @@ fn an_addon_may_call_gametooltip_onload_the_reference_way_with_no_argument() {
"GameTooltip_OnLoad tinted the plate — TOOLTIP_DEFAULT_BACKGROUND_COLOR"
);
// TipBuddy also routes its OnHide through the same helper, bare (TipBuddy.xml l.1412).
// TipBuddy routes its OnHide through the stock helper too, bare (`TipBuddy.xml:1412`).
s.run("MyTip:Show() MyTip:Hide()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Lua route, which two addons take: `CreateFrame("GameTooltip", name, nil,
/// "GameTooltipTemplate")` — BetterCharacterStats/helper.lua l.3 and
/// BigWigs/Raids/Naxxramas/Loatheb.lua l.75, both with `kind = "GameTooltip"` and `parent = nil`.
///
/// The same template through the other door, so the same two things must hold: the caller's name
/// owns the children, and the plate came along.
/// `CreateFrame("GameTooltip", name, nil, "GameTooltipTemplate")`
/// (`BetterCharacterStats/helper.lua:3`) names the template's children after the caller too.
#[test]
fn createframe_with_the_template_is_the_same_tooltip() {
benilla_formats::wow_data_or_skip!();
let s = harness();
// BetterCharacterStats/helper.lua l.3, verbatim in shape (its `getglobal(...) or` guard and
// the WorldFrame owner both collapse to this in a fresh VM).
// `helper.lua:3` in shape.
s.run(
r#"BCS_Tooltip = getglobal("BetterCharacterStatsTooltip")
or CreateFrame("GameTooltip", "BetterCharacterStatsTooltip", nil, "GameTooltipTemplate")
@@ -300,13 +242,8 @@ fn createframe_with_the_template_is_the_same_tooltip() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **scanner** shape — the majority use: an off-screen tooltip nobody ever looks at, filled
/// only so its FontStrings can be read back and parsed.
///
/// CT_BarMod.lua l.1659-1669 blanks `TextLeft1`, fills the tooltip, and reads the FontString back
/// as its "did it populate?" sentinel; l.1747-1770 then walks `1..NumLines()` over both columns.
/// MikScrollingBattleText.lua l.1821-1858 is the same with `SetOwner(UIParent, "ANCHOR_NONE")` and
/// an explicit `Hide()` between passes (l.1832). Both read `TextRight` as well as `TextLeft`.
/// The scanner: an off-screen tooltip filled only to read both columns back
/// (`CT_BarMod.lua:1659-1669`, `MikScrollingBattleText.lua:1821-1858`), hidden between passes.
#[test]
fn the_scanner_shape_reads_both_columns_and_hides_again() {
benilla_formats::wow_data_or_skip!();
@@ -327,14 +264,14 @@ fn the_scanner_shape_reads_both_columns_and_hides_again() {
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// CT_BarMod.lua l.1663-1665's read, verbatim in shape.
// `CT_BarMod.lua:1663-1665`'s read, in shape.
assert_eq!(
s.eval::<String>("return CTTooltipTextLeft1:GetText()")
.unwrap(),
"Shadow Bolt",
"the bare-global spelling resolves too (CT_BarMod, TipBuddy and Outfitter all use it)"
);
// CT_BarMod.lua l.1752-1754's loop body, over the right column.
// `CT_BarMod.lua:1752-1754`'s loop body, over the right column.
let range: String = s
.eval(
r#"for y = 1, CTTooltip:NumLines() do
@@ -349,11 +286,9 @@ fn the_scanner_shape_reads_both_columns_and_hides_again() {
"the right column is reachable by name"
);
// MikScrollingBattleText.lua l.1832's `tooltip:Hide()` — the scanner's way out, and what keeps
// it off screen. (`SetText` SHOWS the plate by design in our engine — the byte-pinned
// `0x531b90` note in tooltip/verbs.rs, and PaperDoll's empty-slot text depends on it — so a
// scanner is off screen because it hides again and because its owner anchor is ANCHOR_NONE,
// not because filling it is invisible.)
// `SetText` (`0x531b90`) shows the plate, as stock `PaperDollFrame.lua:747` relies on, so a
// scanner stays off screen by hiding again (`MikScrollingBattleText.lua:1832`) and by its
// `ANCHOR_NONE` owner.
s.run("CTTooltip:Hide()").unwrap();
assert!(
!s.eval::<bool>("return CTTooltip:IsShown()").unwrap(),
@@ -362,15 +297,9 @@ fn the_scanner_shape_reads_both_columns_and_hides_again() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// `$parentStatusBar` belongs to the TEMPLATE, and TipBuddy is why.
///
/// TipBuddy replaces the whole unit tooltip and never declares a status bar of its own; it
/// `parent=`s a frame to `TipBuddyTooltipStatusBar` (TipBuddy.xml l.2320) and `relativeTo=`s an
/// anchor at it (l.2591), then drives it from Lua (`:SetValue`, `:Show`, `:Hide`, `:IsVisible` —
/// TipBuddy.lua l.177, l.1462-1464). Every one of those is a name only the template can publish.
///
/// This is the assertion that goes red if the status bar is moved back onto the `GameTooltip`
/// instance, which is where it lived while it was inlined.
/// `$parentStatusBar` is the template's (`GameTooltipTemplate.xml:575`): TipBuddy parents and
/// anchors frames to a `TipBuddyTooltipStatusBar` it never declares (`TipBuddy.xml:2320`, `:2591`)
/// and drives it from Lua.
#[test]
fn the_status_bar_is_the_templates_because_tipbuddy_anchors_to_it() {
benilla_formats::wow_data_or_skip!();
@@ -396,7 +325,7 @@ fn the_status_bar_is_the_templates_because_tipbuddy_anchors_to_it() {
.unwrap(),
"$parentStatusBar publishes under the CALLER's name"
);
// TipBuddy.lua l.1462-1464 / l.177 — it shows, hides and drives the bar directly.
// `TipBuddy.lua:1462-1464` and `:177` show, hide and drive the bar directly.
s.run(
r#"TipBuddyTooltipStatusBar:SetMinMaxValues(0, 100)
TipBuddyTooltipStatusBar:SetValue(42)
@@ -414,19 +343,9 @@ fn the_status_bar_is_the_templates_because_tipbuddy_anchors_to_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The instance attributes the corpus actually overrides on top of the template: `frameStrata`
/// (13 sites — `TOOLTIP`, and `LOW` at Necrosis.xml l.1332) and `hidden="false"` (PowerAuras.xml
/// l.20, SimpleActionSets.xml l.349, against the template's `hidden="true"`).
///
/// The splice puts the template's attributes first and the instance's on top, so both of these are
/// the instance's to win — and `hidden="false"` in particular must not be read as "hidden", since
/// PowerAuras never calls `Show()`.
///
/// **It is also the falsifier for `GameTooltip_OnLoad`'s body.** Our copy used to end with a
/// `self:Hide()` the reference's does not have (ref-GameTooltip.lua l.79-82 is the whole function);
/// it was invisible on our four tooltips, every one of which carries `hidden="true"` anyway, and it
/// hid this one. Put the line back and this assertion goes red — which is the only place anything
/// would notice.
/// An instance's `frameStrata` (`Necrosis.xml:1332`) and `hidden="false"` (`PowerAuras.xml:20`)
/// win over the template's; PowerAuras never calls `Show()`, and `GameTooltip_OnLoad` does not
/// hide the frame (`GameTooltip.lua:79-82`).
#[test]
fn an_instance_attribute_beats_the_templates() {
benilla_formats::wow_data_or_skip!();
@@ -451,21 +370,10 @@ fn an_instance_attribute_beats_the_templates() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **`GameTooltipTextLeft1` is a real global from LOAD, hidden — before any line exists.**
///
/// Ours were engine-created purely on demand, so a cold VM had no such global. The corpus pattern
/// that breaks on is a **guard**, not a read: `Participant/Resurrection.lua:294` and
/// `CT_RaidAssist/CT_RADetectSpells.lua:47` both write
/// `... and GameTooltipTextLeft1:IsVisible() then` to ask whether the tooltip is currently showing
/// anything. On the reference that is safe and answers false; for us it raised on the check itself,
/// which is the worst shape — the addon was being careful and got punished for it.
///
/// `ref-GameTooltipTemplate.xml` declares 30 pairs, all `hidden="true"` (l.17-627). We now declare
/// the same 30, and the engine's existing adoption path takes them over when lines are added.
///
/// The second half of the assertion is what makes this safe: a declared-but-unfilled pair must not
/// count as a line. `NumLines()` reads `num_lines`, and `layout_tooltips` sizes from `num_lines`,
/// so 30 hidden pairs add no height and no width.
/// `GameTooltipTextLeft1` exists, hidden, from load: the template declares 30 hidden line pairs
/// (`GameTooltipTemplate.xml:17-556`), so a corpus guard such as `GameTooltipTextLeft1:IsVisible()`
/// (`Participant/Resurrection.lua:294`) answers false. A declared pair counts as a line, and adds
/// size, only once filled.
#[test]
fn the_tooltip_line_globals_exist_cold_and_do_not_count_as_lines() {
benilla_formats::wow_data_or_skip!();
@@ -487,7 +395,7 @@ fn the_tooltip_line_globals_exist_cold_and_do_not_count_as_lines() {
);
}
// The corpus guard itself, verbatim in shape, on a tooltip that has never shown anything.
// The corpus guard itself, in shape, on a tooltip that has never shown anything.
assert!(
!s.eval::<bool>("return GameTooltipTextLeft1:IsVisible()")
.unwrap(),
@@ -500,7 +408,7 @@ fn the_tooltip_line_globals_exist_cold_and_do_not_count_as_lines() {
"30 declared pairs must not inflate NumLines"
);
// And the pairs still work as lines once filled — the adoption path, not a parallel set.
// Filled, the declared pair is the line.
s.run("GameTooltip:SetOwner(UIParent, \"ANCHOR_NONE\") GameTooltip:AddLine(\"Corpse of Bob\") GameTooltip:Show()")
.unwrap();
answer_measures(&mut s);
@@ -1,29 +1,8 @@
//! The target-of-target frame, driven end to end (decision 1576) — the **reference's own**
//! `TargetofTargetFrame`, declared in `Interface\FrameXML\TargetFrame.xml` (l.515-680) and
//! driven by `TargetFrame.lua`, off the player's patch chain. Decision 1751 retired our
//! `assets/ui/UnitFrames.xml` transcription, so what is under test here is the stock file over
//! synthetic `"targettarget"` snapshots.
//!
//! Three things here are not ordinary unit-frame plumbing and are what most of these test:
//!
//! * **The visibility law is six gates deep** — a switch, five display modes, and the four unit
//! tests the reference wraps them in — and a frame that gets any of them wrong is either always
//! there or never there. The mode leg leaks by construction: its solo arm calls neither `Show`
//! nor `Hide` when you are in a raid (ref TargetFrame.lua l.513-520), so the frame keeps
//! whatever state it had. Our transcription closed that; the stock file is the end state, so the
//! leak is now the behaviour under test.
//! * **Nothing on this frame is event-driven.** Its bars carry no `OnEvent` at all (ref
//! TargetFrame.xml l.593-625 — the health bar has only `OnValueChanged`, the mana bar no
//! `<Scripts>` block), so the `UNIT_HEALTH`/`UNIT_MANA` registrations
//! `UnitFrameHealthBar_Initialize`/`UnitFrameManaBar_Initialize` make (ref UnitFrame.lua
//! l.150-151, l.190-200) are dead, and so is the frame's own `UNIT_AURA` registration (ref
//! TargetFrame.xml l.668) — `UnitFrame_OnEvent` handles three events and that is not one of
//! them. `TargetofTarget_OnUpdate` → `TargetofTarget_Update` is the only driver of the name, the
//! bars, the dead word, the portrait tint and the aura row. **That is why nearly every step in
//! this file is a `tick`, not a `fire_event`.**
//! * **A token going away is silent.** The feed clears `"targettarget"` without an event, by the
//! same convention `"target"` uses, so the frame's own events cannot take it down — that is what
//! `TargetFrame_OnUpdate`'s one-compare reconcile is for, and it has a test.
//! The stock target-of-target frame (`TargetFrame.xml:515-680`) over synthetic `"targettarget"`
//! snapshots. Nothing on it answers a unit event: its bars' `UNIT_HEALTH`/`UNIT_MANA`
//! registrations reach no handler that uses them (`TargetFrame.xml:593-625`) and
//! `UnitFrame_OnEvent` ignores `UNIT_AURA` (`UnitFrame.lua:31-45`). `TargetofTarget_OnUpdate` is
//! the only driver, so most steps here are a `tick`.
use benilla_ui::script::{
AuraState, PartyMemberInfo, PartyState, QuadContent, RaidMemberInfo, ScriptValue,
@@ -32,8 +11,7 @@ use benilla_ui::script::{
use super::test_ui::load_ui as load_xml;
/// A unit snapshot with a guid — the identity `UnitIsUnit` compares, which the "your target is
/// you" gate reads.
/// A unit snapshot; the guid is what `UnitIsUnit` compares for the "target is you" gate.
fn unit(name: &str, guid: u64, health: u32) -> UnitState {
UnitState {
exists: true,
@@ -50,13 +28,8 @@ fn unit(name: &str, guid: u64, health: u32) -> UnitState {
}
}
/// The frame's production load prefix, plus a player, a target and a target's target — the state
/// in which everything but the switch itself already allows the frame.
///
/// The tail is the target-aura tests' set and is here for the same reasons: `BuffFrame` defines
/// `DebuffTypeColor`, which the debuff row's dispel tint indexes, and it will not TICK without
/// `ActionBar`'s `TOOLTIP_UPDATE_TIME` — which almost every test in this file needs, because the
/// stock frame is driven from its OnUpdate and nowhere else (see the module doc).
/// The stock unit frames over a player, a target and a target's target: everything but the switch
/// already allows the frame.
fn load_tot() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -85,10 +58,8 @@ fn load_tot() -> UiScript {
load_xml(&s, "Interface\\FrameXML\\PartyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TargetFrame.xml");
load_xml(&s, "Interface\\FrameXML\\PetFrame.xml");
// The target-of-target pair's home is `OptionsFrame.xml` (the reference keeps them in
// `UIOptionsFrame.lua`'s uvar defaults block, l.116-119). That file sits at the BOTTOM of our
// manifest where the reference's sits near the top, so a test that loads the unit kit alone
// has to state them itself — the same thing this harness already does for `DEAD`.
// The pair's defaults from stock `UIOptionsFrame_Init` (`UIOptionsFrame.lua:116-119`), which
// this kit does not load.
s.run(r#"SHOW_TARGET_OF_TARGET = "0" SHOW_TARGET_OF_TARGET_STATE = "5""#)
.unwrap();
s.set_unit("player", Some(unit("Tri", 0x100, 100)));
@@ -98,7 +69,7 @@ fn load_tot() -> UiScript {
s
}
/// Turn the frame on the way the option row does — the write plus its `applyFunc`.
/// Turn the frame on as the option row does: the write, then its `applyFunc`.
fn switch_on(s: &mut UiScript) {
s.run(r#"SHOW_TARGET_OF_TARGET = "1" this = TargetofTargetFrame TargetofTarget_Update() this = nil"#)
.unwrap();
@@ -109,7 +80,7 @@ fn shown(s: &mut UiScript) -> bool {
.unwrap()
}
/// Every texture path the UI actually draws this frame (the pet-frame tests' render probe).
/// Every texture path the UI draws this frame.
fn drawn(s: &mut UiScript) -> Vec<String> {
s.resolve();
s.extract()
@@ -128,7 +99,7 @@ fn debuff(spell_id: u32, name: &str, debuff_type: Option<&str>) -> AuraState {
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
count: 1,
debuff_type: debuff_type.map(str::to_string),
// No unit but yourself carries a duration on the 1.12 wire (decision 0257 B6).
// Only auras on the player carry a duration on the wire (vmangos `SpellAuras.cpp:7516`).
duration: 0.0,
expiration_time: 0.0,
helpful: false,
@@ -151,7 +122,7 @@ fn party(n: usize) -> PartyState {
}
}
/// A raid of `n` (the roster INCLUDES the player, per `GetRaidRosterInfo`'s array).
/// A raid of `n`, the player included, as `GetRaidRosterInfo` counts.
fn raid(n: usize) -> PartyState {
PartyState {
raid: (0..n)
@@ -165,9 +136,7 @@ fn raid(n: usize) -> PartyState {
}
}
/// The switch is the outer gate, and it ships OFF — 1.12's own default (`SHOW_TARGET_OF_TARGET`
/// = `"0"`, ref UIOptionsFrame.lua l.116). Everything else about the state already allows the
/// frame, so this is the switch alone.
/// `SHOW_TARGET_OF_TARGET` defaults to `"0"` in 1.12 (`UIOptionsFrame.lua:116`).
#[test]
fn the_frame_ships_off_and_the_switch_is_what_shows_it() {
benilla_formats::wow_data_or_skip!();
@@ -188,8 +157,7 @@ fn the_frame_ships_off_and_the_switch_is_what_shows_it() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four unit gates the reference wraps the switch in (ref TargetofTarget_Update l.504): a
/// target, a target OF it, a target that is not you, and a target that is alive.
/// The four unit gates around the switch (`TargetFrame.lua:504`).
#[test]
fn the_four_unit_gates_each_take_the_frame_down() {
benilla_formats::wow_data_or_skip!();
@@ -197,29 +165,25 @@ fn the_four_unit_gates_each_take_the_frame_down() {
switch_on(&mut s);
assert!(shown(&mut s));
// No target of target.
s.set_unit("targettarget", None);
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.unwrap();
assert!(!shown(&mut s), "nothing to show");
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
// No target at all.
s.set_unit("target", None);
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.unwrap();
assert!(!shown(&mut s), "no target, no target's target");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
// The target is YOU: its target is your own target, and the frame would restate the frame it
// hangs off.
// The target is the player (same guid).
s.set_unit("target", Some(unit("Tri", 0x100, 100)));
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.unwrap();
assert!(!shown(&mut s), "self-target: nothing to add");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
// A dead target.
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 0)));
s.run("this = TargetofTargetFrame TargetofTarget_Update() this = nil")
.unwrap();
@@ -232,14 +196,8 @@ fn the_four_unit_gates_each_take_the_frame_down() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The five display modes, each against solo / party / raid.
///
/// Mode 3 in a raid is not a rule the ladder states — it is the hole in it. The
/// `SHOW_TARGET_OF_TARGET_STATE == "3"` arm acts only while `GetNumRaidMembers() == 0` (ref
/// TargetFrame.lua l.513-520), so in a raid it calls neither Show nor Hide and the frame keeps
/// whatever it had. It reads `false` here because the preceding `party` step left it hidden, and
/// that sequencing is the only reason. The leak itself is
/// [`the_solo_mode_keeps_the_frame_when_a_raid_forms`], which shows the frame first.
/// Mode 3 in a raid reads hidden only because the party step before it hid the frame: that arm
/// calls neither `Show` nor `Hide` in a raid (`TargetFrame.lua:513-520`).
#[test]
fn the_five_modes_answer_solo_party_and_raid() {
benilla_formats::wow_data_or_skip!();
@@ -277,20 +235,8 @@ fn the_five_modes_answer_solo_party_and_raid() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Mode 3 in a raid, as a STATE question rather than a fresh evaluation — the leaked branch, which
/// can only be seen by showing the frame first.
///
/// **This assertion flipped with 1751.** Our deleted `UnitFrames.xml` answered the question the
/// option asks and took the frame down; the stock file does not. `TargetofTarget_Update`'s
/// `SHOW_TARGET_OF_TARGET_STATE == "3"` arm is `if ( GetNumRaidMembers() == 0 ) then … end` with
/// no `else` (ref TargetFrame.lua l.513-520), so in a raid it reaches neither `Show` nor `Hide`
/// and the frame keeps whatever it had. The stock file is the end state, so the reference's answer
/// is the assertion.
///
/// Driven by a `tick`, which is the strong form: `TargetofTarget_OnUpdate` re-runs
/// `TargetofTarget_Update` on **every** frame (ref TargetFrame.xml l.673-675 → TargetFrame.lua
/// l.500), so this is not one stale evaluation left standing — the reference re-asks the question
/// sixty times a second and still never takes the frame down.
/// In a raid the mode-3 arm reaches neither `Show` nor `Hide` (`TargetFrame.lua:513-520`), though
/// `TargetofTarget_OnUpdate` re-asks every frame (`TargetFrame.lua:494-501`), so the frame stays.
#[test]
fn the_solo_mode_keeps_the_frame_when_a_raid_forms() {
benilla_formats::wow_data_or_skip!();
@@ -309,24 +255,9 @@ fn the_solo_mode_keeps_the_frame_when_a_raid_forms() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The paint: name, health, and the powerless unit's mana rail — which the reference **never
/// hides**.
///
/// **Two things flipped with 1751**, and both are the stock file being the end state:
///
/// * **The name arrives on a tick, not on the switch.** `TargetofTarget_Update` (ref
/// TargetFrame.lua l.503-540) touches the bars, the dead word and the aura rows and never the
/// name; the `SetText` lives in `TargetofTarget_OnUpdate`'s `CURRENT_TARGETTARGET` compare
/// (l.494-501), off the frame's own OnUpdate. Turning the switch on alone leaves it nil.
/// * **A powerless unit still gets a rail.** `UnitFrameManaBar_Update` (ref UnitFrame.lua
/// l.203-224) sets 0..0 and calls neither `Show` nor `Hide` — there is no hide leg anywhere in
/// the reference's mana path. Our deleted `UnitFrames.xml` hid the bar and this asserted that;
/// the stock answer is an empty 0/0 rail, still drawn, so that is what is asserted now.
///
/// The powerless step is a `tick` for the same reason as everything else here: 1.12 has no
/// `UNIT_MAXPOWER` event at all (the reference registers `UNIT_MAXMANA`/`RAGE`/`FOCUS`/`ENERGY`/
/// `HAPPINESS`, ref UnitFrame.lua l.195-199), and this frame's mana bar carries no `OnEvent` to
/// answer one with if it did (ref TargetFrame.xml l.612-625).
/// The name is set only by `TargetofTarget_OnUpdate`'s `CURRENT_TARGETTARGET` compare
/// (`TargetFrame.lua:494-501`), so it waits for a tick. A powerless unit keeps a 0/0 rail:
/// `UnitFrameManaBar_Update` never hides the bar (`UnitFrame.lua:203-224`).
#[test]
fn the_frame_paints_its_unit() {
benilla_formats::wow_data_or_skip!();
@@ -373,15 +304,8 @@ fn the_frame_paints_its_unit() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// DEAD over a dimmed trough — and the reference's connected test, which is what tells a corpse
/// from a linkdead player (both read zero health). Ref `TargetofTarget_CheckDead`, TargetFrame.lua
/// l.559-567.
///
/// Every step is a `tick` rather than a `UNIT_HEALTH`: `TargetofTargetHealthBar` registers that
/// event through `UnitFrameHealthBar_Initialize` (ref UnitFrame.lua l.150) but declares **no**
/// `OnEvent` to answer it with (ref TargetFrame.xml l.593-611 — `OnValueChanged` and nothing
/// else), so the registration is dead and `TargetofTarget_Update` off the frame's OnUpdate is the
/// only thing that ever calls `TargetofTarget_CheckDead`.
/// Zero health shows the dead text only while connected, which tells a corpse from a linkdead
/// player (`TargetofTarget_CheckDead`, `TargetFrame.lua:559-567`).
#[test]
fn the_dead_word_needs_a_connected_corpse() {
benilla_formats::wow_data_or_skip!();
@@ -416,14 +340,8 @@ fn the_dead_word_needs_a_connected_corpse() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The portrait tint (ref `TargetofTargetHealthCheck`, TargetFrame.lua l.569-585), and its
/// PLAYERS-ONLY gate: a creature's portrait is never tinted, which is why the check has to run off
/// the bar's own value rather than off the snapshot.
///
/// Ticks, not `UNIT_HEALTH`, and here the chain is one link longer than the dead word's: the check
/// hangs off the health bar's `OnValueChanged` (ref TargetFrame.xml l.604-608), and the only thing
/// that moves that value is `UnitFrameHealthBar_Update` inside `TargetofTarget_Update` — which is
/// reached from the frame's OnUpdate and nowhere else.
/// The tint (`TargetofTargetHealthCheck`, `TargetFrame.lua:569-585`) runs off the health bar's
/// `OnValueChanged` (`TargetFrame.xml:604-608`) and only for players.
#[test]
fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
benilla_formats::wow_data_or_skip!();
@@ -449,7 +367,7 @@ fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
.unwrap();
assert_eq!((r, g, b), (1.0, 1.0, 1.0), "and white again");
// A creature at the same 15%: the tint must not move (the check returns before touching it).
// A creature at 15%: the check returns before touching the tint.
s.set_unit("targettarget", Some(unit("Kobold", 0x500, 15)));
s.tick(0.016);
let (r, g, b) = s
@@ -459,8 +377,6 @@ fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The left click selects — the one leg the target frame's own click does not have, and the whole
/// reason the frame is clickable.
#[test]
fn the_left_click_targets_the_unit() {
benilla_formats::wow_data_or_skip!();
@@ -472,21 +388,15 @@ fn the_left_click_targets_the_unit() {
vec![SelectionRequest::Unit("targettarget".into())]
);
// A right click is not a menu here: the reference gives this frame none.
// A right click opens no menu here (`TargetFrame.lua:543-557`).
s.run(r#"TargetofTarget_OnClick("RightButton")"#).unwrap();
assert!(s.take_selection_requests().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four debuff buttons, dispel-tinted (ref `RefreshBuffs` over `MAX_PARTY_DEBUFFS`,
/// BuffFrame.lua l.262-313, called from `TargetofTarget_Update` l.538).
///
/// The `UNIT_AURA` fire is what the feed really does and is kept for that reason, but it is inert
/// here: the frame registers the event in its own OnLoad (ref TargetFrame.xml l.668) and answers
/// it with `UnitFrame_OnEvent`, which handles `UNIT_NAME_UPDATE`, `UNIT_PORTRAIT_UPDATE` and
/// `UNIT_DISPLAYPOWER` and nothing else (ref UnitFrame.lua l.31-45). A dead registration in the
/// stock file — the row is redrawn by `RefreshBuffs` off the OnUpdate, so the `tick` is what makes
/// it appear.
/// The row is `RefreshBuffs` over `MAX_PARTY_DEBUFFS` (`BuffFrame.lua:262-313`), called from
/// `TargetofTarget_Update` (`TargetFrame.lua:538`). The `UNIT_AURA` fire is what the feed does,
/// but it is inert: `UnitFrame_OnEvent` ignores it, so the `tick` draws the row.
#[test]
fn the_debuff_row_draws_what_the_unit_carries() {
benilla_formats::wow_data_or_skip!();
@@ -519,14 +429,9 @@ fn the_debuff_row_draws_what_the_unit_carries() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The target's OWN aura rows re-wrap around this frame: 5 to a row instead of 6, and the buffs
/// open under the second debuff row instead of the seventh icon (ref `TargetDebuffButton_Update`,
/// TargetFrame.lua l.312-338 for the wrap and l.370-385 for the row anchors).
///
/// Both directions, and they are not symmetric: the reference re-runs the rows on the way **in**
/// only. `TargetDebuffButton_Update` is called from inside `TargetofTarget_Update`'s
/// `if ( TargetofTargetFrame:IsShown() )` (l.532-537), so turning the frame off leaves the rows
/// wrapped short behind it.
/// While this frame shows, the target's own aura rows wrap at 5 instead of 6
/// (`TargetDebuffButton_Update`, `TargetFrame.lua:312-338`, `:370-385`). They re-lay only on the
/// way in: the call sits inside `if ( TargetofTargetFrame:IsShown() )` (`TargetFrame.lua:532-539`).
#[test]
fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
benilla_formats::wow_data_or_skip!();
@@ -560,12 +465,7 @@ fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
"and the sixth icon starts the second row"
);
// Back off — and the rows STAY wrapped short. **This assertion flipped with 1751.**
// `TargetofTarget_Update` calls `TargetDebuffButton_Update` from inside
// `if ( TargetofTargetFrame:IsShown() )` (ref TargetFrame.lua l.532-537), so the pass that
// hides the frame is exactly the pass that does not re-lay the target's rows. They keep the
// 5-wide wrap until something else runs the update — the next target change or `UNIT_AURA`.
// Our deleted `UnitFrames.xml` re-laid them on the way out; the stock file is the end state.
// Back off: the rows stay wrapped short until the next target change or `UNIT_AURA`.
s.run(r#"SHOW_TARGET_OF_TARGET = "0" this = TargetofTargetFrame TargetofTarget_Update() this = nil"#)
.unwrap();
assert_eq!(
@@ -576,9 +476,8 @@ fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reconcile. A token going away is CLEARED, not announced — no UNIT_* event carries it — so
/// the frame's own registrations cannot take it down. `TargetFrame_OnUpdate` is what does, and it
/// runs only while you have a target.
/// A token going away fires no event, so `TargetFrame_OnUpdate` reconciles it
/// (`TargetFrame.lua:257-261`); it runs only while you have a target.
#[test]
fn the_reconcile_takes_the_frame_down_when_the_token_goes_silent() {
benilla_formats::wow_data_or_skip!();
@@ -586,14 +485,14 @@ fn the_reconcile_takes_the_frame_down_when_the_token_goes_silent() {
switch_on(&mut s);
assert!(shown(&mut s));
// Exactly what the feed does: clear the token, fire nothing.
// As the feed does: clear the token, fire nothing.
s.set_unit("targettarget", None);
assert!(shown(&mut s), "no event, so nothing has told the frame yet");
s.tick(0.016);
assert!(!shown(&mut s), "the reconcile notices within a frame");
// And the other direction: a token appearing DOES fire, but the reconcile covers it anyway.
// A token appearing does fire, and the reconcile covers it too.
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
s.tick(0.016);
assert!(shown(&mut s), "and brings it back");
+79 -238
View File
@@ -1,12 +1,5 @@
//! The shipped **trainer window** driven end-to-end, engine-only (no Bevy): the real
//! 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.
//! End-to-end tests of the stock `Blizzard_TrainerUI` window, engine-only: loaded behind its
//! FrameXML dependencies and fed a synthetic service list.
use benilla_ui::script::{
ExtractedQuad, QuadContent, ScriptValue, SoundRequest, TrainerAbilityReq, TrainerService,
@@ -15,16 +8,9 @@ use benilla_ui::script::{
use super::test_ui::load_ui as load_xml;
/// Load the trainer window + all its deps into a fresh script, screen sized, with every state filter
/// ON (the XML defaults "Already Known" off — the tests want the full tree, deterministic indices).
///
/// The filter's source of truth is the three **saved globals** (decision 1128), which the window
/// 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.
/// The trainer window with every state filter on, for the full tree at fixed indices.
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",
@@ -33,17 +19,14 @@ fn trainer_script() -> UiScript {
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.
/// What the manifest loads before the trainer addon, with a text measurer: the stock row's label
/// is a width-0 `<ButtonText>`, sized to its text.
fn trainer_script_base() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
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).
// In manifest order (ScrollTemplates before UIPanelTemplates): the window calls
// `UpdateMicroButtons` and inherits the panel templates.
for f in [
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
@@ -80,10 +63,8 @@ fn trainer_script_base() -> UiScript {
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.
/// The rest of a LoadOnDemand load: `ADDON_LOADED` pushes the filter globals, "used" on, into the
/// engine. The title reads `UnitName("npc")`, so the NPC is seated.
fn finish_trainer_load(s: &mut UiScript) {
s.set_unit(
"npc",
@@ -100,8 +81,8 @@ fn finish_trainer_load(s: &mut UiScript) {
);
}
/// 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)`.
/// Whether any text quad renders in `color`: the red cost's `(1.0, 0.1, 0.1)` is distinct from an
/// unavailable row's `(0.9, 0, 0)`.
fn has_text_color(quads: &[ExtractedQuad], color: [f32; 3]) -> bool {
quads.iter().any(|q| match &q.content {
QuadContent::Text { color: Some(c), .. } => (0..3).all(|i| (c[i] - color[i]).abs() < 0.02),
@@ -109,8 +90,6 @@ fn has_text_color(quads: &[ExtractedQuad], color: [f32; 3]) -> bool {
})
}
/// Whether the first text quad containing `needle` renders in `color` (small tolerance) — used to
/// assert the selected row's white name vs. a state colour.
fn text_has_color(quads: &[ExtractedQuad], needle: &str, color: [f32; 3]) -> bool {
quads.iter().any(|q| match &q.content {
QuadContent::Text {
@@ -122,7 +101,6 @@ fn text_has_color(quads: &[ExtractedQuad], needle: &str, color: [f32; 3]) -> boo
})
}
/// The centre of the first text quad whose text contains `needle` — a point to aim the wheel at.
fn text_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
let r = quads
.iter()
@@ -134,8 +112,6 @@ fn text_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)
}
/// One service in a named skill line, spelling out every field so the intent is legible at the call
/// site.
fn service(
spell_id: u32,
name: &str,
@@ -169,8 +145,7 @@ fn service(
}
}
/// A two-line warrior menu. Groups sort by name (Arms < Fury); within a group by level then name. The
/// full-filter tree is:
/// A two-line warrior menu. Groups sort by name, services by level then name, so the tree is:
/// 1 H:Arms · 2 Heroic Strike(avail,10c,l1) · 3 Cleave(unavail,l20,skill+ability) ·
/// 4 H:Fury · 5 Rend(used,30c) · 6 Thunder Clap(avail,500c)
fn menu() -> TrainerState {
@@ -203,9 +178,7 @@ fn menu() -> TrainerState {
rank: 50,
met: false,
}),
// The director's case: Cleave is gated (level/skill), but its prerequisite ability is
// already learned — so it reads MET (white) with its rank, decoupled from the
// service's unavailable state.
// Gated by level and skill, but the prerequisite is learned, so it reads white.
vec![TrainerAbilityReq {
name: "Charge (Rank 1)".into(),
met: true,
@@ -237,22 +210,16 @@ fn menu() -> TrainerState {
}
}
/// The whole trainer window minus Bevy: it loads clean, opens on TRAINER_SHOW with the NPC name in the
/// title, renders the interleaved tree (headers + services), picks the first available service, renders
/// the exact `Cost:` label, gates Train on available-and-affordable, queues the selected row's spell id
/// on a buy, reddens the cost of an unaffordable service, builds the `Requires:` line for a gated one,
/// and hides on TRAINER_CLOSED.
#[test]
fn shipped_trainer_frame_drives_end_to_end() {
benilla_formats::wow_data_or_skip!();
let mut s = trainer_script();
// Hidden by default.
assert!(!s
.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.
// 50 copper: Heroic Strike (10c) is affordable, Thunder Clap (500c) is not.
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event(
@@ -261,7 +228,6 @@ fn shipped_trainer_frame_drives_end_to_end() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Shown, title took the NPC name off arg1.
assert!(s
.eval::<bool>("return ClassTrainerFrame:IsVisible()")
.unwrap());
@@ -271,7 +237,6 @@ fn shipped_trainer_frame_drives_end_to_end() {
"Sana Winterhoof"
);
// Row 1 renders the "Arms" header (its name, no indent); row 2 the first service.
assert_eq!(
s.eval::<String>("return ClassTrainerSkill1Text:GetText()")
.unwrap(),
@@ -283,8 +248,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
" Heroic Strike"
);
// selectFirstService picked the first available row (Heroic Strike, index 2) → the detail pane
// shows the exact "Cost:" label and Train is enabled (available + 10c affordable at 50c).
// `ClassTrainer_SelectFirstLearnableSkill` selects row 2, Heroic Strike: Train is enabled.
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
2
@@ -298,14 +262,12 @@ fn shipped_trainer_frame_drives_end_to_end() {
.eval::<bool>("return ClassTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
// Train buys the selected service — the row's spell id reaches the app's drain.
s.run("BuyTrainerService(GetTrainerSelectionIndex())")
.unwrap();
assert_eq!(s.take_trainer_buys(), vec![78]);
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 (SetMoneyFrameColor 1.0, 0.1, 0.1).
// Thunder Clap (row 6) is available but unaffordable: Train disables and the cost reddens.
s.run("this = ClassTrainerSkill6; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
assert!(!s
@@ -317,8 +279,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
"unaffordable cost coins render red"
);
// 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).
// Cleave (row 3) is gated: the `Requires:` line lists its level, skill and ability gates.
s.run("this = ClassTrainerSkill3; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
let reqs = s
@@ -328,9 +289,7 @@ fn shipped_trainer_frame_drives_end_to_end() {
for term in ["Level", "Swords", "Charge"] {
assert!(reqs.contains(term), "reqs missing {term}: {reqs}");
}
// The met prerequisite renders WHITE (|cffffffff…|r) with its rank, while the unmet level/skill
// gates redden — the mixed line the director asked for (a learned prev-rank isn't reddened just
// because the spell itself is unavailable).
// A met ability renders white (`TRAINER_REQ_ABILITY`) beside the red unmet gates.
assert!(
reqs.contains("|cffffffffCharge (Rank 1)|r"),
"a known prerequisite shows white with its rank: {reqs}"
@@ -339,7 +298,6 @@ fn shipped_trainer_frame_drives_end_to_end() {
.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
@@ -348,8 +306,6 @@ fn shipped_trainer_frame_drives_end_to_end() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Clicking a header row folds its group (and expands it back) — the tree collapse, end-to-end through
/// the row button's OnClick → Collapse/ExpandTrainerSkillLine(headerIndex).
#[test]
fn clicking_a_header_row_collapses_its_group() {
benilla_formats::wow_data_or_skip!();
@@ -367,8 +323,7 @@ fn clicking_a_header_row_collapses_its_group() {
" 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.
// Folding Arms leaves 4 rows: H:Arms, H:Fury, Rend, Thunder Clap.
s.run("this = ClassTrainerSkill1; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 4);
@@ -379,15 +334,12 @@ fn clicking_a_header_row_collapses_its_group() {
"Arms folded; its header (row 1) now abuts the Fury header (row 2)"
);
// Click it again → expands back to 6 rows.
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());
}
/// The dropdown filter hides a state client-side: toggling "used" off drops the already-known service,
/// its header stays, and the row count falls.
#[test]
fn filter_hides_a_state_keeping_headers() {
benilla_formats::wow_data_or_skip!();
@@ -398,9 +350,7 @@ fn filter_hides_a_state_keeping_headers() {
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// 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.
// Button 3 is the "used" row, clicked through the kit's own handler.
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button3; UIDropDownMenuButton_OnClick()")
@@ -418,13 +368,8 @@ fn filter_hides_a_state_keeping_headers() {
);
}
/// The filter rows toggle **through the real dropdown kit** — the path a mouse takes, which the test
/// above deliberately shortcuts by faking `this`. `UIDropDownMenuButton_OnClick` runs the row's func
/// and only THEN flips the check for a `keepShownOnClick` row, so the func must not repaint the row
/// itself: an in-func `UIDropDownMenu_Initialize` re-derived the check from the fresh engine state and
/// the kit's flip then inverted it straight back — the check never moved on screen, and `this.checked`
/// stuck true, so a filter turned off could never be turned back on. Click, re-click, and re-open all
/// have to agree.
/// `UIDropDownMenuButton_OnClick` flips a `keepShownOnClick` row's check after running its func:
/// click, re-click and re-open must agree on screen and in the engine.
#[test]
fn filter_rows_toggle_through_the_dropdown_kit() {
benilla_formats::wow_data_or_skip!();
@@ -433,8 +378,7 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
// 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.
// Row 2 is "Unavailable", checked since `trainer_script` turns every state on.
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -445,8 +389,6 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
assert!(row_checked(&mut s), "Unavailable starts checked");
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// Click it: the check clears, the engine filter clears, and Cleave (the unavailable service)
// drops out of the tree. Its Arms header stays.
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -462,8 +404,6 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
"Cleave is hidden"
);
// Click it again: back on, both on screen and in the engine — the case the old code could never
// reach, because `this.checked` never went false.
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(row_checked(&mut s), "the re-click restores the check");
@@ -474,8 +414,7 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// Close and re-open: Initialize re-derives every row from the engine, so the menu agrees with
// what the clicks left behind (the two states left on, "used" still on from trainer_script()).
// Off again, then close and re-open: `Initialize` re-derives every row from the engine.
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
@@ -489,9 +428,7 @@ fn filter_rows_toggle_through_the_dropdown_kit() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A menu bigger than the 11 visible rows (one skill line, 15 services → a 16-row tree), so the list
/// scrolls. The wheel spins the list even when the cursor is over a row — the engine bubbles the spin
/// up the parent chain to the window, which drives the faux-scroll bar.
/// One skill line of 15 services, a 16-row tree over 11 visible rows, so the list scrolls.
fn long_menu() -> TrainerState {
TrainerState {
greeting: "Much to learn.".into(),
@@ -525,7 +462,6 @@ fn wheel_over_a_row_scrolls_the_list() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
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 ClassTrainerSkill2Text:GetText()")
@@ -533,21 +469,16 @@ fn wheel_over_a_row_scrolls_the_list() {
" Service 01"
);
// Aim the wheel at a LIST ROW's text (service 3 — visible at the top): the spot over a row.
// Aim over a row's text: the spin must reach the list from a row.
s.resolve();
let (x, y) = text_center(&s.extract(), "Service 03");
// Spin down (WoW convention: negative = down). The REFERENCE's wheel is a PAGE, not a row:
// `ScrollFrameTemplate_OnMouseWheel` moves `scrollBar:GetHeight() / 2` pixels
// (UIPanelTemplates.lua:150-157), the same half-bar the arrows use. Ours moved one row on
// purpose; the migration reverts that (1860), and the expected row is derived from the bar so
// this stays the reference's rule rather than a literal.
// Negative is down. The reference's wheel moves half the bar's height, a page and not a row
// (`ScrollFrameTemplate_OnMouseWheel`, `UIPanelTemplates.lua:150-157`).
s.mouse_wheel(x, y, -1.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// What this test is FOR is the wiring — wheel -> bar -> `<OnVerticalScroll>` ->
// `FauxScrollFrame_OnVerticalScroll` -> `frame.offset` -> the repaint. The magnitude is the
// 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.
// The wiring is the subject (wheel, bar, `FauxScrollFrame_OnVerticalScroll`, repaint), so the
// magnitude is asserted only as more than a row.
let offset = s
.eval::<i64>("return ClassTrainerListScrollFrame.offset or -1")
.unwrap();
@@ -561,9 +492,7 @@ fn wheel_over_a_row_scrolls_the_list() {
"row 1 shows the entry the offset names, so the repaint followed the scroll"
);
// A second spin cannot go deeper: 16 entries over 11 visible rows makes 5 the deepest legal
// offset, and the reference's half-bar page already reached it in one spin. That it STOPS
// there is the clamp working, not the wheel failing.
// 16 rows over 11 make 5 the deepest offset, which one half-bar page already reaches.
s.mouse_wheel(x, y, -1.0);
assert_eq!(
s.eval::<i64>("return ClassTrainerListScrollFrame.offset or -1")
@@ -572,7 +501,6 @@ fn wheel_over_a_row_scrolls_the_list() {
"clamped at the bottom (numItems - numToDisplay), never past it"
);
// Spin back up twice: the list returns to the top and stops there, never past it.
s.mouse_wheel(x, y, 1.0);
s.mouse_wheel(x, y, 1.0);
assert_eq!(
@@ -584,19 +512,9 @@ fn wheel_over_a_row_scrolls_the_list() {
assert_eq!(s.eval::<String>(row1).unwrap(), "Arms");
}
/// The selected service row's name renders white (HIGHLIGHT), legible against its colour glow — and
/// so does a HOVERED one. Both are the reference's own behaviour, through one mechanism: the row's
/// name is the button's `<ButtonText>` under a `<HighlightFont inherits="GameFontHighlight">`, the
/// engine swaps that instance in while the cursor is on the row, and `LockHighlight()` pins it for
/// the selection (Blizzard_TrainerUI.lua l.183). `SetTextColor` writes the NORMAL instance only, so
/// the state colour cannot follow the label into either state — which is exactly why it goes white.
///
/// An earlier revision of this test called the white name "a deliberate divergence from the ref
/// (which whitens only the subtext)". The ref whitens the subtext *by hand* precisely BECAUSE the
/// subtext is a child FontString the lock cannot reach; the name it leaves to the lock. Ours could
/// not, because the name was a child FontString too and the engine's highlighted label fell back to
/// the normal state's colour — so the white was hand-painted here and absent on hover entirely
/// (decision 1605).
/// The row's name is its button's `<ButtonText>`: hover swaps in the `GameFontHighlight` font and
/// `LockHighlight()` pins it for the selection (`Blizzard_TrainerUI.lua:183`), while `SetTextColor`
/// writes only the normal font, so both states read white.
#[test]
fn a_selected_or_hovered_service_row_paints_its_name_white() {
benilla_formats::wow_data_or_skip!();
@@ -606,15 +524,12 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// 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 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("this = ClassTrainerSkill3; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
s.resolve();
@@ -623,7 +538,6 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
"the selected row's name renders white"
);
// Select Heroic Strike (index 2): Cleave is no longer selected → back to unavailable red.
s.run("this = ClassTrainerSkill2; ClassTrainerSkillButton_OnClick('LeftButton')")
.unwrap();
s.resolve();
@@ -632,7 +546,6 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
"an unselected unavailable row is red again"
);
// Now hover it, selecting nothing: the HighlightFont instance takes over and the row lights up.
let (x, y) = text_center(&s.extract(), "Cleave");
s.mouse_move(x, y);
s.resolve();
@@ -645,7 +558,6 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
"and the SELECTED row stays white while another is hovered"
);
// Cursor off the list: the hovered row falls back to its state colour, the selected one holds.
s.mouse_move(1000.0, 20.0);
s.resolve();
assert!(
@@ -658,9 +570,8 @@ fn a_selected_or_hovered_service_row_paints_its_name_white() {
);
}
/// Scrolling is silent, faithfully: the mouse WHEEL plays no sound (the ref's
/// `ScrollFrameTemplate_OnMouseWheel` is soundless — the director asked for no sound when scrolling),
/// while the arrow BUTTONS keep the ref's `UChatScrollButton` click.
/// The wheel scrolls silently, as the stock `ScrollFrameTemplate_OnMouseWheel` plays no sound; the
/// arrow buttons play `UChatScrollButton`.
#[test]
fn wheel_scroll_is_silent_but_the_arrows_click() {
benilla_formats::wow_data_or_skip!();
@@ -672,7 +583,6 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
let click = SoundRequest::KitName("UChatScrollButton".into());
// A wheel spin over the list scrolls it (proven by the wheel test) but plays NOTHING.
s.resolve();
let (x, y) = text_center(&s.extract(), "Service 03");
s.mouse_wheel(x, y, -1.0);
@@ -681,12 +591,9 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
"the wheel scroll is silent"
);
// 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.
// One notch reaches the bottom: half the 152-tall bar is 76px, which `SetValue` snaps to the
// 16px step as the range's 80, as the reference does; `FauxScrollFrame_Update` then disables
// the down arrow (`UIPanelTemplates.lua:199`).
assert_eq!(
s.eval::<f64>("return ClassTrainerListScrollFrameScrollBar:GetValue()")
.unwrap(),
@@ -694,7 +601,6 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
"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!(
@@ -703,15 +609,8 @@ fn wheel_scroll_is_silent_but_the_arrows_click() {
);
}
/// The scrollbar ARROWS move the list the way they point, and stop at the top.
///
/// **The STEP is the reference's, not ours, since 1860.** `UIPanelScrollBarTemplate`'s arrow
/// OnClick is `parent:SetValue(parent:GetValue() -/+ (parent:GetHeight() / 2))` — half the BAR's
/// height in pixels, which for this window's 144-tall bar over 16px rows is five rows. Our
/// deleted kit stepped exactly one row on purpose ("the generic ref scrollbar steps half its
/// height; a discrete row list wants one"); the migration reverts that, and the magnitude here is
/// computed from the bar rather than written as a literal so it stays the reference's rule and not
/// a number someone has to re-derive.
/// The arrows step half the bar's height (`UIPanelScrollBarTemplate`,
/// `UIPanelTemplates.xml:137-149`), several rows at a time, and stop at the top.
#[test]
fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
benilla_formats::wow_data_or_skip!();
@@ -729,8 +628,7 @@ fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
))
.unwrap();
};
// The reference's own step, read off the bar: half its height in pixels, rounded to rows the
// way `FauxScrollFrame_OnVerticalScroll` rounds (`floor(v/itemHeight + 0.5)`).
// Pixels round to rows as `floor(v/itemHeight + 0.5)` (`UIPanelTemplates.lua:231`).
assert_eq!(offset(&mut s), 0, "opens at the top");
click(&mut s, "Down");
let step = offset(&mut s);
@@ -746,12 +644,8 @@ fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The filter is remembered across a restart** (decision 1128) — the whole persistence path, in
/// 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.
/// A dropdown click writes the global, `## SavedVariables` saves it, and on a restart `LoadAddOn`
/// runs the files, the saved file, then `ADDON_LOADED`, whose arm pushes it into the engine.
#[test]
fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -794,8 +688,7 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
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.
// The stock default `TRAINER_FILTER_USED = 0` hides the known service: 2 headers, 3 services.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
assert_eq!(
info().saved_variables,
@@ -825,8 +718,7 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
);
std::fs::write(saved_dir.join("Blizzard_TrainerUI.lua"), &text).unwrap();
// 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.
// The restart: a fresh VM loads the addon on demand, and its first paint is filtered.
let mut fresh = trainer_script_base();
boot(&mut fresh);
assert!(
@@ -843,10 +735,8 @@ fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
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
/// class/tradeskill/pet trainer, mask 5 (available|used) at a mount trainer — and clears the collapse
/// set, byte-verified (decision 1128). This is the engine half of the pair above: the reset is why
/// the window re-pushes its saved globals on every show.
/// A new list resets the filter mask to 3 (available|unavailable), or 5 (available|used) at a
/// mount trainer, and clears the collapse set, as the reference's list builder does (`0x4d75d9`).
#[test]
fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
benilla_formats::wow_data_or_skip!();
@@ -856,7 +746,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
"ADDON_LOADED",
vec![ScriptValue::Str("Blizzard_TrainerUI".into())],
);
// Collapse a group, and turn a state off directly (no global) — both are engine-side state.
// Engine-side state only: a collapsed group, and `used` set with no global.
s.run("CollapseTrainerSkillLine(1) SetTrainerServiceTypeFilter('used', 1)")
.unwrap();
assert!(s
@@ -882,7 +772,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
"nothing collapsed any more (6 rows less the already-known service the mask now hides)"
);
// A mount trainer wants available|used instead — what makes a known mount visible at all.
// A mount trainer gets available|used, which is what shows a known mount at all.
s.reset_trainer_list_state(1);
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
@@ -892,8 +782,7 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
.unwrap());
}
/// A profession trainer's list: one skill line, a long recipe name and a short one, each with a rank
/// subtext — the shape of the B253 report (a Leatherworking trainer at "Handstitched Leather Pants").
/// One profession skill line: a long recipe name and a short one, each with a rank subtext.
fn recipe_menu() -> TrainerState {
let mut long = service(
3756,
@@ -927,16 +816,9 @@ fn recipe_menu() -> TrainerState {
}
}
/// **B253 — a long row name drew two lines over the row beneath it.** The row is a FLOW, not two
/// fixed columns: the name carries no width (a FontString given one wraps at it, and a 16 px row
/// cannot grow), and the rank follows the name's right edge rather than sitting at an invented
/// x=188. Both halves are the reference's own row
/// (`ClassTrainerFrameTemplates.xml`'s `<ButtonText>` at width 0 + the per-row
/// `SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`), read off the player's chain.
///
/// The mutation check is the first assertion: put a `<Size>` back on the row's `<ButtonText>` and
/// the request
/// carries a wrap width, the long name measures two lines, and its height passes the row height.
/// The stock row is a flow: its `<ButtonText>` has width 0 (ClassTrainerFrameTemplates.xml), so a
/// long name stays on one line in its 16 px row, and the rank is anchored 10 px past the name's
/// right edge (`Blizzard_TrainerUI.lua:157`).
#[test]
fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
benilla_formats::wow_data_or_skip!();
@@ -946,8 +828,8 @@ fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Nadyia".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The host measure: 6 px per character, 14 px per line, wrapped at whatever width was asked for.
// Capture the row names' requests on the way past — what they ASK is the structural fact.
// A host measure of 6 px per character and 14 px per line; the name's requests are kept, since
// what it asks for is the fact under test.
let mut name_wraps: Vec<Option<f32>> = Vec::new();
let mut answer = |s: &mut UiScript, collect: bool| {
let reqs = s.fontstrings_needing_measure();
@@ -1029,17 +911,8 @@ fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
);
}
/// **B256 — "filter to Available, learn a spell, the filter comes back partly reset."** The window
/// is driven here through the real [`crate::ui_trainer::TrainerOpen`], so the packet→reset decision
/// under test is the app's own and not this test's: the closure below is the trainer feed's three
/// lines (`if open.fresh_list { reset } ; set_trainer ; fire`), and everything else is the shipped
/// Lua.
///
/// The reference cannot produce this bug because it never gets a list packet with the window open —
/// it repaints a purchase from a client-side state re-derivation (`0x4d7d40`, decision 1128 §4.2).
/// benilla re-asks the server instead, so the reference's per-packet mask reset (`0x4d75d9`) was
/// riding in on a packet the reference never sends, and taking the player's choice — and their
/// collapsed groups, which 1128 recorded as "a collapse does not survive a purchase" — with it.
/// A purchase repaints in place, as the reference's state re-evaluator (`0x4d7d40`) does, so the
/// player's filter and collapse stand; only a fresh list resets the mask (`0x4d75d9`).
#[test]
fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
benilla_formats::wow_data_or_skip!();
@@ -1047,7 +920,7 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
const DAZALAR: u64 = 0xabc;
let mut s = trainer_script();
// The reference's own file-scope defaults (1128), then the player's choice below.
// The stock file-scope defaults.
s.run("TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0")
.unwrap();
s.fire_event(
@@ -1056,7 +929,7 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
);
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
// `ui_trainer::feed_trainer`, reduced to its reset-then-fire.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_list_state(open.trainer_type);
@@ -1092,13 +965,11 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
4,
"two headers over the two learnable services"
);
// …and folds the first group away while he is at it.
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(rows(&mut s), 3, "the folded group keeps its header only");
// He trains. The spell lands (`SMSG_LEARNED_SPELL`) and the state re-evaluator (2333, the
// reference's `0x4d7d40`) repaints the bought row gray IN PLACE — no second list, so nothing
// that a list packet resets.
// He trains: the state re-evaluator (the reference's `0x4d7d40`) repaints the bought row gray
// in place, with no second list to reset anything.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
@@ -1123,11 +994,9 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
s.run("ToggleDropDownMenu(1, nil, ClassTrainerFrameFilterDropDown)")
.unwrap();
// 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).
// He walks away and back: a fresh list resets the mask to 3 (`0x4d75d9`), and the stock addon
// pushes its globals only on its one `ADDON_LOADED`, so nothing restores his choice; the
// collapse clears too.
open.clear();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
let mut learned = menu();
@@ -1150,25 +1019,10 @@ fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
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.
/// After a purchase the reference shows an empty detail pane and no highlight: the Train click
/// clears `showSkillDetails`, which `TRAINER_UPDATE` restores only for a selection index of 0 or
/// 1, and the engine answers the learned service's index in the hidden tail, past
/// `GetNumTrainerServices()` (`0x4d7520`), so the stock arm hides the pane.
#[test]
fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_one() {
benilla_formats::wow_data_or_skip!();
@@ -1176,8 +1030,7 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
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.
// The stock defaults: "already known" off, so a learned service leaves the list.
s.run("TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0")
.unwrap();
s.fire_event(
@@ -1186,7 +1039,7 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
);
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
// `ui_trainer::feed_trainer`, reduced to its reset-then-fire.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_list_state(open.trainer_type);
@@ -1211,7 +1064,6 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
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),
@@ -1225,10 +1077,8 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
"the Train button bought the selected row"
);
// The spell lands and the re-evaluator (2333) repaints the bought row gray in place; with
// "already known" off it leaves the list, and Cleave slides up into row 2 under where the
// selection used to be. (Until 2333 this was a second list packet marked as a refresh —
// B256.)
// The re-evaluator greys the bought row in place; with "already known" off it leaves the
// list, and Cleave slides up into row 2.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
@@ -1255,24 +1105,15 @@ fn learning_a_spell_takes_the_detail_pane_with_it_instead_of_stranding_the_last_
"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.
/// A filter click repaints the row buttons, not just the engine's count: the stock click handler
/// fires no update, because in the reference the engine's mask-commit thunk fires `TRAINER_UPDATE`
/// (`0x4d8c90`: `mov ecx,0x136; jmp 0x703e50`), whose arm runs `ClassTrainerFrame_Update`.
#[test]
fn a_filter_click_repaints_the_rows_the_player_is_looking_at() {
benilla_formats::wow_data_or_skip!();
@@ -1281,7 +1122,7 @@ fn a_filter_click_repaints_the_rows_the_player_is_looking_at() {
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.
// The painted row buttons, as the player sees them.
let painted = "\
local n = 0
for i = 1, 11 do
@@ -1296,8 +1137,8 @@ return n";
.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).
// The engine's queued `TRAINER_UPDATE` lands on the next tick: a binding cannot re-enter the
// handler dispatch from inside Lua.
s.tick(0.016);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -1,7 +1,4 @@
//! 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.
//! The stock tutorial alerts and window (`TutorialFrame.xml`), driven by `TUTORIAL_TRIGGER`.
use benilla_ui::script::{ScriptValue, UiScript};
@@ -40,8 +37,7 @@ 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.
/// The click's `FlagTutorial` takes the published id; the app drains it 0-based.
#[test]
fn a_trigger_queues_an_alert_whose_click_opens_the_window_and_flags_it() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -93,7 +89,7 @@ fn a_trigger_queues_an_alert_whose_click_opens_the_window_and_flags_it() {
assert_eq!(s.take_tutorial_clears(), 0);
}
/// Unticking "Display Tips" and closing calls `ClearTutorials` and drops every alert.
/// Hiding with "Display Tips" unticked clears every tutorial (`TutorialFrame.lua:7`).
#[test]
fn an_unticked_checkbox_clears_the_tutorials_on_hide() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -111,8 +107,8 @@ fn an_unticked_checkbox_clears_the_tutorials_on_hide() {
);
}
/// `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.
/// `CINEMATIC_STOP` clicks a queued Welcome alert, id 42, and centres the window
/// (`TutorialFrame.lua:104`).
#[test]
fn cinematic_stop_opens_the_welcome_window() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1,21 +1,12 @@
//! 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).
//! Decision 1996.
//! The stock `UIMenu` kit as the chat menu uses it: rows fixed at 104x16 by `UIMenu_Initialize`,
//! the label the button's own text, the shortcut a sibling anchored RIGHT. `SetText` anchors a new
//! label by the normal font's `justifyH` (`CSimpleButton::SetFontString`, `0x778d20`).
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.
/// The chat stack the menu lives in, with a fixed-advance font so the rows have widths.
fn chat_menu() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -49,9 +40,7 @@ fn edges(s: &mut UiScript, name: &str) -> (f32, f32) {
.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").
/// Label at the row's left edge, shortcut at its right, so "Macro" and "/macro" never meet.
#[test]
fn chat_menu_rows_left_align_their_label_and_right_align_their_shortcut() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -60,7 +49,7 @@ fn chat_menu_rows_left_align_their_label_and_right_align_their_shortcut() {
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).
// Macro is the tenth row `ChatMenu_OnLoad` adds (`ChatFrame.lua:2301`).
assert_eq!(
s.eval::<String>("return ChatMenuButton10:GetText()")
.unwrap(),
@@ -71,12 +60,11 @@ fn chat_menu_rows_left_align_their_label_and_right_align_their_shortcut() {
.unwrap(),
"/macro"
);
// The control: `UIMenu_Initialize` fixed the row at 104 wide; nothing here may resize it.
// `UIMenu_Initialize` fixes the row at 104 wide (`UIMenu.lua:16`).
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.
// The label's implicit anchor is LEFT to LEFT (0, 0), from `<NormalFont justifyH="LEFT"/>`.
let (p, rp, x, y): (String, String, f32, f32) = s
.eval(
"local p, _, rp, x, y = ChatMenuButton10:GetFontString():GetPoint(1) \
@@ -90,17 +78,15 @@ fn chat_menu_rows_left_align_their_label_and_right_align_their_shortcut() {
);
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.
// The template anchors the shortcut string RIGHT.
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.
// With 8 px glyphs "Macro" spans 40 and "/macro" 48 of 104; a centred label (32..72) overlaps.
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);
@@ -109,9 +95,7 @@ fn chat_menu_rows_left_align_their_label_and_right_align_their_shortcut() {
}
}
/// 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.
/// A lazily created label answers the normal font's justify and object (`0x779810`).
#[test]
fn a_menu_rows_label_reports_the_normal_fonts_justify_and_object() {
let _data = benilla_formats::wow_data_or_skip!();

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