- a comment that justifies code by an engine difference is a claim with an expiry date, and the tree had ~96 expired ones across `assets/ui`, `ui_script/` and `benilla-ui` — a hygiene pass, no behaviour change: dangling `Benilla*` names with zero definitions (`BenillaMoney_*` at thirteen sites after 1937/1962 converged the coin cluster onto the stock `SmallMoneyFrameTemplate`; `BenillaGossipRow_Resize`, `BenillaErrorsFrame_AddMessage`, the digit feed 1285 deleted, `BenillaMerchantItem_OnClick` and three more), "the shipped `assets/ui/<X>.xml`" for thirty-odd files the directory no longer holds — it holds nine — each site now naming the chain file that actually loads, `UiPanels.xml` in the present tense after 1988 took the panel manager and the dialog engine stock, and expired self-deprecation: SimpleHTML "a widget kind we do not have" (1337 built it), "benilla has no console" (1948 bound `ConsoleExec`), `Model`'s "seven verbs not published yet" and "`ClearFog` is a guess" (all 23 are published, the carve sits 500 lines below, `UNBUILT` has been `[&str; 0]` since 2027), `frame_flag_gate`'s doc describing four groups of accepted mouse divergences over a `KNOWN` list that is empty (the empty array and its enforcement stay), five comments saying key delivery is not gated on `keyboard_enabled` where `script::keyboard`'s walk filters on exactly that field (1319), `SetRaidTarget` called a verb this house lacks beside an assert that its intent IS queued (1820), two questlog tests deferring to a screenshot for `GetPoint`/`IsVisible` the regions already answer, `benilla.toc`'s "trade still needs four engine verbs" (all four bound, 1966) and its ping `Model`s "this engine draws no pixels for" (2013 renders `MinimapPing.mdx` itself), and `rig_anim`'s `set_paused` naming a booth caller it does not have; and the code the prose was propping up goes with it: `GameMenuFrame.xml`'s six `<OnLoad>` label setters, because `text=` on a `<Button>` is a global-string lookup at the loader's attribute step (0995, built 61 seconds before the file was written) — the buttons carry `text="LOGOUT"`/`"EXIT_GAME"`/`"MACROS"`/`"RETURN_TO_GAME"`/`"MAIN_MENU"` as the reference's own do; `ScriptLogFrame`'s five authored close-button lines, now `inherits="UIPanelCloseButton"` off the chain's `UIPanelTemplates.xml` seated 757 lines above it (1846); and the editbox's 22-line "gap" doc claiming only the self-acquire `autoFocus` path is built and its `false` default unverified — path 1 is 1686's `visibility_focus`, and the default is `true`, byte-read off `flags = 1` at `0x779a29`/`0x779a2e`, so the test named `autofocus_does_not_focus_on_show…` is renamed for what it actually pins (2044) - the `text=` divergence never existed: `resolve_text`'s doc had claimed since 0068 that falling back to the raw attribute on a GlobalStrings miss was "one deliberate divergence" from a reference that draws the empty string, and read at the byte it is the reference's own behaviour — `FrameScript_GetText 0x703bf0` does return a pre-seeded empty string (`0x882748`) when the global is not a string, but that return never reaches a label, because all three `text=` readers image-wide test it and substitute the raw attribute: `Button::LoadXML` at `0x778c07` (`0x778c31 mov eax,esi`), `CSimpleFontString::LoadXML` at `0x771006` byte-identically, and `0x7292a6`, which pushes `edi` on the empty arm; so the loader changes nothing, the two test comments that had called `PetLevelText`'s "Level level race class" placeholder and `CLOSE` our divergence now name the reference's arm (a real 1.12 client draws them too), and what IS ours is the extra warning on a key-shaped miss (0983 → 0991), a strict addition; beside it, `GetEffectiveScale`'s root factor is 1 not because "benilla has no `uiScale` CVar" — 1804 registered one, default 0.9 — but because the dial is applied at the raster seam, `768/uiScale` UI units of virtual-screen height rather than a scale on `UIParent`, so every coordinate the VM hands Lua is already in those units and the reference's `GetCursorPosition()/GetEffectiveScale()` transcriptions convert correctly with a root of 1 (2044) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1202 lines
48 KiB
Rust
1202 lines
48 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, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
|
||
load_xml(&s, "Interface\\FrameXML\\CharacterFrameTemplates.xml");
|
||
load_xml(&s, "Interface\\FrameXML\\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 the WIDGET's to get right: the loot
|
||
/// itself is the `LootButton` kind's click behaviour (`benilla-ui` `script/button.rs`, decision
|
||
/// 1799 — `l.94`'s `button:SetSlot(slot)` is what arms it), gated on no shift/ctrl/alt, which is
|
||
/// why the stock `LootFrameItem_OnClick` never calls a take itself and needs no `return`. 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 [
|
||
r"Interface\FrameXML\UIParent.xml", // UIParent + UIParent.lua, the reference's own (1988)
|
||
"Interface\\FrameXML\\LootFrame.xml",
|
||
"Interface\\FrameXML\\DressUpFrame.xml",
|
||
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
|
||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||
"Interface\\FrameXML\\BasicControls.xml",
|
||
"Interface\\FrameXML\\ChatFrame.xml",
|
||
"Interface\\FrameXML\\UIDropDownMenu.xml",
|
||
"Interface\\FrameXML\\UIPanelTemplates.lua",
|
||
"Interface\\FrameXML\\UIPanelTemplates.xml",
|
||
"Interface\\FrameXML\\LocaleProperties.lua",
|
||
"Interface\\FrameXML\\FloatingChatFrame.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());
|
||
}
|
||
|
||
/// **The state every loot on a cold item cache opens in** — the rows are on the wire, the item
|
||
/// templates are not back yet — driven through the STOCK `LootFrame.lua`. Decision 1805.
|
||
///
|
||
/// The hole this closes: every loot fixture in this file (and in `benilla-ui`'s own) handed the
|
||
/// window fully-resolved rows, so nothing in the suite ever ran `LootFrame_Update` over a row whose
|
||
/// template was still in flight. Our deleted `LootFrame.xml` could not have noticed either — it
|
||
/// carried an `ITEM_QUALITY_COLORS[quality] or ITEM_QUALITY_COLORS[1]` guard, the way
|
||
/// `GroupLootFrame.xml` and `AuctionFrame.xml` still do. The stock file has no guard: it does
|
||
/// `color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:82`) and dereferences `color.r` on the
|
||
/// next line, so a nil quality raised
|
||
/// `LootFrame.lua:85: attempt to index local 'color' (a nil value)` out of `LootFrame_OnShow` —
|
||
/// on the director's screen, on an ordinary corpse.
|
||
///
|
||
/// The window must therefore come up clean, paint the row, and paint it in the cache-miss colour —
|
||
/// `ITEM_QUALITY_COLORS[-1]`, the row `UIParent.lua`'s `for i = -1, 6` exists to provide.
|
||
#[test]
|
||
fn loot_row_awaiting_its_template_opens_clean() {
|
||
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");
|
||
|
||
// One row, in flight: the wire gave us the icon (it rides the display id) and the stack size,
|
||
// and nothing else yet.
|
||
s.set_loot(Some(LootState {
|
||
fishing: false,
|
||
master_candidates: Vec::new(),
|
||
rows: vec![Some(LootRow {
|
||
item_id: 2582,
|
||
name: None,
|
||
texture: Some("Interface\\Icons\\INV_Gauntlets_17".into()),
|
||
quantity: 1,
|
||
quality: None,
|
||
is_coin: false,
|
||
link: None,
|
||
random_property_id: 0,
|
||
})],
|
||
}));
|
||
s.fire_event("LOOT_OPENED", vec![]);
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"an in-flight loot row must not raise in LootFrame_Update: {:?}",
|
||
s.errors()
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return LootFrame:IsVisible() and LootButton1:IsVisible()")
|
||
.unwrap(),
|
||
"the window opened and drew the row"
|
||
);
|
||
|
||
// The binding answered the reference's sentinels rather than nils, and the quality one indexes
|
||
// `ITEM_QUALITY_COLORS` — which is the whole of what `LootFrame.lua:82` needs to be true.
|
||
let (item, quantity, quality) = s
|
||
.eval::<(String, i64, i64)>("local _, i, n, q = GetLootSlotInfo(1)\nreturn i, n, q")
|
||
.unwrap();
|
||
assert_eq!((item.as_str(), quantity, quality), ("", 1, -1));
|
||
assert!(
|
||
s.eval::<bool>("return ITEM_QUALITY_COLORS[select(4, GetLootSlotInfo(1))] ~= nil")
|
||
.unwrap(),
|
||
"the cache-miss quality must be a real row of ITEM_QUALITY_COLORS"
|
||
);
|
||
|
||
// And line 85 — the line that raised — actually ran: the row text wears the cache-miss colour,
|
||
// read back off the FontString rather than off the table. `ITEM_QUALITY_COLORS[-1]` is Common
|
||
// (`GetItemQualityColor`'s clamp is unsigned, so -1 takes the same branch as 7-and-up), not the
|
||
// Poor it used to be here — 1805 corrects 1199 on that.
|
||
let painted: (f64, f64, f64) = s
|
||
.eval("local r, g, b = LootButton1Text:GetTextColor()\nreturn r, g, b")
|
||
.unwrap();
|
||
let miss: (f64, f64, f64) = s
|
||
.eval("local r, g, b = GetItemQualityColor(-1)\nreturn r, g, b")
|
||
.unwrap();
|
||
assert_eq!(miss, (1.0, 1.0, 1.0), "the cache-miss row is Common/white");
|
||
// (f32 region storage vs the f64 the binding computes — compare, don't equate.)
|
||
assert!(
|
||
(painted.0 - miss.0).abs() < 1e-6
|
||
&& (painted.1 - miss.1).abs() < 1e-6
|
||
&& (painted.2 - miss.2).abs() < 1e-6,
|
||
"LootFrame_Update:85 must paint the -1 colour, got {painted:?} want {miss:?}"
|
||
);
|
||
|
||
// The template lands and the window is reopened: the row reads its name in its own quality's
|
||
// colour, so the sentinel state poisons nothing downstream.
|
||
//
|
||
// It is a REOPEN and not a repaint on purpose. The app fires `LOOT_UPDATE` when an open
|
||
// window's content changes, and `LOOT_UPDATE` is not a 1.12 event — it appears nowhere in the
|
||
// reference's FrameXML and `LootFrame_OnLoad` does not register it — so nothing in the stock
|
||
// file listens. What the real client does when a template arrives mid-window is out at wow-re
|
||
// (the arrival callback `0x4c2ac0` is recorded as clearing the pending flag and nothing else);
|
||
// this test deliberately asserts only what is settled.
|
||
s.fire_event("LOOT_CLOSED", vec![]);
|
||
s.set_loot(Some(LootState {
|
||
fishing: false,
|
||
master_candidates: Vec::new(),
|
||
rows: vec![Some(LootRow {
|
||
item_id: 2582,
|
||
name: Some("Thin Cloth Gloves".into()),
|
||
texture: Some("Interface\\Icons\\INV_Gauntlets_17".into()),
|
||
quantity: 1,
|
||
quality: Some(1),
|
||
is_coin: false,
|
||
link: None,
|
||
random_property_id: 0,
|
||
})],
|
||
}));
|
||
s.fire_event("LOOT_OPENED", vec![]);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
s.resolve();
|
||
let white = text_color(&s.extract(), "Thin Cloth Gloves").expect("the resolved name renders");
|
||
assert!(
|
||
(white[0] - 1.0).abs() < 0.02
|
||
&& (white[1] - 1.0).abs() < 0.02
|
||
&& (white[2] - 1.0).abs() < 0.02,
|
||
"the resolved row is white, got {white:?}"
|
||
);
|
||
}
|