- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference - the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil - the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them - the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1075 lines
42 KiB
Rust
1075 lines
42 KiB
Rust
use benilla_ui::script::{
|
||
DressUpIntent, ExtractedQuad, LootRow, LootState, MerchantItem, MerchantState, QuadContent,
|
||
SoundRequest, UiScript,
|
||
};
|
||
|
||
use super::test_ui::load_ui as load_xml;
|
||
|
||
/// 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,
|
||
master_candidates: Vec::new(),
|
||
rows: vec![
|
||
Some(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,
|
||
random_property_id: 0,
|
||
}),
|
||
Some(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,
|
||
random_property_id: 0,
|
||
}),
|
||
Some(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,
|
||
random_property_id: 0,
|
||
}),
|
||
],
|
||
}
|
||
}
|
||
|
||
/// 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 the coin row
|
||
/// looted hides ITS button in place (the items keep their rows — the fixed slot layout), 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);
|
||
for f in super::test_ui::LOOT_UI {
|
||
load_xml(&s, f);
|
||
}
|
||
assert_eq!(
|
||
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml"),
|
||
34,
|
||
"the STOCK file's own shape (1751): the window, its portrait overlay and Next/Prev art, \
|
||
four LootButton rows each carrying ItemButtonTemplate's sub-frames, the two pagers, the \
|
||
close button, and GroupLootDropDown with the dropdown template's own children. Our \
|
||
transcription materialized 10 — a count is a fingerprint of a file, not a property of a \
|
||
loot window, and this one is now the reference's"
|
||
);
|
||
|
||
// 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 LootFrameDownButton: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" sits INSIDE the Wool Cloth row's icon, at the reference
|
||
// ItemButtonTemplate inset (BOTTOMRIGHT -5,+2 of the 37px icon) — the director's report was
|
||
// this count overshooting the icon's right border onto the name plate.
|
||
let count_rect = quads
|
||
.iter()
|
||
.find_map(|q| match &q.content {
|
||
QuadContent::Text { text: Some(x), .. } if x == "3" => q.rect,
|
||
_ => None,
|
||
})
|
||
.expect("the x3 stack count renders");
|
||
let icon_rect = quads
|
||
.iter()
|
||
.find(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||
if p.contains("INV_Fabric_Wool_01"))
|
||
})
|
||
.and_then(|q| q.rect)
|
||
.expect("Wool Cloth icon quad");
|
||
assert!(
|
||
(count_rect.right - (icon_rect.right - 5.0)).abs() < 0.6,
|
||
"count right edge pinned 5px inside the icon (ref ItemButtonTemplate), got {} vs icon right {}",
|
||
count_rect.right,
|
||
icon_rect.right
|
||
);
|
||
assert!(
|
||
(count_rect.bottom - (icon_rect.bottom + 2.0)).abs() < 0.6,
|
||
"count bottom pinned 2px above the icon bottom, got {} vs icon bottom {}",
|
||
count_rect.bottom,
|
||
icon_rect.bottom
|
||
);
|
||
|
||
// 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 LOOTED: its slot becomes a gap (`None`) — its button hides in
|
||
// place and the two items KEEP their rows (the reference's fixed slot layout; the director's
|
||
// report was these rows sliding up to fill the coin's spot).
|
||
let mut coin_looted = coin_and_two_items();
|
||
coin_looted.rows[0] = None;
|
||
s.set_loot(Some(coin_looted));
|
||
// The app's own pair, in the app's own order (`ui_loot`): the per-slot event first — the
|
||
// stock file hangs "hide the button in place" off it and only off it — then the summary
|
||
// repaint. Firing LOOT_UPDATE alone leaves the looted row drawn, which is what a client that
|
||
// sent only the summary would actually show.
|
||
s.fire_event(
|
||
"LOOT_SLOT_CLEARED",
|
||
vec![benilla_ui::script::ScriptValue::Int(1)],
|
||
);
|
||
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,
|
||
(false, true, true),
|
||
"the looted coin row hides in place — the items do not shift up"
|
||
);
|
||
|
||
// The close button hides the window → OnHide → CloseLoot() queues the release intent.
|
||
s.run("LootCloseButton:Click()").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);
|
||
for f in super::test_ui::LOOT_UI {
|
||
load_xml(&s, f);
|
||
}
|
||
load_xml(&s, "Interface\\FrameXML\\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,
|
||
master_candidates: Vec::new(),
|
||
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);
|
||
for f in super::test_ui::LOOT_UI {
|
||
load_xml(&s, f);
|
||
}
|
||
load_xml(&s, "Interface\\FrameXML\\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);
|
||
for f in super::test_ui::LOOT_UI {
|
||
load_xml(&s, f);
|
||
}
|
||
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
|
||
|
||
let rows: Vec<Option<LootRow>> = (0..5)
|
||
.map(|i| {
|
||
Some(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,
|
||
random_property_id: 0,
|
||
})
|
||
})
|
||
.collect();
|
||
s.set_loot(Some(LootState {
|
||
fishing: false,
|
||
master_candidates: Vec::new(),
|
||
rows: rows.clone(),
|
||
}));
|
||
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 LootFrameUpButton:IsVisible(), LootFrameDownButton: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 LootFrameUpButton:IsVisible(), LootFrameDownButton:IsVisible()")
|
||
.unwrap();
|
||
assert_eq!(pager2, (true, false), "page 2: Up shown, Down hidden");
|
||
|
||
// Back on page 1, loot all three of its rows: their buttons hide in place, and the emptied
|
||
// page advances to page 2 on its own — the ref's LOOT_SLOT_CLEARED tail (LootFrame.lua
|
||
// l.38-50: every button hidden + Down visible → PageDown).
|
||
//
|
||
// The events are the app's own, in the app's own order: one LOOT_SLOT_CLEARED per row that
|
||
// went away (`ui_loot`'s emitter), then the summary LOOT_UPDATE. The auto-advance hangs off
|
||
// the per-slot event and ONLY off it, which is why the app fires both.
|
||
s.run("LootFrame_PageUp()").unwrap();
|
||
let mut cleared = rows;
|
||
cleared[0] = None;
|
||
cleared[1] = None;
|
||
cleared[2] = None;
|
||
s.set_loot(Some(LootState {
|
||
fishing: false,
|
||
master_candidates: Vec::new(),
|
||
rows: cleared,
|
||
}));
|
||
for row in 1..=3 {
|
||
s.fire_event(
|
||
"LOOT_SLOT_CLEARED",
|
||
vec![benilla_ui::script::ScriptValue::Int(row)],
|
||
);
|
||
}
|
||
s.fire_event("LOOT_UPDATE", vec![]);
|
||
assert_eq!(
|
||
s.eval::<i64>("return LootFrame.page").unwrap(),
|
||
2,
|
||
"an emptied page auto-advances to the next"
|
||
);
|
||
let auto: (bool, bool) = s
|
||
.eval("return LootButton1:IsVisible(), LootButton2:IsVisible()")
|
||
.unwrap();
|
||
assert_eq!(auto, (true, true), "…showing the two remaining rows");
|
||
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);
|
||
for f in super::test_ui::LOOT_UI {
|
||
load_xml(&s, f);
|
||
}
|
||
load_xml(&s, "Interface\\FrameXML\\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,
|
||
max_stack: Some(1),
|
||
}],
|
||
..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);
|
||
for f in super::test_ui::LOOT_UI {
|
||
load_xml(&s, f);
|
||
}
|
||
load_xml(&s, "Interface\\FrameXML\\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 f in super::test_ui::LOOT_UI {
|
||
load_xml(&s, f);
|
||
}
|
||
for file in [
|
||
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
|
||
"Interface\\FrameXML\\LootFrame.xml",
|
||
"DressUpFrame.xml",
|
||
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
|
||
"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,
|
||
master_candidates: Vec::new(),
|
||
rows: vec![
|
||
Some(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
|
||
random_property_id: 0,
|
||
}),
|
||
Some(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()),
|
||
random_property_id: 0,
|
||
}),
|
||
],
|
||
}));
|
||
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());
|
||
}
|
||
|
||
/// **Master loot, end to end through the shipped XML** (decision 1675).
|
||
///
|
||
/// The whole interaction in one pass: a row click stashes the four `LootFrame.selected*` fields
|
||
/// the dropdown reads, `OPEN_MASTER_LOOT_LIST` raises the menu anchored on that row, a candidate
|
||
/// row hands the item straight out when the item is below `MASTER_LOOT_THREHOLD`, and an at-or-
|
||
/// above-threshold item raises `CONFIRM_LOOT_DISTRIBUTION` first and only sends on accept.
|
||
///
|
||
/// The two thresholds matter: `MASTER_LOOT_THREHOLD` is 4 (epic), so an uncommon goes straight
|
||
/// out and an epic asks. The misspelling is the reference's own (`LootFrame.lua:3`).
|
||
#[test]
|
||
fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
|
||
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");
|
||
|
||
let row = |name: &str, quality: u32| {
|
||
Some(LootRow {
|
||
item_id: 17182,
|
||
name: Some(name.into()),
|
||
texture: Some("Interface\\Icons\\INV_Sword_39".into()),
|
||
quantity: 1,
|
||
quality: Some(quality),
|
||
is_coin: false,
|
||
link: None,
|
||
random_property_id: 0,
|
||
})
|
||
};
|
||
s.set_loot(Some(LootState {
|
||
fishing: false,
|
||
master_candidates: vec![Some("Thrall".into()), Some("Cairne".into())],
|
||
rows: vec![row("Wool Cloth", 2), row("Thunderfury", 4)],
|
||
}));
|
||
s.fire_event("LOOT_OPENED", vec![]);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
let _ = s.take_loot_picks();
|
||
|
||
// ── The row click stashes the selection the dropdown will read ────────────────────────────
|
||
let click = |s: &mut UiScript, frame: &str| {
|
||
let (x, y) = s
|
||
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
|
||
.unwrap();
|
||
s.mouse_button(x as f32, y as f32, "LeftButton", true);
|
||
s.mouse_button(x as f32, y as f32, "LeftButton", false);
|
||
s.resolve();
|
||
};
|
||
click(&mut s, "LootButton1");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
assert_eq!(s.eval::<i64>("return LootFrame.selectedSlot").unwrap(), 1);
|
||
assert_eq!(
|
||
s.eval::<i64>("return LootFrame.selectedQuality").unwrap(),
|
||
2
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return LootFrame.selectedItemName")
|
||
.unwrap(),
|
||
"Wool Cloth"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return LootFrame.selectedLootButton")
|
||
.unwrap(),
|
||
"LootButton1",
|
||
"the dropdown anchors on the clicked row, not the window"
|
||
);
|
||
// The click still queues the pick — deciding whether it is a take or a master-loot open is
|
||
// the app's, off the wire slot type, exactly as the real client decides it in C.
|
||
assert_eq!(s.take_loot_picks(), vec![1]);
|
||
|
||
// ── The app answers a MASTER row with the event; the menu lists the candidates ─────────────
|
||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
assert!(
|
||
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
|
||
"the candidate menu is up"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button1:GetText()")
|
||
.unwrap(),
|
||
"Thrall"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button2:GetText()")
|
||
.unwrap(),
|
||
"Cairne"
|
||
);
|
||
|
||
// ── Below the threshold: straight out, no confirmation ────────────────────────────────────
|
||
click(&mut s, "DropDownList1Button2");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
assert_eq!(
|
||
s.take_loot_master_gives(),
|
||
vec![(1, 2)],
|
||
"row 1 to candidate 2"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||
"an uncommon item is under MASTER_LOOT_THREHOLD — no confirmation"
|
||
);
|
||
|
||
// ── At the threshold: the confirmation stands between the click and the send ──────────────
|
||
click(&mut s, "LootButton2");
|
||
assert_eq!(
|
||
s.eval::<i64>("return LootFrame.selectedQuality").unwrap(),
|
||
4
|
||
);
|
||
let _ = s.take_loot_picks();
|
||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||
s.resolve();
|
||
click(&mut s, "DropDownList1Button1");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
assert!(
|
||
s.take_loot_master_gives().is_empty(),
|
||
"an epic asks first — nothing is sent on the dropdown click"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||
"CONFIRM_LOOT_DISTRIBUTION is up"
|
||
);
|
||
s.run("StaticPopup1Button1:Click()").unwrap(); // Yes
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
assert_eq!(
|
||
s.take_loot_master_gives(),
|
||
vec![(2, 1)],
|
||
"row 2 to candidate 1, only after the accept"
|
||
);
|
||
|
||
// A fresh row click hides a standing confirmation, so an accept can never land on a row the
|
||
// player has since clicked away from (the ref's own reason for the StaticPopup_Hide).
|
||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||
s.resolve();
|
||
click(&mut s, "DropDownList1Button1");
|
||
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||
click(&mut s, "LootButton1");
|
||
assert!(
|
||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||
"clicking another row closes the standing confirmation"
|
||
);
|
||
}
|
||
|
||
/// **The raid arm of the master-loot menu** (decision 1675) — the half the wow-re §5 corrected.
|
||
///
|
||
/// In a raid the candidate array is not the wire order: the client files each candidate into its
|
||
/// own subgroup's five-slot block, leaving holes. `GroupLootDropDown_Initialize` reads those holes
|
||
/// as raid-group membership — it walks `1..40` in blocks of five and keeps a "Group N" submenu only
|
||
/// where the block has an occupant. This drives the shipped XML with a hole-shaped list and asserts
|
||
/// the menu that comes out, which a densely-packed list could not produce.
|
||
#[test]
|
||
fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
|
||
use benilla_ui::script::{PartyState, RaidMemberInfo};
|
||
|
||
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");
|
||
|
||
// A raid, so the dropdown takes its nested arm (GetNumRaidMembers() > 0 is the whole gate).
|
||
s.set_party(PartyState {
|
||
raid: vec![RaidMemberInfo::default(); 7],
|
||
loot_method: "master".into(),
|
||
master_looter: Some(0),
|
||
loot_threshold: 2,
|
||
..PartyState::default()
|
||
});
|
||
|
||
// Subgroup 1 holds slots 1-2, subgroup 3 holds slot 11 — everything else is a hole. That is
|
||
// the placement `SMSG_LOOT_MASTER_LIST`'s handler produces for a raid.
|
||
let mut candidates = vec![None; 12];
|
||
candidates[0] = Some("Thrall".to_string());
|
||
candidates[1] = Some("Cairne".to_string());
|
||
candidates[10] = Some("Sylvanas".to_string());
|
||
s.set_loot(Some(LootState {
|
||
fishing: false,
|
||
master_candidates: candidates,
|
||
rows: vec![Some(LootRow {
|
||
item_id: 17182,
|
||
name: Some("Thunderfury".into()),
|
||
texture: Some("Interface\\Icons\\INV_Sword_39".into()),
|
||
quantity: 1,
|
||
quality: Some(2),
|
||
is_coin: false,
|
||
link: None,
|
||
random_property_id: 0,
|
||
})],
|
||
}));
|
||
s.fire_event("LOOT_OPENED", vec![]);
|
||
let (x, y) = s
|
||
.eval::<(f64, f64)>("return LootButton1:GetCenter()")
|
||
.unwrap();
|
||
s.mouse_button(x as f32, y as f32, "LeftButton", true);
|
||
s.mouse_button(x as f32, y as f32, "LeftButton", false);
|
||
s.resolve();
|
||
let _ = s.take_loot_picks();
|
||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
|
||
// A title row, then ONE row per occupied block — not one per candidate, and labelled by the
|
||
// block's own number rather than by position in the list.
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button1:GetText()")
|
||
.unwrap(),
|
||
"Give Loot To:",
|
||
"the raid arm opens with the title row the party arm has no such thing for"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button2:GetText()")
|
||
.unwrap(),
|
||
"Group 1"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||
.unwrap(),
|
||
"Group 3",
|
||
"the empty group 2 block contributes no row — and group 3 is NOT relabelled 2"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
|
||
3,
|
||
"title + two occupied groups, out of eight blocks"
|
||
);
|
||
}
|
||
|
||
/// **The soulbind confirm's Lua half** (decision 1744, ref `StaticPopup.lua:601-611` +
|
||
/// `UIParent.lua:317-323` + `LootFrame.lua:53-54`). `LOOT_BIND_CONFIRM` raises `LOOT_BIND` with the
|
||
/// real `LOOT_NO_DROP` text and Okay/Cancel; the row rides on `dialog.data`, and Okay hands it back
|
||
/// through `LootSlot` — the continuation verb, which is the only thing that can complete a deferred
|
||
/// take. Cancel sends nothing at all, and `LOOT_CLOSED` takes the dialog down with the window.
|
||
#[test]
|
||
fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
|
||
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(Some(coin_and_two_items()));
|
||
s.fire_event("LOOT_OPENED", vec![]);
|
||
let _ = s.take_loot_picks();
|
||
|
||
// The app defers row 2 and says so.
|
||
s.fire_event(
|
||
"LOOT_BIND_CONFIRM",
|
||
vec![benilla_ui::script::ScriptValue::Int(2)],
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||
"LOOT_BIND_CONFIRM raises the dialog"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1.which").unwrap(),
|
||
"LOOT_BIND"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||
.unwrap(),
|
||
"Looting this item will bind it to you.",
|
||
"the real GlobalStrings LOOT_NO_DROP, which carries no format specifier"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Button1:GetText()")
|
||
.unwrap(),
|
||
"Okay"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return StaticPopup1Button2:GetText()")
|
||
.unwrap(),
|
||
"Cancel"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<i64>("return StaticPopup1.data").unwrap(),
|
||
2,
|
||
"the row rides on dialog.data, not in the text"
|
||
);
|
||
|
||
// Cancel: no continuation, and nothing reaches either queue.
|
||
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
|
||
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||
assert!(
|
||
s.take_loot_confirms().is_empty(),
|
||
"Cancel completes nothing"
|
||
);
|
||
assert!(s.take_loot_picks().is_empty(), "and takes nothing either");
|
||
|
||
// Okay: the row goes back out through LootSlot, the continuation verb.
|
||
s.fire_event(
|
||
"LOOT_BIND_CONFIRM",
|
||
vec![benilla_ui::script::ScriptValue::Int(2)],
|
||
);
|
||
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
|
||
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||
assert_eq!(s.take_loot_confirms(), vec![2]);
|
||
assert!(
|
||
s.take_loot_picks().is_empty(),
|
||
"the accept is a continuation, never a fresh take"
|
||
);
|
||
|
||
// The window closing takes an open dialog with it (ref LootFrame.lua l.53-54) — its row number
|
||
// would name a slot in the next corpse.
|
||
s.fire_event(
|
||
"LOOT_BIND_CONFIRM",
|
||
vec![benilla_ui::script::ScriptValue::Int(2)],
|
||
);
|
||
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||
s.fire_event("LOOT_CLOSED", vec![]);
|
||
assert!(
|
||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||
"LOOT_CLOSED hides the confirm"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The row click is `BenillaTakeLootSlot`, not `LootSlot` — a real mouse click on a row must land
|
||
/// in the TAKE queue, or every loot would be a continuation nothing is pending for and the window
|
||
/// would sit there doing nothing (decision 1744).
|
||
#[test]
|
||
fn a_row_click_takes_rather_than_continues() {
|
||
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(Some(coin_and_two_items()));
|
||
s.fire_event("LOOT_OPENED", vec![]);
|
||
let quads = s.extract();
|
||
|
||
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![2], "the click is a TAKE");
|
||
assert!(
|
||
s.take_loot_confirms().is_empty(),
|
||
"and never a continuation"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|