Files
benilla-octo/crates/benilla-app/src/ui_script/loot_tests.rs
T
SamandClaude Fable 5 44ff435304 the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:18:31 +07:00

653 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use benilla_ui::script::{
DressUpIntent, ExtractedQuad, LootRow, LootState, MerchantItem, MerchantState, QuadContent,
SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s` (the panel 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
}
/// A bare frame's own rect via its `QuadContent::Frame` entry (every frame emits one at its resolved
/// rect). The re-skinned loot window has no solid-colour fill (the UI-LootPanel slab is opaque), so
/// it's found by its own frame quad rather than a background texture.
fn frame_rect(quads: &[ExtractedQuad], w: f32, h: f32) -> benilla_ui::layout::Rect {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Frame => q
.rect
.filter(|r| (r.width() - w).abs() < 0.5 && (r.height() - h).abs() < 0.5),
_ => None,
})
.unwrap_or_else(|| panic!("no bare-frame quad sized {w}x{h}"))
}
/// The centre of the first texture quad whose path contains `needle` (a row's icon), for clicking it.
fn icon_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
let r = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
})
.and_then(|q| q.rect)
.unwrap_or_else(|| panic!("no icon 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`.
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 coin_and_two_items() -> LootState {
LootState {
fishing: false,
rows: vec![
LootRow {
item_id: 0,
name: Some("1g 23s 45c".into()),
texture: Some("Interface\\Icons\\INV_Misc_Coin_01".into()),
quantity: 1,
quality: Some(1),
is_coin: true,
link: None,
},
LootRow {
item_id: 0,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
quantity: 3,
quality: Some(2), // uncommon → green text
is_coin: false,
link: None,
},
LootRow {
item_id: 0,
name: Some("Linen Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
quantity: 1,
quality: Some(1), // common → white text
is_coin: false,
link: None,
},
],
}
}
/// The whole loot chain minus Bevy (decision 0084): LOOT_OPENED lands the window at the left slot,
/// the coin row (first) + two item rows render with quality-coloured text + a stack count, a coin
/// click and an item click each queue the right 1-based row pick, a LOOT_UPDATE with a row removed
/// repaints to two rows, and the close button releases through OnHide → CloseLoot.
#[test]
fn shipped_loot_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "UiPanels.xml");
// window + 4 rows + up + down + close.
assert_eq!(
load_xml(&s, "LootFrame.xml"),
8,
"window + 4 rows + up + down + close"
);
// Hidden by default: no coin icon on screen, left slot empty.
s.resolve();
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
})
};
assert!(
!has_icon(&s.extract(), "INV_Misc_Coin_01"),
"loot window starts hidden"
);
assert!(s.eval::<bool>("return GetLeftFrame() == nil").unwrap());
// The app's feed: a coin pile + two items.
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Shown at the left slot; three rows visible (coin + 2 items), row 4 hidden.
assert!(s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
let vis: (bool, bool, bool, bool) = s
.eval(
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible(), LootButton4:IsVisible()",
)
.unwrap();
assert_eq!(vis, (true, true, true, false), "coin + 2 items, 4th hidden");
// Only 3 items ⇒ no pager.
assert!(!s.eval::<bool>("return LootDownButton:IsVisible()").unwrap());
s.resolve();
let quads = s.extract();
// The slot anchor applied: top-left at (0, 664) — screen height 768 minus the left slot's 104.
let win = frame_rect(&quads, 256.0, 256.0);
assert_eq!(
(win.left, win.top),
(0.0, 664.0),
"loot window landed at the left slot (TOPLEFT UIParent, 0, -104)"
);
// The single UI-LootPanel slab IS the window art (ref LootFrame.xml l.88): no size/anchors → it
// fills the 256×256 window, sampling its whole texture (no TexCoords).
let panel = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-LootPanel"))
})
.and_then(|q| q.rect)
.expect("no UI-LootPanel art quad");
assert!(
(panel.width() - 256.0).abs() < 0.5 && (panel.height() - 256.0).abs() < 0.5,
"loot panel fills the 256×256 window, got {}×{}",
panel.width(),
panel.height()
);
assert_eq!(
(panel.left, panel.top),
(win.left, win.top),
"loot panel pinned to the window's TOPLEFT"
);
// The coin row is FIRST: its money text renders, and the uncommon item's text is green while the
// common item's is white (ITEM_QUALITY_COLORS).
let has_text = |t: &str| {
quads
.iter()
.any(|q| matches!(&q.content, QuadContent::Text { text: Some(x), .. } if x == t))
};
assert!(has_text("1g 23s 45c"), "coin row shows the money amount");
assert!(has_text("Wool Cloth"), "item row shows the name");
let green = text_color(&quads, "Wool Cloth").expect("Wool Cloth colour");
assert!(
(green[0] - 0.12).abs() < 0.02 && (green[1] - 1.0).abs() < 0.02 && green[2].abs() < 0.02,
"uncommon item text is green, got {green:?}"
);
let white = text_color(&quads, "Linen Cloth").expect("Linen Cloth colour");
assert!(
(white[0] - 1.0).abs() < 0.02
&& (white[1] - 1.0).abs() < 0.02
&& (white[2] - 1.0).abs() < 0.02,
"common item text is white, got {white:?}"
);
// The stack count "3" overlays the Wool Cloth row.
assert!(has_text("3"), "the x3 stack count renders");
// Click the coin row's icon → LootSlot(1); click the Wool Cloth row's icon → LootSlot(2).
let (cx, cy) = icon_center(&quads, "INV_Misc_Coin_01");
s.mouse_button(cx, cy, "LeftButton", true);
s.mouse_button(cx, cy, "LeftButton", false);
let (ix, iy) = icon_center(&quads, "INV_Fabric_Wool_01");
s.mouse_button(ix, iy, "LeftButton", true);
s.mouse_button(ix, iy, "LeftButton", false);
assert_eq!(
s.take_loot_picks(),
vec![1, 2],
"coin row is pick 1, the first item row is pick 2"
);
assert!(!s.take_loot_close());
// LOOT_UPDATE with the coin row gone (looted) repaints to two rows, keeping the window open.
s.set_loot(Some(LootState {
fishing: false,
rows: coin_and_two_items().rows[1..].to_vec(),
}));
s.fire_event("LOOT_UPDATE", vec![]);
let vis2: (bool, bool, bool) = s
.eval(
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible()",
)
.unwrap();
assert_eq!(vis2, (true, true, false), "the coin row cleared → two rows");
// The close button hides the window → OnHide → CloseLoot() queues the release intent.
s.run("BenillaLootCloseButton_OnClick()").unwrap();
assert!(
s.take_loot_close(),
"closing the window releases the loot (OnHide → CloseLoot)"
);
assert!(!s.eval::<bool>("return LootFrame:IsVisible()").unwrap());
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"HideUIPanel vacated the left slot"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The LOOTWINDOWOPENEMPTY branch (LootFrame.lua LootFrame_OnShow, l.135-136): an empty loot roll
/// queues the empty-open kit on show; a normal (non-empty) loot open queues NO sound (that kit is
/// C-side, server-driven). DR 0086 masks the lootable flag for empty rolls so live empty windows are
/// rare — but the branch must exist, and this proves it fires the right kit and only then.
#[test]
fn loot_empty_roll_plays_the_empty_open_kit() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
// A normal, non-empty loot open queues no sound (the normal open kit is C-side).
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.take_sounds().is_empty(),
"a non-empty loot open is silent (its open kit is C-side)"
);
// Close it (also silent — the loot-close kit is C-side).
s.fire_event("LOOT_CLOSED", vec![]);
assert!(s.take_sounds().is_empty(), "loot close is silent (C-side)");
// Re-open with an EMPTY roll: OnShow's numItems==0 fork queues exactly LOOTWINDOWOPENEMPTY.
s.set_loot(Some(LootState {
fishing: false,
rows: vec![],
}));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("LOOTWINDOWOPENEMPTY".into())],
"an empty loot roll plays the empty-open kit"
);
}
/// The fishing fork (LootFrame.lua LootFrame_OnShow l.137-140; decision 1086): a fishing loot open
/// plays "FISHING REEL IN" (SoundEntries "Fishing Reel in", kit 3407 — the name lookup is
/// case-insensitive on both sides) and swaps the portrait ring's skull for the FishingLoot-Icon;
/// the next ordinary open resets the skull (the ref re-stamps TargetDead at every show, l.133).
#[test]
fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
})
};
let mut fished = coin_and_two_items();
fished.fishing = true;
s.set_loot(Some(fished));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("FISHING REEL IN".into())],
"a fishing loot open plays the reel-in kit"
);
let quads = s.extract();
assert!(
has_icon(&quads, "FishingLoot-Icon"),
"the portrait ring shows the fishing icon"
);
assert!(
!has_icon(&quads, "TargetDead"),
"…instead of the dead-target skull"
);
// Close, then an ordinary corpse loot: silent again, and the skull is back.
s.fire_event("LOOT_CLOSED", vec![]);
let _ = s.take_sounds();
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.take_sounds().is_empty(),
"a non-fishing loot open is silent (its open kit is C-side)"
);
let quads = s.extract();
assert!(has_icon(&quads, "TargetDead"), "the skull is restored");
assert!(!has_icon(&quads, "FishingLoot-Icon"));
}
/// Paging (LootFrame.lua l.70-73,105-118): 5 items ⇒ 3 rows + a Down pager on page 1, 2 rows + an Up
/// pager on page 2 — the real shipped XML driving the render, not just the arithmetic.
#[test]
fn shipped_loot_frame_pages_five_items() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
let rows: Vec<LootRow> = (0..5)
.map(|i| LootRow {
item_id: 0,
name: Some(format!("Item {i}")),
texture: Some(format!("Interface\\Icons\\Item_{i}")),
quantity: 1,
quality: Some(1),
is_coin: false,
link: None,
})
.collect();
s.set_loot(Some(LootState {
fishing: false,
rows,
}));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Page 1: 3 rows (the pager spends the 4th slot), Down shown, Up hidden.
let page1: (bool, bool, bool, bool) = s
.eval(
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible(), LootButton4:IsVisible()",
)
.unwrap();
assert_eq!(page1, (true, true, true, false), "page 1 shows 3 rows");
let pager1: (bool, bool) = s
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.unwrap();
assert_eq!(pager1, (false, true), "page 1: Up hidden, Down shown");
// Page down → page 2: 2 rows, Up shown, Down hidden.
s.run("LootFrame_PageDown()").unwrap();
let page2: (bool, bool, bool) = s
.eval(
"return LootButton1:IsVisible(), LootButton2:IsVisible(),\n\
LootButton3:IsVisible()",
)
.unwrap();
assert_eq!(page2, (true, true, false), "page 2 shows the last 2 rows");
let pager2: (bool, bool) = s
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.unwrap();
assert_eq!(pager2, (true, false), "page 2: Up shown, Down hidden");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Pin §4/§5: loot (pushable=7) open, then merchant (pushable=0) open → loot is pushed to the CENTER
/// slot rather than replaced, and merchant takes the left slot loot vacated — the real windows, not
/// the synthetic stand-in the merchant panel test uses.
#[test]
fn shipped_loot_pushed_to_center_by_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "UiPanels.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
// Loot opens onto the empty left slot.
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"LootFrame\"")
.unwrap(),
"loot took the empty left slot"
);
// Merchant (pushable=0) opens: loot's pushable=7 outranks it, so loot is promoted to center and
// merchant takes the left spot.
s.set_merchant(Some(MerchantState {
items: vec![MerchantItem {
name: Some("Refreshing Spring Water".into()),
texture: Some("Interface\\Icons\\INV_Drink_18".into()),
price: 25,
quantity: 1,
num_available: -1,
item_id: 159,
stats: None,
link: None,
}],
..Default::default()
}));
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == \"LootFrame\"")
.unwrap(),
"loot was pushed to center, not replaced"
);
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"MerchantFrame\"")
.unwrap(),
"merchant took the left slot loot vacated"
);
// Both windows still visible, at their slots.
s.resolve();
let quads = s.extract();
let loot_center = frame_rect(&quads, 256.0, 256.0);
assert_eq!(
(loot_center.left, loot_center.top),
(384.0, 664.0),
"loot moved to the center slot (TOPLEFT UIParent, 384, -104)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The party frame drew through the loot window** — the director's report, and the reason
/// decision 0597 exists.
///
/// Both own the top-left corner: `ShowUIPanel` plants the loot window at `UIParent TOPLEFT
/// (0,-104)` and `PartyMemberFrame1` sits just above it. Benilla had transcribed the party template
/// without the reference's `frameStrata="LOW"` (`PartyFrameTemplates.xml` l.194), so it inherited
/// the default MEDIUM — the panels' own stratum. Within one stratum the draw key is
/// `level`-then-insertion, and the party frame's art rides a nested `$parentTextureFrame` CHILD
/// (level 1) while the loot window's `UI-LootPanel` slab is a REGION of the window itself (level
/// 0). Level outranks insertion, so being shown later could never have lifted the window above it.
///
/// Asserted on the packed draw key itself, and it pins both halves of the fix: that the template's
/// strata reaches an `inherits=` instance at all, and that it cascades down to the nested child
/// frames (`SetFrameStrata 0x76a470` is a whole-subtree cascade).
#[test]
fn the_loot_window_draws_over_the_party_frames() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// PartyFrame's per-member dropdown OnLoad walks the whole popup kit (app manifest order).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "PartyFrame.xml");
load_xml(&s, "LootFrame.xml");
// The party frame up FIRST, the window second — the order that cannot be what saves it.
s.eval::<()>("PartyMemberFrame1:Show()").unwrap();
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
let z_of = |needle: &str| {
quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains(needle))
})
.unwrap_or_else(|| panic!("no quad for {needle}"))
.z
};
// The window's own background slab (a level-0 region) against the party art (a level-1 child
// frame's region) — the exact pair that inverted.
let loot_panel = z_of("UI-LootPanel");
let party_art = z_of("UI-PartyFrame");
assert!(
loot_panel > party_art,
"the loot window's background must draw OVER the party frame art: \
panel {loot_panel:#x} vs party {party_art:#x}"
);
// And the whole party frame is below the whole window, not just that one pair.
const STRATUM_SHIFT: u32 = 60;
assert!(
(party_art >> STRATUM_SHIFT) < (loot_panel >> STRATUM_SHIFT),
"they must differ by STRATUM, not by luck within one"
);
}
/// The row click's modifier fork (ref `LootFrameItem_OnClick`, LootFrame.lua l.147-154): CTRL
/// previews the row's item in the dressing room (decision 1060), SHIFT posts its link into an open
/// chat edit box (decision 1059) — and **neither loots**.
///
/// That last clause is the whole point of the test, and it is ours to get right rather than the
/// reference's: in the real client the loot itself is the C `LootButton` widget's click behaviour
/// (l.94's `button:SetSlot(slot)`), so its Lua arms fall through harmlessly; ours owns the
/// `LootSlot` call, so the arms had to grow a `return` the reference does not have. The unmodified
/// click still loots — the regression that would otherwise ship silently.
#[test]
fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
const WOOL_LINK: &str = "|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"LootFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
// A coin row + one resolved item: the item's link is fed exactly as `ui_loot.rs` builds it.
s.set_loot(Some(LootState {
fishing: false,
rows: vec![
LootRow {
item_id: 0,
name: Some("1g 23s 45c".into()),
texture: Some("Interface\\Icons\\INV_Misc_Coin_01".into()),
quantity: 1,
quality: Some(1),
is_coin: true,
link: None, // synthesized row, no item behind it
},
LootRow {
item_id: 2589,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
quantity: 3,
quality: Some(1),
is_coin: false,
link: Some(WOOL_LINK.into()),
},
],
}));
s.fire_event("LOOT_OPENED", vec![]);
s.resolve();
let quads = s.extract();
let (x, y) = icon_center(&quads, "INV_Fabric_Wool_01");
let (coin_x, coin_y) = icon_center(&quads, "INV_Misc_Coin_01");
// The control first, while nothing else has been opened: a plain click still loots row 2.
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert_eq!(
s.take_loot_picks(),
vec![2],
"an unmodified click still loots the row"
);
// SHIFT with the chat edit box open → the link, and no loot.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
WOOL_LINK,
"the row's full escaped link landed in the chat box"
);
assert!(
s.take_loot_picks().is_empty(),
"a shift-click must not also loot the row"
);
// The COIN row has no link at all, and a modified click on it must be inert rather than an
// error: our `EditBox:Insert` binding is typed `String` and raises on a nil, so the handler
// guards it (the reference's C Insert tolerates the nil its own callers hand it).
s.set_modifiers(true, false, false);
s.mouse_button(coin_x, coin_y, "LeftButton", true);
s.mouse_button(coin_x, coin_y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.errors().is_empty(),
"shift-clicking the linkless coin row must not raise: {:?}",
s.errors()
);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
WOOL_LINK,
"and it must not disturb what is already typed"
);
assert!(
s.take_loot_picks().is_empty(),
"a shift-click on the coin row must not loot the money either"
);
// ALT → nothing at all, and in particular NOT a loot (decision 1067). No FrameXML file binds
// alt on a loot row; the suppression is the C `CLootButton::OnClick`'s own third gate
// (`0x41f8f0(2)` @ `0x4c1841`, VERIFIED at the bytes), which our Lua-side take has to
// reproduce. This is the arm nothing else in the suite would catch: before 1067, alt looted.
s.set_modifiers(false, false, true);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.take_loot_picks().is_empty(),
"an alt-click must not loot the row (the C gate's third modifier)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// CTRL → the dressing room wearing it, still no loot. Last, because opening the room takes the
// left UIPanel slot and moves the loot window (pushable 2 vs 7).
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(2589)],
"re-dress first, then try the looted item on"
);
assert!(
s.take_loot_picks().is_empty(),
"a ctrl-click must not also loot the row"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}