Files
benilla-octo/crates/benilla-app/src/ui_script/group_loot_tests.rs
T
SamandClaude Fable 5 d99ad8bc7d the dressing room opens, and the pet gets its paper doll
- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:02:21 +07:00

594 lines
24 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,
};
/// Load one shipped `assets/ui/<file>` into `s` (the loot tests' loader, duplicated here so this
/// file is self-contained), panicking on any loader error and returning the frame count.
fn load_xml(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
);
report.frames
}
/// 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, "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()),
},
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()),
},
// 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,
},
],
}
}
/// 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.
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!(
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, "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.
s.run("BenillaMultiBarBottomLeft = { IsShown = function() return true end }; BenillaMultiBarBottomRight = BenillaMultiBarBottomLeft; 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("BenillaShapeshiftBarFrame = { IsShown = function() return true 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("BenillaShapeshiftBarFrame = { IsShown = function() return false 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, "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());
}