Files
benilla-octo/crates/benilla-app/src/ui_script/group_loot_tests.rs
T
SamandClaude Fable 5.1 eb554c0957 the inspect and talent windows are the reference's own
- the inspect and talent windows are the reference's own: `Blizzard_InspectUI` and `Blizzard_TalentUI` are LoadOnDemand addons whose real `.xml`/`.lua` live inside `patch.MPQ` — the loose `Interface\AddOns` folder holds only a `.pub` signature, which three notes in this tree had read as "packed, no source to migrate from", one wrong inference that kept five windows on hand-written transcriptions; 2300 lines of ours out, no engine work needed, and three things our talent transcription had quietly invented fell out (there was no level-10 gate, every tree scrolled where a shallow one does not, and a nil `TalentMicroButton` threw out of OnShow before the window painted)
- a nil unit token RAISES in 53 of 83 bindings: wow-re censused the registrar table at `0x850438` — `UnitName(nil)` raises and `UnitIsVisible(nil)` is quiet, one line apart in stock files, so there is no rule to derive, the table is the rule; ours answered a default and our own test had pinned it; both token positions of the seven two-token predicates are gated, `GetRaidTargetIndex` too, and `UnitIsAFK`/`UnitIsDND` are confirmed not to exist in 5875 at all (zero occurrences under a positive control) — we invented them, and they stay deliberately ungated
- `EditBox:SetNumber` did not exist (the only chain file calling it is `MoneyInputFrame.lua`, on the chain since the money kit): it is byte-identical to `SetText`, formats through `%.14g` in MSVC's CRT (`1e+020`, `1.#INF`), and `OnTextChanged` never fires from an edit — it fires from the dirty-word drain inside the box's own OnUpdate, coalesced to one per box per frame, where ours fired synchronously and `MoneyInputFrame_SetCopper` raised on it; `PanelTemplates_TabResize`'s `tab` argument is optional as the reference's is (stock macro tabs call it with `0`)
- the skull or star on your CURRENT target never appeared or cleared until you re-targeted — `RAID_TARGET_UPDATE` was never fired, and stock `TargetFrame.lua` hangs its repaint off it; a raid-to-party conversion now empties the mark board as `0x4ba550` does; and the group-loot roll popups were already on the chain inside stock `LootFrame.xml` — our 546-line `GroupLootFrame.xml` had been shadowing all four of them

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:06:18 +07:00

758 lines
34 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) {
load_xml(s, "UIParent.xml");
// `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, "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, "UIPanelTemplates.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // PASS/NEED/GREED + item hovers
load_xml(&s, "Cooldown.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the in-flight icon fallback) —
// buff_tests.rs's own load-order precedent for this same global.
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.
///
/// The guarantee this pins is therefore not an ordering but a *repair*: whenever a roll's display
/// identity changes under an open frame — the snapshot finally arriving, or a late item template —
/// `UPDATE_LOOT_ROLL(rollID)` repaints it.
#[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());
s.fire_event("UPDATE_LOOT_ROLL", vec![ScriptValue::Int(7)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// **The repaint does not happen, and this is the migration's one real loss.** Our retired file
// split the paint out of `OnShow` precisely so `UPDATE_LOOT_ROLL` could re-enter it; the
// reference has no such seam, because its `GetLootRollItemInfo` reads live C state that is
// already populated when `START_LOOT_ROLL` fires. Ours reads a pushed snapshot, and while
// `feed_loot_rolls` does push it BEFORE firing, the item template can still be in flight.
//
// The 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"
);
// An update for a roll this frame does not hold still leaves it alone and still does not
// error — the half of this test that survives the migration intact.
s.fire_event("UPDATE_LOOT_ROLL", vec![ScriptValue::Int(999)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<Option<String>>("return GroupLootFrame1Name:GetText()")
.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, "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");
// `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.
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, "ChatFrame.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:?}"
);
}