- the chat window is the reference's own `ChatFrame.xml` and `FloatingChatFrame.xml`, run off the player's chain; our 2216-line transcription is deleted, the feed fires `CHAT_MSG_*` and the reference's `ChatFrame_OnEvent` prints — no app-side composition into the windows, no routing table, no whisper chime or flash of ours, and the transcribed edit-box machine (sticky type, tell ring, Tab cycle) retires with it; the verbs the two files call are built to the bytes — the whole `0x98` chat-window record and its eight verbs, the 94-entry chat-type colour registry, the language walk, the channel verbs as a command queue, the slash handlers' emote/roll/assist/uninvite/console/logging verbs; `chat-cache.txt` is written and read in the reference's whole grammar, the 68-entry message-group table in flag order and the no-file boot included, and the chat bindings and macro lines run through the reference's own arms (1948) - the spellbook is the reference's own `SpellBookFrame.xml`; our 965-line transcription is deleted, and two quirks it reproduced by hand (the pet page ignores its page number, its arrows write the spell book's counter) now come from the reference's lines; one adapter remains because the reference's autocast shine is a `<Model>` that draws no pixels in this engine — the script puts the token-texture shine beside each button's model and carries it with the model's shown state; the nine spell-slot bindings (`GetSpellName`, `CastSpell`, `PickupSpell` and their kin) now parse their arguments the way the reference's shared marshaller `0x4b3ec0` does — a numeric arg 1, a mandatory arg 2, the index gated to [0, 0x400) with "Invalid spell slot in <Verb>" on a miss, "pet" matched without case — where ours raised mlua's own message on a negative id, answered nil for 0 and above 1024, and read the wrong book for "PET" (1952) - a long-hidden world model part parks its `Mesh3d`: the entity path keeps the batches the retained pass declines resident — 5.3k hidden submeshes at any instant at the Stormwind auction house — and Bevy's sweeps walk every `Mesh3d` whether it draws or not, so a part parks after 30 consecutive hidden frames and unparks the frame its verdict turns visible; every world model part takes part, units' bodies included, while a booth's mirror parts stay out; interleaved pairs read an alone Stormwind 0.1–0.4 ms lighter, and `WOW_NO_MESH_PARK=1` is the A/B; the audio thread's realtime class was the standing suspect for the raid's `cpu_p99` spikes with sound on, and `WOW_AUDIO_NO_RT=1` across two interleaved Blackrock pairs read no difference, so the lever stays as an instrument (1949); and a no-merge prop batch now says which rule declined it — animated (1551 at the auction house) or transparent, not order-free (340) (1951) - the probe reads CPU per thread across its window (user and system split, because a user-mode sampler never sees kernel time) and the main thread's CPU inside the app's frame beside its whole-frame CPU; together they place the vsync tax every player pays by default — cpu_ms 10.7 uncapped against 12.5 under vsync, alone in Stormwind, with no system growing and no thread spinning: it lands on the main thread, in user time, inside the schedules, the same systems each running slower, and a low-priority spinner holding one core busy erases it to the last decimal (vsync + spinner 10.18 against uncapped 10.17), which is frequency scaling — under vsync the cores idle deeper between frames and the next burst starts at a lower clock; nothing in the client is the cause and nothing in the client changes for it (1950) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
786 lines
36 KiB
Rust
786 lines
36 KiB
Rust
//! The group-loot roll popups (decision 0591, `assets/ui/GroupLootFrame.xml`): the four stacked
|
|
//! `GroupLootFrame`s that answer `START_LOOT_ROLL`/`CANCEL_LOOT_ROLL` off a pushed
|
|
//! [`LootRollsState`] snapshot (the `loot_roll.rs` seam's own harness idiom, mirrored here the way
|
|
//! `loot_tests.rs` mirrors it for `set_loot`/`LootState`).
|
|
|
|
use benilla_ui::script::{
|
|
DressUpIntent, ExtractedQuad, LootRollEntry, LootRollsState, QuadContent, ScriptValue, UiScript,
|
|
};
|
|
|
|
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.
|
|
fn quad_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
|
|
let r = quads
|
|
.iter()
|
|
.find(|q| {
|
|
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
|
|
})
|
|
.and_then(|q| q.rect)
|
|
.unwrap_or_else(|| panic!("no quad for {needle}"));
|
|
((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).
|
|
fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
|
|
quads.iter().find_map(|q| match &q.content {
|
|
QuadContent::Text {
|
|
text: Some(x),
|
|
color,
|
|
..
|
|
} if x == t => Some(*color),
|
|
_ => None,
|
|
})?
|
|
}
|
|
|
|
/// 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.
|
|
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.
|
|
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");
|
|
}
|
|
|
|
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).
|
|
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, r"Interface\FrameXML\ItemButtonTemplate.xml");
|
|
// `UIPanelCloseButton`, which the loot window's four close buttons inherit.
|
|
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
|
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
|
load_xml(&s, "MoneyFrame.xml");
|
|
load_xml(&s, "UiPanels.xml");
|
|
load_xml(&s, "GameTooltip.xml"); // PASS/NEED/GREED + item hovers
|
|
load_xml(&s, "Cooldown.xml");
|
|
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
|
|
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
|
|
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
|
|
load_xml(&s, "UIParent.xml");
|
|
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
|
|
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
|
|
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
|
|
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); // BENILLA_FALLBACK_ICON (the in-flight icon fallback) —
|
|
// buff_tests.rs's own load-order precedent for this same global.
|
|
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.
|
|
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.
|
|
fn rolls() -> LootRollsState {
|
|
LootRollsState {
|
|
rolls: vec![
|
|
LootRollEntry {
|
|
roll_id: 7,
|
|
name: Some("Staff of Jordan".into()),
|
|
texture: Some("Interface\\Icons\\INV_Staff_12".into()),
|
|
quantity: 1,
|
|
quality: Some(4), // Epic -> purple
|
|
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,
|
|
},
|
|
LootRollEntry {
|
|
roll_id: 8,
|
|
name: Some("Worn Shortsword".into()),
|
|
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
|
|
quantity: 1,
|
|
quality: Some(1), // Common -> white
|
|
bind_on_pickup: false,
|
|
time_left_ms: 60_000,
|
|
item_id: 25,
|
|
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.
|
|
LootRollEntry {
|
|
roll_id: 9,
|
|
name: None,
|
|
texture: None,
|
|
quantity: 1,
|
|
quality: None,
|
|
bind_on_pickup: false,
|
|
time_left_ms: 55_000,
|
|
item_id: 4306,
|
|
link: None,
|
|
random_property_id: 0,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
/// The XML loads with zero loader warnings/errors, and all four `GroupLootFrame1..4` exist
|
|
/// and start hidden.
|
|
#[test]
|
|
fn shipped_group_loot_frame_loads_clean_and_starts_hidden() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let s = setup();
|
|
load_xml(&s, "UIParent.xml");
|
|
load_xml(&s, r"Interface\FrameXML\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.
|
|
assert!(
|
|
load_xml_no_warnings(&s, r"Interface\FrameXML\LootFrame.xml") > 4,
|
|
"the loot window brought its frames"
|
|
);
|
|
|
|
for i in 1..=4 {
|
|
let name = format!("GroupLootFrame{i}");
|
|
assert!(
|
|
!s.eval::<bool>(&format!("return {name}:IsVisible()"))
|
|
.unwrap(),
|
|
"{name} 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`.
|
|
#[test]
|
|
fn start_loot_roll_claims_frames_in_order_and_paints_the_roll() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = setup();
|
|
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)],
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
assert!(s
|
|
.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).
|
|
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.
|
|
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)],
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
assert!(s
|
|
.eval::<bool>("return GroupLootFrame1:IsVisible()")
|
|
.unwrap());
|
|
assert!(s
|
|
.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!(
|
|
s.eval::<String>("return GroupLootFrame1Name:GetText()")
|
|
.unwrap(),
|
|
"Staff of Jordan"
|
|
);
|
|
let purple = text_color(&quads, "Staff of Jordan").expect("Staff of Jordan colour");
|
|
assert!(
|
|
(purple[0] - 0.64).abs() < 0.02 && (purple[1] - 0.21).abs() < 0.02,
|
|
"Epic item text is purple, got {purple:?}"
|
|
);
|
|
let white = text_color(&quads, "Worn Shortsword").expect("Worn Shortsword colour");
|
|
assert!(
|
|
(white[0] - 1.0).abs() < 0.02 && (white[1] - 1.0).abs() < 0.02,
|
|
"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.
|
|
let (nx, ny) = s
|
|
.eval::<(f64, f64)>(
|
|
"return (GroupLootFrame1RollButton:GetLeft() + GroupLootFrame1RollButton:GetRight()) \
|
|
/ 2, (GroupLootFrame1RollButton:GetBottom() + GroupLootFrame1RollButton:GetTop()) / 2",
|
|
)
|
|
.unwrap();
|
|
let (nx, ny) = (nx as f32, ny as f32);
|
|
assert_eq!(
|
|
s.hit_test_name(nx, ny).as_deref(),
|
|
Some("GroupLootFrame1RollButton"),
|
|
"the click has to land on frame 1's Need button for the rest of this to mean anything"
|
|
);
|
|
s.mouse_button(nx, ny, "LeftButton", true);
|
|
s.mouse_button(nx, ny, "LeftButton", false);
|
|
assert!(
|
|
s.take_loot_roll_votes().is_empty(),
|
|
"a BoP Need must not reach the wire before the confirm"
|
|
);
|
|
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.
|
|
#[test]
|
|
fn the_bop_confirm_popup_lands_the_withheld_vote() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = setup();
|
|
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`).
|
|
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.
|
|
assert!(
|
|
s.eval::<bool>("return StaticPopup_FindVisible(\"CONFIRM_LOOT_ROLL\") ~= nil")
|
|
.unwrap(),
|
|
"the confirm popup should be visible"
|
|
);
|
|
let (data, data2) = s
|
|
.eval::<(i64, i64)>(
|
|
"local d = StaticPopup_FindVisible(\"CONFIRM_LOOT_ROLL\")\nreturn d.data, d.data2",
|
|
)
|
|
.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.
|
|
let dialog = s
|
|
.eval::<String>("return StaticPopup_FindVisible(\"CONFIRM_LOOT_ROLL\"):GetName()")
|
|
.unwrap();
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {dialog}Text:GetText()"))
|
|
.unwrap(),
|
|
"Looting this item will bind it to you."
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return {dialog}Button1:GetText()"))
|
|
.unwrap(),
|
|
"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",
|
|
)
|
|
.unwrap();
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
assert_eq!(
|
|
s.take_loot_roll_votes(),
|
|
vec![(7, 1)],
|
|
"OK on the confirm sends the Need the gate withheld"
|
|
);
|
|
}
|
|
|
|
/// `CANCEL_LOOT_ROLL` for a rollID hides that frame, and ONLY that one.
|
|
#[test]
|
|
fn cancel_loot_roll_hides_only_that_frame() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = setup();
|
|
load_group_loot(&s);
|
|
s.set_loot_rolls(rolls());
|
|
|
|
s.fire_event(
|
|
"START_LOOT_ROLL",
|
|
vec![ScriptValue::Int(7), ScriptValue::Int(42_000)],
|
|
);
|
|
s.fire_event(
|
|
"START_LOOT_ROLL",
|
|
vec![ScriptValue::Int(8), ScriptValue::Int(60_000)],
|
|
);
|
|
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
|
|
.eval::<bool>("return GroupLootFrame1:IsVisible()")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return GroupLootFrame2:IsVisible()")
|
|
.unwrap());
|
|
|
|
// A cancel for a rollID nobody holds any more (or never held) is a harmless 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());
|
|
assert!(s
|
|
.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
|
|
.eval::<bool>("return GroupLootFrame2:IsVisible()")
|
|
.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.
|
|
#[test]
|
|
fn in_flight_roll_does_not_error_and_falls_back() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = setup();
|
|
load_group_loot(&s);
|
|
s.set_loot_rolls(rolls());
|
|
|
|
s.fire_event(
|
|
"START_LOOT_ROLL",
|
|
vec![ScriptValue::Int(9), ScriptValue::Int(55_000)],
|
|
);
|
|
assert!(
|
|
s.errors().is_empty(),
|
|
"an in-flight roll (all-nil GetLootRollItemInfo) must not error: {:?}",
|
|
s.errors()
|
|
);
|
|
assert!(s
|
|
.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).
|
|
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.
|
|
s.resolve();
|
|
let has_fallback_icon = s.extract().iter().any(|q| {
|
|
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
|
if p.contains("INV_Misc_QuestionMark"))
|
|
});
|
|
assert!(
|
|
!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 production ordering** — the one every other test in this file quietly assumes away.
|
|
///
|
|
/// `feed_loot_rolls` drains `rolls.opened` in the *same pass* that builds the snapshot, so the model
|
|
/// a `START_LOOT_ROLL` OnShow reads can never already contain the roll that just opened: the entry
|
|
/// is added to `LootRolls::active` and to `opened` in one call. Every other test here calls
|
|
/// `set_loot_rolls` **first** and so paints from a model the app would not have had yet — which is
|
|
/// exactly how a roll dialog that shipped "green" reached the director showing a `?` icon and a
|
|
/// blank name.
|
|
///
|
|
/// What this pins now is the DEFECT, deliberately: a roll whose template lands after the frame is
|
|
/// up keeps its `?`. Our retired frame repaired it from an `UPDATE_LOOT_ROLL(rollID)`; the stock
|
|
/// frame has no such seam and never listened, so 1883 stopped firing an event the 1.12 client does
|
|
/// not have. Decision 1838 carries the real fix — hold the roll until its template resolves, which
|
|
/// is the reference's own invariant. This test goes green-to-red the day that lands, which is the
|
|
/// point of writing it against the current behaviour rather than deleting it.
|
|
#[test]
|
|
fn a_roll_that_opens_before_its_snapshot_stays_blank() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = setup();
|
|
load_group_loot(&s);
|
|
|
|
// The app's order: the event first, against a model that has no such roll at all.
|
|
s.fire_event(
|
|
"START_LOOT_ROLL",
|
|
vec![ScriptValue::Int(7), ScriptValue::Int(42_000)],
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
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(),
|
|
None,
|
|
"nothing to paint yet — this is the state the director saw"
|
|
);
|
|
|
|
// ...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, and this is the migration's one real loss.** Our retired file
|
|
// split the paint out of `OnShow` precisely so an `UPDATE_LOOT_ROLL` could re-enter it — an
|
|
// event the 1.12 client does not have, which 1883 stopped firing once the stock frame proved
|
|
// nothing listened for it. The
|
|
// reference has no such seam, because its `GetLootRollItemInfo` reads live C state that is
|
|
// already populated when `START_LOOT_ROLL` fires. Ours reads a pushed snapshot, and while
|
|
// `feed_loot_rolls` does push it BEFORE firing, the item template can still be in flight.
|
|
//
|
|
// The fix is app-side ordering, not a Lua shim: hold the roll until its template resolves.
|
|
// Decision 1838 carries that, and the engine question an adapter ran into on the way — a
|
|
// `Hide()`/`Show()` round trip re-fires `OnShow` from a plain chunk but not from inside an
|
|
// event handler.
|
|
assert_eq!(
|
|
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
|
|
.unwrap(),
|
|
None,
|
|
"the name stays blank: the reference has no repaint path"
|
|
);
|
|
assert!(
|
|
!s.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
|
|
.unwrap(),
|
|
"and so does the BoP decoration"
|
|
);
|
|
s.resolve();
|
|
let quads = s.extract();
|
|
assert!(
|
|
!quads.iter().any(|q| {
|
|
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
|
if p.contains("INV_Staff_12"))
|
|
}),
|
|
"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()")
|
|
.unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// 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.
|
|
#[test]
|
|
fn managed_positions_engage_for_the_bare_frame_name() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
|
|
load_xml(&s, "UIParent.xml");
|
|
// UiPanels.xml before GroupLootFrame.xml, mirroring the shipped manifest order
|
|
// (`ui_script::load_default_ui`): the roll file's CONFIRM_LOOT_ROLL entry indexes
|
|
// `StaticPopupDialogs`, and indexing a nil there aborts the WHOLE inline <Script> chunk —
|
|
// taking every BenillaGroupLootFrame_* function down with it, not just the popup.
|
|
load_xml(&s, "MoneyFrame.xml");
|
|
load_xml(&s, "UiPanels.xml");
|
|
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
|
|
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
|
|
// `TOOLTIP_DEFAULT_COLOR`, which the chain's dropdown backdrops read in their OnLoad — the
|
|
// dropdown kit rides in with the loot window now (1838), so this bespoke setup needs it too.
|
|
load_xml(&s, "GameTooltip.xml");
|
|
load_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.
|
|
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.
|
|
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; MultiBarBottomRight = MultiBarBottomLeft; 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.
|
|
s.run(
|
|
"local t = { Show = function() end, Hide = function() end } \
|
|
ShapeshiftBarLeft, ShapeshiftBarMiddle, ShapeshiftBarRight = t, t, t",
|
|
)
|
|
.unwrap();
|
|
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
|
|
.unwrap();
|
|
s.resolve();
|
|
assert_eq!(bottom(&s), 144.0, "60 + 42 + pet 42");
|
|
|
|
// And it settles back when the stance bar hides.
|
|
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
|
|
.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.
|
|
#[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, "UIParent.xml"); // BenillaChatEdit_InsertLink, the shared shift-insert helper
|
|
load_xml(&s, "DressUpFrame.xml");
|
|
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");
|
|
load_xml(&s, "Interface\\FrameXML\\ChatFrame.xml");
|
|
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
|
|
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.lua");
|
|
load_xml(&s, "Interface\\FrameXML\\UIPanelTemplates.xml");
|
|
load_xml(&s, "UiPanels.xml");
|
|
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.
|
|
s.fire_event(
|
|
"START_LOOT_ROLL",
|
|
vec![ScriptValue::Int(8), ScriptValue::Int(60_000)],
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
s.resolve();
|
|
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.
|
|
s.mouse_button(x, y, "LeftButton", true);
|
|
s.mouse_button(x, y, "LeftButton", false);
|
|
assert!(
|
|
s.take_loot_roll_votes().is_empty() && s.take_loot_roll_confirms().is_empty(),
|
|
"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);
|
|
s.mouse_button(x, y, "LeftButton", false);
|
|
s.set_modifiers(false, false, false);
|
|
assert_eq!(
|
|
s.eval::<String>("return ChatFrameEditBox:GetText()")
|
|
.unwrap(),
|
|
SWORD_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);
|
|
s.set_modifiers(false, false, false);
|
|
assert_eq!(
|
|
s.take_dressup_intents(),
|
|
vec![DressUpIntent::Dress, DressUpIntent::TryOn(25)],
|
|
"re-dress first, then try the rolled item on"
|
|
);
|
|
assert!(
|
|
s.take_loot_roll_votes().is_empty() && s.take_loot_roll_confirms().is_empty(),
|
|
"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);
|
|
assert_eq!(
|
|
s.take_loot_roll_votes(),
|
|
vec![(8, 1)],
|
|
"the Need button still votes"
|
|
);
|
|
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.
|
|
#[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.
|
|
s.run(r#"Probe = CreateFrame("Frame", "ProbeRoll", nil, "GroupLootFrameTemplate")"#)
|
|
.expect("the reference-named template must resolve");
|
|
|
|
for part in [
|
|
"IconFrame",
|
|
"IconFrameIcon",
|
|
"Name",
|
|
"Corner",
|
|
"Decoration",
|
|
"Timer",
|
|
] {
|
|
assert!(
|
|
s.eval::<bool>(&format!(r#"return getglobal("ProbeRoll{part}") ~= nil"#))
|
|
.unwrap(),
|
|
"an addon inheriting the template must find ProbeRoll{part}"
|
|
);
|
|
}
|
|
|
|
// The timer is a StatusBar, not a plain frame: `bar.lua:33` calls SetMinMaxValues on it.
|
|
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.
|
|
#[test]
|
|
fn the_stock_group_loot_frame_survives_an_in_flight_roll() {
|
|
let _data = benilla_formats::wow_data_or_skip!();
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
for f in super::test_ui::LOOT_UI {
|
|
load_xml(&s, f);
|
|
}
|
|
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.
|
|
for roll in [9, 99] {
|
|
s.run(&format!("GroupLootFrame_OpenNewFrame({roll}, 55000)"))
|
|
.unwrap_or_else(|e| panic!("stock OpenNewFrame raised on roll {roll}: {e}"));
|
|
assert!(
|
|
s.errors().is_empty(),
|
|
"stock GroupLootFrame_OnShow raised on roll {roll}: {:?}",
|
|
s.errors()
|
|
);
|
|
}
|
|
assert!(s
|
|
.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.
|
|
let painted: (f64, f64, f64) = s
|
|
.eval("local r, g, b = GroupLootFrame1Name:GetTextColor()\nreturn r, g, b")
|
|
.unwrap();
|
|
let common: (f64, f64, f64) = s
|
|
.eval("local r, g, b = GetItemQualityColor(1)\nreturn r, g, b")
|
|
.unwrap();
|
|
assert!(
|
|
(painted.0 - common.0).abs() < 1e-6
|
|
&& (painted.1 - common.1).abs() < 1e-6
|
|
&& (painted.2 - common.2).abs() < 1e-6,
|
|
"the stock roll popup paints the miss tail's Common, got {painted:?} want {common:?}"
|
|
);
|
|
}
|