Files
benilla-octo/crates/benilla-app/src/ui_script/group_loot_tests.rs
T
SamandClaude Fable 5.1 e7aa04f41c the buff bar goes stock, and gossip quests show their level
- the buff bar is the reference's own `BuffFrame.xml`, and the swap found an engine defect: an empty `<OnLoad>` in 1.12's XML is a BLANK that takes an inherited handler away again (seven such sites across four stock files), which our loader read as "none given" — so the two enchant slots would have painted the player's first buff in the corner and cancelled it on right-click; the first fix then had to be corrected at the bytes (`SetScript 0x7025c0` tests only NULL and empty, and nothing trims, so `<OnLoad>↵</OnLoad>` compiles to a valid empty function rather than storing nil, which any addon calling `GetScript` can observe); `SHOW_BUFF_DURATIONS` is applied once post-load because 1.12 leaves that to `UIOptionsFrame.lua`, which benilla does not build — without it the bar ships a 10px pitch error on every buff
- gossip quests come through the reference's four verbs — `GetGossipAvailableQuests`/`GetGossipActiveQuests` returning (title, level) PAIRS, with the two `Select*` verbs indexing into that list — replacing three invented names that sat on the beyond-1.12 exception list; the quest LEVEL was on the wire, parsed and dropped because the invented verb had nowhere to put it, and the reference's order (available before active, whatever the packet interleaved) comes with them
- eight more files off the chain as groundwork: `LocaleProperties.lua` (the gendered/plural `GetText` selector that eleven stock call sites read their words through — fifty lines of FrameXML that looked like an engine binding), `CinematicFrame.xml`, the UIMenu kit, the `UnitFrame.lua`/`CombatFeedback.lua` kits (the only two names blocking the stock player, party, target and pet frames), the money-input kit, and `ItemButtonTemplate.xml` — the base every bag, bank, merchant, loot, trade and mail slot inherits; `GetMerchantItemMaxStack` (on the wire the whole time, dropped) and the two trade item-link verbs join the engine
- two instruments for the migration: a gap report that reads a stock window's call sites against a LOADED VM and says which engine verbs it still needs (28 windows need none, the rest name 1–5), and the readiness probe's documented false positive — loading a stock window on top of our same-named one leaves `getglobal(name)` pointing at ours while the stock handlers run against theirs, so a failure inside a colliding name is a question, not an answer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 16:58:47 +07:00

653 lines
28 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;
/// 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.
fn load_xml_no_warnings(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
assert!(
report.warnings.is_empty(),
"{file}: loader warnings: {:?}",
report.warnings
);
report.frames
}
/// 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,
})?
}
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS + GameFontNormalSmall
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 s = setup();
// Per instance: the frame + IconFrame + PassButton + RollButton + GreedButton + Timer (6),
// times 4, plus the one BenillaGroupLootFrameDriver event-listener frame.
assert_eq!(
load_xml_no_warnings(&s, "GroupLootFrame.xml"),
4 * 6 + 1,
"4 x (frame + icon + pass + need + greed + timer) + the START_LOOT_ROLL driver"
);
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 mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
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 mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
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 mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
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 mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
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").
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
.unwrap(),
""
);
// No BoP decoration for an unresolved (default-false bindOnPickUp) roll.
assert!(!s
.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
.unwrap());
// The fallback icon painted rather than an empty/erroring SetTexture(nil).
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, "in-flight roll shows the fallback icon");
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_repaints_when_it_lands() {
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
// 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());
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
.unwrap(),
"",
"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());
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
.unwrap(),
"Staff of Jordan",
"the repaint fills in the name the OnShow could not know"
);
assert!(
s.eval::<bool>("return GroupLootFrame1Decoration:IsShown()")
.unwrap(),
"and the BoP gold decoration, which OnShow also painted from nothing"
);
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 the real icon replaced the fallback"
);
// An update for a roll this frame does not hold leaves it alone.
s.fire_event("UPDATE_LOOT_ROLL", vec![ScriptValue::Int(999)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return GroupLootFrame1Name:GetText()")
.unwrap(),
"Staff of Jordan"
);
}
/// 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 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");
load_xml(&s, "GroupLootFrame.xml");
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 mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
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 s = setup();
load_xml(&s, "GroupLootFrame.xml");
// 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());
}