the dressing room opens, and the pet gets its paper doll

- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:02:21 +07:00
co-authored by Claude Fable 5
parent 31c224548c
commit d99ad8bc7d
109 changed files with 11304 additions and 2532 deletions
@@ -14,8 +14,8 @@
//! command itself is still owed once the unrelated breakage clears, per this crate's own gates.
use benilla_ui::script::{
ExtractedQuad, InvSlotView, InventorySlots, PlayerCombatStats, QuadContent, ScriptValue,
SoundRequest, UiScript, UnitState,
ExtractedQuad, InvSlotView, InventorySlots, QuadContent, ScriptValue, SoundRequest, UiScript,
UnitCombatStats, UnitState,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the questlog/panel
@@ -62,8 +62,8 @@ fn player_unit() -> UnitState {
/// A minimal combat-stats snapshot: STR effective 15 (a +2 pos buff, so the stat line shows green),
/// 120 armor (school 0), 3 arcane resistance (school 6), a mainhand-only melee weapon (2.6s speed,
/// 10-15 damage, 80 AP) and NO ranged weapon equipped (exercises the "N/A" ranged fallback).
fn combat_stats() -> PlayerCombatStats {
PlayerCombatStats {
fn combat_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [15, 12, 20, 8, 9],
stat_pos: [2, 0, 0, 0, 0],
resistances: [120, 5, 0, 0, 0, 0, 3],
@@ -0,0 +1,378 @@
//! The dressing room + the chat-link insert, driven through the real click paths (decisions
//! 1059/1060).
//!
//! What these pin is the **fork**, at the sites a player actually clicks: CTRL opens the room
//! wearing the item, SHIFT posts its link into an open chat edit box — and, at a bag slot, SHIFT
//! with chat *closed* still opens the stack splitter it always did (the reference's own `else`,
//! and the regression these two features could most easily cause).
use benilla_ui::script::{
ContainerSlot, ContainerState, DressUpIntent, InvSlotView, InventorySlots, SoundRequest,
UiScript,
};
/// The jerky stack every bag fixture here uses — a real 1.12 link, quality white.
const JERKY_LINK: &str = "|cffffffff|Hitem:117|h[Tough Jerky]|h|r";
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The shipped files the dressing room's click sites need, in manifest order.
fn load_room(s: &UiScript) {
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"ItemRef.xml",
"Cooldown.xml",
"MerchantFrame.xml",
"BagFrame.xml",
"StackSplit.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(s, file);
}
}
/// A backpack holding a 5-stack of Tough Jerky in slot 1, opened; returns that button's centre.
fn backpack_with_jerky(s: &mut UiScript) -> (f32, f32) {
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
bar_placeable: true,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: Some(JERKY_LINK.into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
s.resolve();
s.run(
"BENILLA_TEST_BTN = nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_BTN = b end\n\
end",
)
.unwrap();
s.eval(
"return (BENILLA_TEST_BTN:GetLeft() + BENILLA_TEST_BTN:GetRight()) / 2, \
(BENILLA_TEST_BTN:GetTop() + BENILLA_TEST_BTN:GetBottom()) / 2",
)
.unwrap()
}
/// CTRL + left-click on a bag item opens the dressing room and puts that item on — the ref's
/// `DressUpItemLink(GetContainerItemLink(...))` (ContainerFrame.lua:565-566). The two intents are
/// ordered: `Dress` (the window was closed, so it re-dresses in the player's own gear first) then
/// `TryOn` — reversed, the room would show the player's own gear and not the clicked item.
#[test]
fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
let (x, y) = backpack_with_jerky(&mut s);
assert!(!s
.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap());
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!(
s.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap(),
"ctrl-click opened the dressing room"
);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(117)],
"re-dress first, then try the clicked item on"
);
assert!(
s.cursor_item().is_none(),
"the ctrl fork never picks the item up"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// SHIFT + left-click posts the item's link into the chat edit box **when it is open**, and still
/// opens the stack splitter when it is not — the reference's own if/else (ContainerFrame.lua:
/// 567-577). Both halves in one test because it is the *fork* that matters: the split was the
/// behaviour that already shipped, and the insert must not have eaten it.
#[test]
fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
let (x, y) = backpack_with_jerky(&mut s);
// Chat closed → the splitter, exactly as before.
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!(
s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
"with chat closed, shift-click still opens the stack splitter"
);
s.run("BenillaStackSplitFrame:Hide()").unwrap();
// Chat open → the link, and no splitter.
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(),
JERKY_LINK,
"the item's full escaped link landed in the chat box"
);
assert!(
!s.eval::<bool>("return BenillaStackSplitFrame:IsShown()")
.unwrap(),
"with chat open the splitter must NOT open"
);
assert!(
s.cursor_item().is_none(),
"neither shift fork picks the stack up"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A link already IN chat: ctrl-clicking it previews that item (ref ItemRef.lua:49-50), shift
/// -clicking it re-inserts it (the branch that already shipped). The router is `SetItemRef`, which
/// is what the message frame's `OnHyperlinkClick` calls with the full markup.
#[test]
fn ctrl_clicking_a_chat_link_previews_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
s.set_modifiers(false, true, false);
s.run(&format!(
"SetItemRef(\"item:117\", \"{JERKY_LINK}\", \"LeftButton\")"
))
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(117)],
"a chat link's id reached TryOn through DressUpItemLink's own gsub"
);
assert!(
s.eval::<bool>("return BenillaDressUpFrame:IsVisible()")
.unwrap(),
"and the room opened"
);
// The plain click still shows the link tooltip rather than the room (no modifier).
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The paper doll's own slots (ref PaperDollFrame.lua:647-655): ctrl previews what you are
/// wearing, shift posts its link. Both read the unit-keyed `GetInventoryItemLink`, the binding this
/// arc added — so this also pins that the getter answers for `"player"`.
#[test]
fn the_paper_doll_slots_preview_and_post_what_you_wear() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
let mut slots = InventorySlots::default();
slots[1] = Some(InvSlotView {
item_id: 1234,
icon: Some("Interface\\Icons\\INV_Helmet_01".into()),
count: 1,
quality: 2,
name: Some("Test Helm".into()),
link: Some("|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r".into()),
equip_slots: vec![1],
..Default::default()
});
s.set_inventory_slots(slots);
assert_eq!(
s.eval::<String>("return GetInventoryItemLink(\"player\", 1)")
.unwrap(),
"|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r"
);
// CTRL on the head slot → the room, wearing the helm.
s.set_modifiers(false, true, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(1234)]
);
// SHIFT with chat open → the link; the item is never picked up either way.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"|cff1eff00|Hitem:1234:0:0:0|h[Test Helm]|h|r"
);
assert!(
s.cursor_item().is_none(),
"a modified doll click never picks the worn item up"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A slot whose item template has not answered yet has **no link** — a state the real client never
/// has (its item cache is synchronous), so the reference carries no guard and `EditBox:Insert`
/// takes a string. Shift-clicking such a slot must post nothing and raise nothing; the click after
/// the answer lands works normally.
#[test]
fn shift_clicking_an_unresolved_slot_posts_nothing_and_never_raises() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"CharacterFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
// The slot is occupied but unresolved: an item id with no name/quality yet, so no link.
let mut slots = InventorySlots::default();
slots[1] = Some(InvSlotView {
item_id: 1234,
count: 1,
..Default::default()
});
s.set_inventory_slots(slots);
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"",
"nothing posts while the template is in flight"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// And ctrl on the same unresolved slot is inert too (DressUpItemLink's own nil guard).
s.set_modifiers(false, true, false);
s.run("BenillaPaperDollSlot_OnClick(BenillaCharacterHeadSlot, \"LeftButton\")")
.unwrap();
s.set_modifiers(false, false, false);
assert!(
s.take_dressup_intents().is_empty(),
"no try-on for an item we cannot even name yet"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The window's own controls: Reset re-dresses (and clicks), closing it empties the booth, and the
/// rotate buttons move the pane's yaw by the reference's own ±0.03 per OnClick — which fires on
/// BOTH mouse edges, so one tap is 0.06 (decision 0638 §3).
#[test]
fn reset_re_dresses_close_empties_and_the_arrows_spin_the_pane() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
s.run("ShowUIPanel(BenillaDressUpFrame)").unwrap();
let _ = s.take_sounds();
let _ = s.take_dressup_intents();
s.resolve();
// The pane's OnLoad seeded the ref's default facing.
assert!(
(s.dressup_yaw() - 0.61).abs() < 1e-6,
"ref UIParent.lua:1422"
);
s.run("BenillaDressUpFrameResetButton:Click()").unwrap();
assert_eq!(s.take_dressup_intents(), vec![DressUpIntent::Dress]);
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("gsTitleOptionOK".into())],
"Reset plays the ref's own kit"
);
// One tap of rotate-left: OnClick on press AND release, −0.03 each.
let before = s.dressup_yaw();
let (x, y) = s
.eval::<(f32, f32)>(
"return (BenillaDressUpModelFrameRotateLeftButton:GetLeft() \
+ BenillaDressUpModelFrameRotateLeftButton:GetRight()) / 2, \
(BenillaDressUpModelFrameRotateLeftButton:GetTop() \
+ BenillaDressUpModelFrameRotateLeftButton:GetBottom()) / 2",
)
.unwrap();
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
(s.dressup_yaw() - (before - 0.06)).abs() < 1e-5,
"a tap fires OnClick twice: {} → {}",
before,
s.dressup_yaw()
);
s.run("HideUIPanel(BenillaDressUpFrame)").unwrap();
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Close],
"closing the window empties the booth"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -76,6 +76,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
quantity: 1,
quality: Some(1),
is_coin: false,
link: None,
}],
}));
s.fire_event("LOOT_OPENED", vec![]);
@@ -463,15 +463,36 @@ pub(super) fn drive_script(
}
// A portrait region samples the circular-masked variant so the square icon/model
// doesn't poke past the frame ring's thin band (SetPortraitToTexture, decision 0084).
let handle = path.as_deref().and_then(|p| {
assets.as_mut().and_then(|a| {
if circular {
//
// A path the archives don't have draws **nothing** — never a white slab. This arm
// used to fall through to `color.unwrap_or(WHITE)` with a `None` texture, which
// `ui_pass` renders as the shared 1×1 white image tinted white: an opaque white
// rectangle at the region's rect, which is how B221's macro icons reached the
// director's screen. wow-re settles what the reference does: `TextureCreate` does
// build an 8×8 placeholder, but `CSimpleTexture::SetTexture` (`0x770200`) checks the
// status severity and at ≥2 releases it and returns **without touching the widget's
// texture** — the widget keeps what it had, and Lua gets `nil`. Nothing goes white.
// We can't keep the *previous* art (a path is re-resolved per frame at extract, not
// latched at `SetTexture`), so the faithful-enough result is an empty cell; what
// matters is that it is never a phantom quad. The `Backdrop` and live-portrait arms
// already guard exactly this way.
//
// `assets` missing ENTIRELY is a data-less run (the headless UI tests), not a bad
// path — those keep the old behaviour rather than blanking every textured quad.
let handle = match (path.as_deref(), assets.as_mut()) {
(Some(p), Some(a)) => {
let resolved = if circular {
a.portrait_texture(p, &mut images)
} else {
a.sprite_texture(p, &mut images)
};
if resolved.is_none() {
continue;
}
})
});
resolved
}
_ => None,
};
// A pathless Texture region is a solid color; a textured one tints by it.
let mut color = color.unwrap_or([1.0, 1.0, 1.0, 1.0]);
color[3] *= eq.alpha;
@@ -250,6 +250,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
quantity: 1,
quality: Some(1),
is_coin: false,
link: None,
}],
}));
s.fire_event("LOOT_OPENED", vec![]);
@@ -4,7 +4,7 @@
//! `loot_tests.rs` mirrors it for `set_loot`/`LootState`).
use benilla_ui::script::{
ExtractedQuad, LootRollEntry, LootRollsState, QuadContent, ScriptValue, UiScript,
DressUpIntent, ExtractedQuad, LootRollEntry, LootRollsState, QuadContent, ScriptValue, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s` (the loot tests' loader, duplicated here so this
@@ -90,6 +90,11 @@ fn setup() -> UiScript {
s
}
/// The two resolved rolls' item links, exactly as `ui_loot_roll.rs` builds them (`item_link`:
/// quality colour + the four-field `|Hitem:` payload) — what the icon button's ctrl/shift arms read.
const STAFF_LINK: &str = "|cffa335ee|Hitem:17182:0:0:0|h[Staff of Jordan]|h|r";
const SWORD_LINK: &str = "|cffffffff|Hitem:25:0:0:0|h[Worn Shortsword]|h|r";
/// A resolved Epic (BoP) roll and an in-flight one (item template not landed yet) — the same two
/// shapes `loot_roll.rs`'s own test module fixtures, reused here against the real shipped XML.
fn rolls() -> LootRollsState {
@@ -104,6 +109,8 @@ fn rolls() -> LootRollsState {
bind_on_pickup: true,
time_left_ms: 42_000,
item_id: 17182,
// The link lands with the name — one template answer fills both (decision 1059).
link: Some(STAFF_LINK.into()),
},
LootRollEntry {
roll_id: 8,
@@ -114,8 +121,10 @@ fn rolls() -> LootRollsState {
bind_on_pickup: false,
time_left_ms: 60_000,
item_id: 25,
link: Some(SWORD_LINK.into()),
},
// The item-template query hasn't landed: name/texture/quality all nil (loot_roll.rs).
// The item-template query hasn't landed: name/texture/quality all nil (loot_roll.rs) —
// and no link either: it embeds the name, so it cannot exist before the name does.
LootRollEntry {
roll_id: 9,
name: None,
@@ -125,6 +134,7 @@ fn rolls() -> LootRollsState {
bind_on_pickup: false,
time_left_ms: 55_000,
item_id: 4306,
link: None,
},
],
}
@@ -504,3 +514,80 @@ fn managed_positions_engage_for_the_bare_frame_name() {
s.resolve();
assert_eq!(bottom(&s), 102.0, "back to the multibar-only stack");
}
/// The roll popup's item icon (ref `$parentIconFrame` OnClick, LootFrame.xml l.353-361): CTRL
/// previews the rolled item in the dressing room (decision 1060), SHIFT posts its link into an open
/// chat edit box (decision 1059). Both read `GetLootRollItemLink(rollID)`, the binding this arc
/// added — so this pins that getter against the real shipped XML too.
///
/// The control that must not change: the Need/Greed/Pass buttons still vote. The icon button is new
/// click surface on a frame whose whole job is a three-button vote, and a stray vote from the icon
/// (or a swallowed one from the dice) is exactly the regression worth catching.
#[test]
fn ctrl_and_shift_on_the_roll_icon_preview_and_post_its_link() {
let mut s = setup();
load_xml(&s, "GroupLootFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink, the shared shift-insert helper
load_xml(&s, "DressUpFrame.xml");
load_xml(&s, "ChatFrame.xml");
s.set_loot_rolls(rolls());
// Roll 8 (BoE Worn Shortsword) claims frame 1 — a non-BoP roll, so the dice below can land a
// real vote rather than the bind-on-pickup confirm.
s.fire_event(
"START_LOOT_ROLL",
vec![ScriptValue::Int(8), ScriptValue::Int(60_000)],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
let (x, y) = quad_center(&quads, "INV_Sword_04");
// A plain click on the icon does nothing at all — the reference's handler has no third branch,
// and the vote is the dice's job, not the icon's.
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert!(
s.take_loot_roll_votes().is_empty() && s.take_loot_roll_confirms().is_empty(),
"an unmodified icon click votes nothing"
);
// SHIFT with the chat edit box open → the link.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
SWORD_LINK,
"the rolled item's full escaped link landed in the chat box"
);
// CTRL → the dressing room wearing it.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(25)],
"re-dress first, then try the rolled item on"
);
assert!(
s.take_loot_roll_votes().is_empty() && s.take_loot_roll_confirms().is_empty(),
"no modified icon click may vote"
);
// The control: Need on the dice still reaches the wire (roll 8 is not bind-on-pickup).
let (nx, ny) = quad_center(&quads, "UI-GroupLoot-Dice-Up");
s.mouse_button(nx, ny, "LeftButton", true);
s.mouse_button(nx, ny, "LeftButton", false);
assert_eq!(
s.take_loot_roll_votes(),
vec![(8, 1)],
"the Need button still votes"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+142 -2
View File
@@ -1,6 +1,6 @@
use benilla_ui::script::{
ExtractedQuad, LootRow, LootState, MerchantItem, MerchantState, QuadContent, SoundRequest,
UiScript,
DressUpIntent, ExtractedQuad, LootRow, LootState, MerchantItem, MerchantState, QuadContent,
SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s` (the panel tests' loader, duplicated here so this
@@ -71,6 +71,7 @@ fn coin_and_two_items() -> LootState {
quantity: 1,
quality: Some(1),
is_coin: true,
link: None,
},
LootRow {
item_id: 0,
@@ -79,6 +80,7 @@ fn coin_and_two_items() -> LootState {
quantity: 3,
quality: Some(2), // uncommon → green text
is_coin: false,
link: None,
},
LootRow {
item_id: 0,
@@ -87,6 +89,7 @@ fn coin_and_two_items() -> LootState {
quantity: 1,
quality: Some(1), // common → white text
is_coin: false,
link: None,
},
],
}
@@ -297,6 +300,7 @@ fn shipped_loot_frame_pages_five_items() {
quantity: 1,
quality: Some(1),
is_coin: false,
link: None,
})
.collect();
s.set_loot(Some(LootState { rows }));
@@ -366,6 +370,7 @@ fn shipped_loot_pushed_to_center_by_merchant() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
}],
..Default::default()
}));
@@ -455,3 +460,138 @@ fn the_loot_window_draws_over_the_party_frames() {
"they must differ by STRATUM, not by luck within one"
);
}
/// The row click's modifier fork (ref `LootFrameItem_OnClick`, LootFrame.lua l.147-154): CTRL
/// previews the row's item in the dressing room (decision 1060), SHIFT posts its link into an open
/// chat edit box (decision 1059) — and **neither loots**.
///
/// That last clause is the whole point of the test, and it is ours to get right rather than the
/// reference's: in the real client the loot itself is the C `LootButton` widget's click behaviour
/// (l.94's `button:SetSlot(slot)`), so its Lua arms fall through harmlessly; ours owns the
/// `LootSlot` call, so the arms had to grow a `return` the reference does not have. The unmodified
/// click still loots — the regression that would otherwise ship silently.
#[test]
fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
const WOOL_LINK: &str = "|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"LootFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
// A coin row + one resolved item: the item's link is fed exactly as `ui_loot.rs` builds it.
s.set_loot(Some(LootState {
rows: vec![
LootRow {
item_id: 0,
name: Some("1g 23s 45c".into()),
texture: Some("Interface\\Icons\\INV_Misc_Coin_01".into()),
quantity: 1,
quality: Some(1),
is_coin: true,
link: None, // synthesized row, no item behind it
},
LootRow {
item_id: 2589,
name: Some("Wool Cloth".into()),
texture: Some("Interface\\Icons\\INV_Fabric_Wool_01".into()),
quantity: 3,
quality: Some(1),
is_coin: false,
link: Some(WOOL_LINK.into()),
},
],
}));
s.fire_event("LOOT_OPENED", vec![]);
s.resolve();
let quads = s.extract();
let (x, y) = icon_center(&quads, "INV_Fabric_Wool_01");
let (coin_x, coin_y) = icon_center(&quads, "INV_Misc_Coin_01");
// The control first, while nothing else has been opened: a plain click still loots row 2.
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
assert_eq!(
s.take_loot_picks(),
vec![2],
"an unmodified click still loots the row"
);
// SHIFT with the chat edit box open → the link, and no loot.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
WOOL_LINK,
"the row's full escaped link landed in the chat box"
);
assert!(
s.take_loot_picks().is_empty(),
"a shift-click must not also loot the row"
);
// The COIN row has no link at all, and a modified click on it must be inert rather than an
// error: our `EditBox:Insert` binding is typed `String` and raises on a nil, so the handler
// guards it (the reference's C Insert tolerates the nil its own callers hand it).
s.set_modifiers(true, false, false);
s.mouse_button(coin_x, coin_y, "LeftButton", true);
s.mouse_button(coin_x, coin_y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.errors().is_empty(),
"shift-clicking the linkless coin row must not raise: {:?}",
s.errors()
);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
WOOL_LINK,
"and it must not disturb what is already typed"
);
assert!(
s.take_loot_picks().is_empty(),
"a shift-click on the coin row must not loot the money either"
);
// ALT → nothing at all, and in particular NOT a loot (decision 1067). No FrameXML file binds
// alt on a loot row; the suppression is the C `CLootButton::OnClick`'s own third gate
// (`0x41f8f0(2)` @ `0x4c1841`, VERIFIED at the bytes), which our Lua-side take has to
// reproduce. This is the arm nothing else in the suite would catch: before 1067, alt looted.
s.set_modifiers(false, false, true);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert!(
s.take_loot_picks().is_empty(),
"an alt-click must not loot the row (the C gate's third modifier)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// CTRL → the dressing room wearing it, still no loot. Last, because opening the room takes the
// left UIPanel slot and moves the loot window (pushable 2 vs 7).
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![DressUpIntent::Dress, DressUpIntent::TryOn(2589)],
"re-dress first, then try the looted item on"
);
assert!(
s.take_loot_picks().is_empty(),
"a ctrl-click must not also loot the row"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+15 -1
View File
@@ -84,7 +84,9 @@ const UI_MANIFEST: &[&str] = &[
// never has one never sees it. Right after the stance bar, whose row it shares (either
// one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
// (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
// GameTooltip.xml (its hover).
// GameTooltip.xml (its hover). It is also the pet ARC's file, so decision 1066's three
// right-click-menu dialogs (ABANDON_PET / RENAME_PET / PETRENAMECONFIRM) register here —
// which adds UiPanels.xml to its dependency set, already far above.
"PetActionBar.xml",
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
@@ -136,6 +138,12 @@ const UI_MANIFEST: &[&str] = &[
// windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
// already loaded above.
"CharacterFrame.xml",
// The Pet tab (decision 1057): the same separate-top-level-file shape as SkillFrame below,
// and ahead of it because it is the earlier tab. It leans on CharacterFrame.xml for real —
// the `(unit, prefix)` stat setters, the `BenillaStatRow`/`BenillaMagicResistanceFrame`
// templates and the tab row it raises tab 2 of — all of which only exist once that file has
// loaded, so this order is a requirement here, not just tidiness.
"PetPaperDollFrame.xml",
// The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
// top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
// `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
@@ -149,6 +157,12 @@ const UI_MANIFEST: &[&str] = &[
// itself (its slot template, handlers, and booth slot are all its own — the two windows
// share only the reference's art).
"InspectFrame.xml",
// The dressing room (decision 1060): the ctrl-click item preview. Loaded right after the
// windows whose slots feed it (the character window above, the bags before that) and before
// everything else that ctrl-clicks into it — order is legibility only, since `DressUpItemLink`
// is a global resolved at call time. Needs UiPanels (the panel manager, its left-slot row) and
// Fonts; its booth pane needs nothing loaded at all.
"DressUpFrame.xml",
// The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
// for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
// windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
@@ -5,8 +5,8 @@
//! folder's one-file-per-window convention.
use benilla_ui::script::{
ContainerState, ExtractedQuad, ItemStatsHead, MerchantItem, MerchantState, QuadContent,
ScriptValue, SoundRequest, UiScript,
ContainerState, DressUpIntent, ExtractedQuad, ItemStatsHead, MerchantItem, MerchantState,
QuadContent, ScriptValue, SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error and returning the
@@ -91,6 +91,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
},
MerchantItem {
name: Some("Linen Bandage".into()),
@@ -100,6 +101,7 @@ fn shipped_merchant_frame_drives_end_to_end() {
num_available: 5,
item_id: 1251,
stats: None,
link: None,
},
],
..Default::default()
@@ -589,6 +591,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
block: 0,
sell_price: 0,
}),
link: None,
},
MerchantItem {
name: Some("Chipped Buckler".into()),
@@ -611,6 +614,7 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
block: 1,
sell_price: 0,
}),
link: None,
},
],
..Default::default()
@@ -794,6 +798,7 @@ fn money_display_shrinks_to_content_and_stays_flush() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
},
MerchantItem {
name: Some("Linen Bandage".into()),
@@ -803,6 +808,7 @@ fn money_display_shrinks_to_content_and_stays_flush() {
num_available: -1,
item_id: 1251,
stats: None,
link: None,
},
],
..Default::default()
@@ -937,6 +943,7 @@ fn merchant_tabs_drive_buyback_page_and_repair_pair() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
}],
buyback: vec![
buyback_item("Bandit Cloak", 116),
@@ -1089,6 +1096,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
num_available: -1,
item_id: 159,
stats: None,
link: None,
},
MerchantItem {
name: Some("Linen Bandage".into()),
@@ -1098,6 +1106,7 @@ fn shipped_merchant_frame_arms_the_buy_cursor_on_hover() {
num_available: 5,
item_id: 1251,
stats: None,
link: None,
},
],
..Default::default()
@@ -1200,3 +1209,114 @@ fn trade_recipient_money_renders_the_digit_not_ellipsis() {
"recipient money shows the digit. all text quads: {texts:?}"
);
}
/// The vendor row's LEFT-button modifier fork (ref `MerchantItemButton_OnClick`,
/// MerchantFrame.lua l.301-306): CTRL previews the item in the dressing room (decision 1060), SHIFT
/// posts its link into an open chat edit box (decision 1059) — both over `GetMerchantItemLink`, the
/// binding this arc added. Neither may buy: this window's click *is* a purchase, so a modified click
/// that fell through would spend the player's money.
///
/// The controls that must not change: a plain RIGHT-click still buys, and (the reference's own
/// right-button guard, l.332-333) a CTRL-held right-click buys nothing at all.
#[test]
fn ctrl_and_shift_on_a_vendor_row_preview_and_post_without_buying() {
const WATER_LINK: &str = "|cffffffff|Hitem:159:0:0:0|h[Refreshing Spring Water]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"GameTooltip.xml",
"MerchantFrame.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(&s, file);
}
s.set_money(12_345);
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,
// Fed exactly as `ui_merchant.rs` builds it off the row's template answer.
link: Some(WATER_LINK.into()),
}],
..Default::default()
}));
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
let icon = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("INV_Drink_18"))
})
.and_then(|q| q.rect)
.expect("no row icon quad");
let (x, y) = (
(icon.left + icon.right) * 0.5,
(icon.bottom + icon.top) * 0.5,
);
// The control first: an unmodified right-click still buys row 1.
s.mouse_button(x, y, "RightButton", true);
s.mouse_button(x, y, "RightButton", false);
assert_eq!(
s.take_merchant_buys(),
vec![(1, 1)],
"an unmodified right-click still buys"
);
// The reference's right-button guard (l.332-333): CTRL-held, a right-click does nothing.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "RightButton", true);
s.mouse_button(x, y, "RightButton", false);
s.set_modifiers(false, false, false);
assert!(
s.take_merchant_buys().is_empty(),
"a ctrl-held right-click must not buy (ref l.332-333)"
);
// SHIFT + LEFT with the chat edit box open → the link, no purchase.
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(),
WATER_LINK,
"the vendor row's full escaped link landed in the chat box"
);
assert!(
s.take_merchant_buys().is_empty(),
"a shift-click must not also buy"
);
// CTRL + LEFT → the dressing room wearing it, no purchase. Last: opening the room takes a
// UIPanel slot and can move the vendor window.
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(159)],
"re-dress first, then try the vendor's item on"
);
assert!(
s.take_merchant_buys().is_empty(),
"a ctrl-click must not also buy"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+6
View File
@@ -718,9 +718,15 @@ mod questlog_tests;
#[cfg(test)]
mod character_tests;
#[cfg(test)]
mod pet_paperdoll_tests;
#[cfg(test)]
mod inspect_tests;
#[cfg(test)]
mod dressup_tests;
#[cfg(test)]
mod minimap_tests;
@@ -1396,3 +1396,267 @@ fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── B223 · the row tooltips (decision 1054) ─────────────────────────────────────────────────────
/// Hover the middle of a named row's LABEL half (left of the control column) — the surface the
/// reporter's cursor was on when the row lit and said nothing. Callers pin `ERA_WINDOW_SCALE = 1`
/// first (the wash test's own idiom): frame rects are in the frame's scale space, the pointer is
/// in the screen's, and at scale 1 the two are the same numbers.
fn hover_label(s: &mut UiScript, frame: &str) {
s.resolve();
let g = |s: &mut UiScript, verb: &str| -> f32 {
s.eval(&format!("return {frame}:{verb}()")).unwrap()
};
let (l, t, b) = (g(s, "GetLeft"), g(s, "GetTop"), g(s, "GetBottom"));
s.mouse_move(l + 60.0, (t + b) * 0.5);
}
/// A hovered row raises its 1.12 description, on the era's seat, and drops it on leave — the row
/// itself, its checkbox, and (B223's report) the label the cursor actually crosses. The string
/// resolves by KEY at hover, so seeding it AFTER the window loaded still paints: that is the
/// property the 1.12 panel's own `getglobal("OPTION_TOOLTIP_"..key)` lookup buys.
#[test]
fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Checking this will prevent the deselection.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let row = "OptionsFrameContainerBodyControlsRowStickyTarget";
hover_label(&mut s, row);
s.resolve();
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the label half raises the plate — the reported gap"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Checking this will prevent the deselection."
);
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
1,
"the description ALONE — the era's white name line is cut (1054)"
);
// The era seat: BOTTOMLEFT on the label region's TOPRIGHT, hung 10 back over the control
// column (DefaultTooltipMixin's ANCHOR_RIGHT / x -10).
let owned: bool = s
.eval(&format!("return GameTooltip:IsOwned({row}Tip)"))
.unwrap();
assert!(owned, "owned by the row's $parentTip region");
let (tip_right, tip_top): (f32, f32) = (
s.eval(&format!("return {row}Tip:GetRight()")).unwrap(),
s.eval(&format!("return {row}Tip:GetTop()")).unwrap(),
);
let (left, bottom): (f32, f32) = (
s.eval("return GameTooltip:GetLeft()").unwrap(),
s.eval("return GameTooltip:GetBottom()").unwrap(),
);
assert!(
(left - (tip_right - 10.0)).abs() < 0.01 && (bottom - tip_top).abs() < 0.01,
"plate at ({left}, {bottom}); the era seat is ({}, {tip_top})",
tip_right - 10.0
);
// Crossing onto the checkbox keeps it up (one seam lights the wash and raises the plate, so
// the row's OnLeave and the box's OnEnter cancel out inside the one move).
let (bl, br, bt, bb): (f32, f32, f32, f32) = (
s.eval(&format!("return {row}Check:GetLeft()")).unwrap(),
s.eval(&format!("return {row}Check:GetRight()")).unwrap(),
s.eval(&format!("return {row}Check:GetTop()")).unwrap(),
s.eval(&format!("return {row}Check:GetBottom()")).unwrap(),
);
s.mouse_move((bl + br) * 0.5, (bt + bb) * 0.5);
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the plate survives the crossing onto the control"
);
// Leaving the row puts it away.
s.mouse_move(5.0, 5.0);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"OnLeave drops the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The row with no 1.12 string raises NOTHING — not a plate echoing the label under the cursor,
/// and not the LAST row's description left standing over it. Auto Loot is that row: 1.12 has no
/// Auto Loot setting, so no `OPTION_TOOLTIP_*` for it. The second leg is the live probe's find: a
/// hover driven without the outgoing row's OnLeave (1054) must still put the neighbour away.
#[test]
fn a_row_with_no_1_12_string_raises_no_plate() {
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Sticky's own description.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
hover_label(&mut s, "OptionsFrameContainerBodyControlsRowAutoLoot");
s.resolve();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"no 1.12 description, no plate"
);
assert!(
s.eval::<bool>("return OptionsFrameContainerBodyControlsRowAutoLootHover:IsVisible()")
.unwrap(),
"…but the row still lights: the wash is not gated on the string"
);
// Sticky's plate up, then straight into the mute row with no OnLeave in between.
s.run("OptionsRow_Hover(OptionsFrameContainerBodyControlsRowStickyTarget, 1)")
.unwrap();
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
s.run("OptionsRow_Hover(OptionsFrameContainerBodyControlsRowAutoLoot, 1)")
.unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the mute row puts the neighbour's description away — it never describes THIS row"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The RUNTIME leg on the real client data (`ui_quest`'s pattern): every key the rows carry
/// resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` — the guard against a
/// typo'd key degrading a description to silence — and the ONE row without a key is Auto Loot.
/// Pins the reporter's own row end to end (B223's screenshot text). Skips without client data.
#[test]
fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let strings = UiScript::new().expect("VM");
strings
.run(&String::from_utf8_lossy(&src))
.expect("GlobalStrings runs clean");
// The mapping, read off the LIVE rows so a page added later can't slip the check.
let s = harness();
let listing: String = s
.eval(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"OptionsFrameContainerBody\" .. page .. rkey) \
table.insert(out, page .. rkey .. \"=\" .. (row.tip or \"\")) \
end \
end \
return table.concat(out, \",\")",
)
.unwrap();
let mut untipped = vec![];
let mut checked = 0;
for entry in listing.split(',') {
let (row, key) = entry.split_once('=').expect("row=key");
if key.is_empty() {
untipped.push(row.to_string());
continue;
}
assert!(
key.starts_with("OPTION_TOOLTIP_"),
"{row}: {key} is not a 1.12 option-tooltip key"
);
let text: String = strings
.lua()
.globals()
.get::<String>(key)
.unwrap_or_default();
assert!(
!text.is_empty() && text != "PLACE_HOLDER",
"{row}: {key} resolves to nothing in the real GlobalStrings"
);
checked += 1;
}
assert_eq!(checked, 14, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
"Auto Loot is the only row 1.12 never had"
);
// The reporter's row, byte for byte off the MPQ chain.
assert_eq!(
strings
.lua()
.globals()
.get::<String>("OPTION_TOOLTIP_GAMEFIELD_DESELECT")
.unwrap(),
"Checking this will prevent the deselection of targets by clicking on the gamefield. \
Targets can only be cleared by pressing escape or clicking another target."
);
}
/// EVERY row on every page, all three control flavors: hovering it raises a plate exactly when
/// the row carries a 1.12 key, and no row's hover errors. The teeth are the per-template
/// `$parentTip` seat — a flavor missing that region would take `SetOwner(nil, …)` and print a red
/// line instead of a plate, which no key-mapping check would ever see.
#[test]
fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
let mut s = harness_on(audio_harness());
// Stand-in strings for every key the rows name (the real texts are the data test's job).
s.run(
"for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"OptionsFrameContainerBody\" .. page .. rkey) \
if row.tip then setglobal(row.tip, \"described: \" .. rkey) end \
end \
end",
)
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
let mut raised = 0;
for page in ["Controls", "Audio", "Graphics", "Nameplates"] {
s.run(&format!("OptionsFrameCategoryListRow{page}:Click()"))
.unwrap();
let rows: String = s
.eval(&format!(
"return table.concat(OPTIONS_PAGE_ROWS.{page}, \",\")"
))
.unwrap();
for rkey in rows.split(',') {
let row = format!("OptionsFrameContainerBody{page}{rkey}");
hover_label(&mut s, &row);
s.resolve();
let tipped: bool = s.eval(&format!("return {row}.tip ~= nil")).unwrap();
let shown: bool = s.eval("return GameTooltip:IsVisible()").unwrap();
assert_eq!(
shown, tipped,
"{row}: plate shown {shown}, has key {tipped}"
);
if tipped {
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
format!("described: {rkey}"),
"{row}: the plate reads ITS OWN row's description"
);
assert!(
s.eval::<bool>(&format!("return GameTooltip:IsOwned({row}Tip)"))
.unwrap(),
"{row}: seated on its own $parentTip region"
);
raised += 1;
}
s.mouse_move(5.0, 5.0);
}
assert!(
s.errors().is_empty(),
"{page}: script errors: {:?}",
s.errors()
);
}
assert_eq!(raised, 14, "14 of the 15 rows have a 1.12 description");
}
@@ -528,6 +528,9 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
num_available: -1,
item_id: 159,
stats: None,
// Not this test's subject (the slot manager is) — a row with no template answer yet
// carries no link (decision 1059).
link: None,
}],
..Default::default()
}));
@@ -657,6 +660,9 @@ fn shipped_panel_slot_pushable_promotes_to_center() {
num_available: -1,
item_id: 159,
stats: None,
// Not this test's subject (the slot manager is) — a row with no template answer yet
// carries no link (decision 1059).
link: None,
}],
..Default::default()
}));
@@ -517,10 +517,15 @@ fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
/// fires the slot.
///
/// The load-bearing half is what the key path does NOT do: it is a bare `CastPetAction`, with none
/// of `PetActionButton_OnClick`'s forks. Pinned below on the Attack token with the pet ALREADY
/// attacking — a left click there calls the attack off (the test above), while the key re-issues
/// it. That asymmetry is the reference's, and the only thing that could hide a regression into it
/// is a test that never presses a key on an active slot.
/// of `PetActionButton_OnClick`'s forks. Pinned below on an Attack token fed as already-active — a
/// left click on that same view calls the attack off (the test above) while the key re-issues it.
///
/// **What that fixture is and is not.** `attack_active` is pushed straight onto the view here, and
/// on a real *pet* bar it can never be true: `ui_pet`'s `possessing` carve (`0x4bd420`) raises the
/// latch only for a unit you are POSSESSING, so on a hunter's bar the click fork is dead code and
/// key and click behave identically. The state below is a **possess** bar's — Mind Control, Eye of
/// Kilrogg — which is the one case where the two paths genuinely diverge, and therefore the only
/// one worth pinning. Read as "ordinary pets differ from clicks", this test would be lying.
#[test]
fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
let mut s = UiScript::new().unwrap();
@@ -544,8 +549,9 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
"the press is visual only — the ref fires on the release"
);
// Up releases the art and casts — slot 1 is Attack and the pet is already attacking, so a
// LEFT CLICK here would call it off instead. The key has no such fork.
// Up releases the art and casts — slot 1 is Attack and the view says the order is live, so a
// LEFT CLICK here would call it off instead (a possess bar's case; see the header). The key
// has no such fork.
s.run("BenillaPetActionButtonUp(1)").unwrap();
assert_eq!(state(&s), "NORMAL");
assert_eq!(s.take_pet_actions(), vec![1]);
@@ -310,6 +310,74 @@ fn left_clicking_the_pet_frame_targets_it() {
assert!(s.take_target_requests().is_empty());
}
/// **All three legs of `PetFrame_OnClick` survive the click** — B208's "dropping food from the bag
/// onto the pet doesn't feed" (decision 1055).
///
/// The handler transcribed the reference's three legs correctly from the day it shipped, but two of
/// the globals it calls — `DropItemOnUnit` and `SpellTargetUnit` — were never registered, so the
/// middle leg called a nil value and the whole handler errored out. Nothing fed, and nothing said
/// why. The bottom-leg test above could not catch it: it only ever exercises the `else`.
///
/// So this walks the legs the reference's own order picks between them (`SpellIsTargeting()` first,
/// then `CursorHasItem()`, then plain target — `PetFrame.lua:114-129`), and asserts each *runs*.
/// The feed itself is the app's half, gated and sent in `ui_action::drop_item`; what the VM owes is
/// a queued token and no error.
#[test]
fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
// The three globals the handler reaches for must all exist. Named individually so a failure
// says which one went missing rather than just "attempt to call a nil value".
for global in [
"SpellIsTargeting",
"CursorHasItem",
"DropItemOnUnit",
"SpellTargetUnit",
] {
assert!(
s.eval::<bool>(&format!("return _G[\"{global}\"] ~= nil"))
.unwrap_or(false),
"{global} is not registered — the pet frame's click calls it"
);
}
// The middle leg, for real: put food in the backpack, pick it up the way a player does, then
// click the pet. Before the fix this click errored instead of queueing anything.
s.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::from([(
1,
benilla_ui::script::ContainerSlot {
item_id: 2287, // Haunch of Meat — pet food
count: 1,
..Default::default()
},
)]),
}),
);
s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]);
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(
s.eval::<bool>("return CursorHasItem()").unwrap(),
"the food is on the cursor"
);
s.run("BenillaPetFrame_OnClick(\"LeftButton\")")
.expect("the cursor-holds-an-item leg must not error");
assert_eq!(
s.take_drop_item_on_unit(),
vec!["pet".to_string()],
"a held item + a pet click queues the drop"
);
// …and it is the DROP leg, not the target leg — the reference's if/elseif is exclusive.
assert!(s.take_target_requests().is_empty());
}
/// The GlobalStrings the happiness tooltip resolves by key. Loaded from the MPQ in production; the
/// test declares the ones it reads, the pet-bar tests' convention. (That the real keys exist in the
/// shipped file is asserted separately, by name, in `ui_pet_stats`.)
@@ -336,6 +404,10 @@ fn stats(
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
// The unit frame draws neither the family word (the paper doll's level line) nor the diet
// (its tooltip) — decision 1062. Left at their defaults on purpose: the happiness icon
// these tests drive must not start depending on them.
..benilla_ui::script::PetStats::default()
}
}
@@ -0,0 +1,451 @@
//! The shipped **pet paper doll** driven end-to-end, engine-only (decision 1057): the real
//! `assets/ui/PetPaperDollFrame.xml` behind `CharacterFrame.xml`, fed synthetic pet snapshots.
//!
//! The page's rows are the character page's own code with a different unit token, and
//! `character_tests.rs` already pins those. What is genuinely new — and what these test — is the
//! **conditional tab**: it goes up and down with the pet, the tab beside it slides to close the
//! gap, the page refuses to open without a pet, and it closes itself when the pet leaves. That
//! machinery is the half a green build cannot see.
use benilla_ui::script::{
PetStats, QuadContent, ScriptValue, UiScript, UnitCombatStats, UnitState,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error (the character tests' loader).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The page's production load prefix — it leans on `CharacterFrame.xml` for the shared setters,
/// the row templates and the tab row itself, exactly as the manifest orders them.
fn load_pet_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
// `UIParent.xml` is the manifest's third file and this page needs one thing out of it:
// `BuildListString`, the reference's own `UIParent.lua:1051` joiner behind the diet tooltip
// (decision 1062). Loaded in manifest order rather than bolted on at the end.
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "CharacterFrame.xml");
load_xml(&s, "PetPaperDollFrame.xml");
s.set_unit("player", Some(player_unit()));
s
}
fn player_unit() -> UnitState {
UnitState {
exists: true,
name: Some("Benilla".into()),
health: 100,
max_health: 100,
level: 60,
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Hunter".into()),
class_file: Some("HUNTER".into()),
sex: 2,
is_player: true,
..UnitState::default()
}
}
fn pet_unit() -> UnitState {
UnitState {
exists: true,
name: Some("Snarl".into()),
health: 900,
max_health: 1000,
level: 58,
..UnitState::default()
}
}
/// A hunter pet's stat block — `hunter_pet` is `HasPetUI`'s SECOND return, the one that gates the
/// training-point line and the diet icon. The family pair is the shipped `CreatureFamily.dbc` row
/// for a Boar (id 5, food mask 63) resolved through `ItemPetFood.dbc` (decision 1062).
fn hunter_pet_stats() -> PetStats {
PetStats {
hunter_pet: true,
happiness: Some(3),
damage_percentage: 125.0,
loyalty_rate: 20.0,
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
family: Some("Boar".into()),
food_types: ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]
.map(String::from)
.to_vec(),
}
}
/// Numbers a pet's descriptor really carries: stats and resistances, no PLAYER-block buff split
/// (a creature has none — decision 1057), so every pos/neg stays zero.
fn pet_combat_stats() -> UnitCombatStats {
UnitCombatStats {
stats: [123, 88, 210, 20, 45],
resistances: [2400, 0, 55, 0, 0, 0, 30],
min_damage: 90.0,
max_damage: 130.0,
main_attack_time_ms: 2000,
attack_power: 640,
..UnitCombatStats::default()
}
}
/// Every string the UI actually draws this frame.
fn texts(s: &mut UiScript) -> Vec<String> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Text { text: Some(t), .. } => Some(t),
_ => None,
})
.collect()
}
/// A tab's resolved left edge. Only meaningful with the **window open** — a frame nothing lays out
/// has no rect at all, and `GetLeft()` answers nil rather than a stale number.
fn tab_left(s: &mut UiScript, tab: u32) -> f64 {
s.resolve();
s.eval::<f64>(&format!("return BenillaCharacterFrameTab{tab}:GetLeft()"))
.unwrap_or_else(|e| panic!("tab {tab} has no resolved rect: {e}"))
}
/// Put a pet in the world and tell the page about it, the way the app's feeds do.
fn give_pet(s: &mut UiScript) {
s.set_unit("pet", Some(pet_unit()));
s.set_pet_stats(true, hunter_pet_stats());
s.set_pet_combat_stats(Some(pet_combat_stats()));
s.fire_event("PET_BAR_UPDATE", vec![]);
}
/// Take it away again — the dismiss/death path: `UNIT_PET` names the OWNER, not the pet (0990).
fn take_pet(s: &mut UiScript) {
s.set_unit("pet", None);
s.set_pet_stats(false, PetStats::default());
s.set_pet_combat_stats(None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
}
#[test]
fn shipped_pet_page_loads_clean() {
let s = load_pet_page();
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
// The page itself exists and starts down, like every other subframe.
assert!(!s
.eval::<bool>("return BenillaPetPaperDollFrame:IsVisible()")
.unwrap());
}
/// **The conditional tab, both ways.** With no pet the Pet tab is down and Skills slides onto its
/// spot; with a pet the Pet tab comes up *at that very spot* and Skills moves out past it. Pinning
/// "Skills-when-closed sits exactly where Pet-when-open does" is what proves the re-anchor really
/// ran — a tab merely hidden, with the row left as-authored, would leave a gap and pass a weaker
/// check.
#[test]
fn the_pet_tab_rises_and_falls_with_the_pet_and_skills_closes_the_gap() {
let mut s = load_pet_page();
// The rects only exist while something lays the window out.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(
!s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"no pet: the Pet tab is down"
);
let closed3 = tab_left(&mut s, 3);
give_pet(&mut s);
assert!(
s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"a pet raises the Pet tab"
);
let open2 = tab_left(&mut s, 2);
let open3 = tab_left(&mut s, 3);
assert_eq!(
open2, closed3,
"with no pet, Skills stood exactly where the Pet tab stands with one"
);
assert!(
open3 > open2,
"…and a pet pushes Skills out past it ({open3} vs {open2})"
);
take_pet(&mut s);
assert!(
!s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"the pet leaving lowers the tab again"
);
assert_eq!(tab_left(&mut s, 3), closed3, "…and the row closes back up");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The reference's `ToggleCharacter` guard (ref `CharacterFrame.lua:4-6`): asking for the pet page
/// with no pet does **nothing at all** — it does not open the window on some other tab, and it does
/// not close an already-open one.
#[test]
fn asking_for_the_pet_page_without_a_pet_does_nothing() {
let s = load_pet_page();
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
!s.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap(),
"no pet: the window stays shut"
);
// …and with the window already open on the character page, the refusal leaves it open.
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaPaperDollFrame:IsVisible()")
.unwrap(),
"the refusal must not switch pages or close the window"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The page paints the **pet's** numbers, not the player's — the whole point of un-gating the stat
/// bindings. The player's own snapshot is deliberately absent here: anything that leaked through
/// the `"player"` path would read zeros and this would fail.
#[test]
fn the_page_reads_the_pets_own_snapshot() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(s
.eval::<bool>("return BenillaPetPaperDollFrame:IsVisible()")
.unwrap());
let drawn = texts(&mut s);
for want in ["123", "88", "210", "Snarl", "(Loyalty Level 6) Best Friend"] {
assert!(
drawn.iter().any(|t| t.contains(want)),
"expected {want:?} on the page; drew {drawn:?}"
);
}
// Unspent training points = total - spent (ref l.85-86), not either number by itself.
assert!(
drawn.iter().any(|t| t == "40"),
"unspent training points (170-130); drew {drawn:?}"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The window has ONE name line and the two pages take turns holding it (ref l.50-60) — the pet's
/// name replaces the player's on show and hands it back on hide. A page that forgot the hand-back
/// would leave the character sheet nameless.
#[test]
fn the_page_borrows_the_windows_name_line_and_gives_it_back() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(!s
.eval::<bool>("return BenillaCharacterNameText:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaPetNameText:IsVisible()")
.unwrap());
s.run(r#"ToggleCharacter("BenillaPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaCharacterNameText:IsVisible()")
.unwrap(),
"the player's name line comes back"
);
assert!(!s
.eval::<bool>("return BenillaPetNameText:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A pet dismissed or killed while its own page is open **closes the window** (ref l.32-37). This
/// is the one arm that must run while the page is visible and the pet is already gone, so it is
/// also the arm that would blow up on a page that repainted before checking.
#[test]
fn the_pet_leaving_closes_the_page_under_it() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(s
.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap());
take_pet(&mut s);
assert!(
!s.eval::<bool>("return BenillaCharacterFrame:IsVisible()")
.unwrap(),
"the pet's page cannot outlive the pet"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// A warlock's minion: `HasPetUI` says yes, its second return says no. The page opens in full and
/// the three hunter-only pieces stay down (1005's class gate, seen from the page's side).
#[test]
fn a_minion_gets_the_page_without_the_hunter_furniture() {
let mut s = load_pet_page();
s.set_unit("pet", Some(pet_unit()));
s.set_pet_stats(true, PetStats::default()); // has_ui, but hunter_pet == false
s.set_pet_combat_stats(Some(pet_combat_stats()));
s.fire_event("PET_BAR_UPDATE", vec![]);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaCharacterFrameTab2:IsVisible()")
.unwrap(),
"a minion still gets the tab"
);
assert!(!s
.eval::<bool>("return BenillaPetPaperDollPetInfo:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaPetTrainingPointText:IsVisible()")
.unwrap());
assert!(!s
.eval::<bool>("return BenillaPetTrainingPointLabel:IsVisible()")
.unwrap());
// …and the stat rows still read the minion's own numbers.
assert!(
texts(&mut s).iter().any(|t| t.contains("210")),
"the shared rows are not hunter-gated"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **The level line, both ways** (decision 1062). The reference guards its whole `SetText` on
/// `UnitCreatureFamily("pet")` (ref `PetPaperDollFrame.lua:68-70`), so the family is not decoration
/// on an existing line — it is the *condition* for the line existing at all. With a family the row
/// reads "Level 58 Imp"; without one the page shows no level at all, which is the state 1057
/// shipped in and the hole this closes.
///
/// The minion is deliberate: a warlock's Imp fails `HasPetUI`'s second return, so this also pins
/// that the family word rides OUTSIDE 1005's hunter gate. Gating it would blank this line for every
/// warlock in the game.
///
/// **The two halves are two separate page loads on purpose.** The ref's guard skips the `SetText`;
/// it does not clear the FontString — so pushing a family and then taking it away leaves the old
/// word on screen (this test found that by asserting the opposite first). That is the reference's
/// own shape and it is unreachable in play: a family is a property of the pet's *template*, it
/// never goes away under a live pet, and the page closes with the pet anyway. The case that IS
/// reachable is "the page painted before the creature query answered", which is a page that never
/// had a family — the second half here.
#[test]
fn the_level_line_names_the_family_and_is_blank_without_one() {
let with_family = |family: Option<String>| {
let mut s = load_pet_page();
s.set_unit("pet", Some(pet_unit()));
s.set_pet_combat_stats(Some(pet_combat_stats()));
s.set_pet_stats(
true,
PetStats {
family,
..PetStats::default() // hunter_pet false — a warlock's minion
},
);
s.fire_event("PET_BAR_UPDATE", vec![]);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
let drawn = texts(&mut s);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
drawn
};
let drawn = with_family(Some("Imp".into()));
assert!(
drawn.iter().any(|t| t == "Level 58 Imp"),
"UNIT_LEVEL_TEMPLATE + the family word; drew {drawn:?}"
);
// No family — the creature query has not answered, or the template has none. The line is
// absent, not "Level 58" and not "Level 58 " with a trailing space: the ref's guard skips the
// whole SetText, which is why nil (never "") is the binding's contract.
let drawn = with_family(None);
assert!(
!drawn.iter().any(|t| t.starts_with("Level")),
"no family ⇒ no level line at all; drew {drawn:?}"
);
}
/// **The diet tooltip** (decision 1062): the happiness-art icon under the rotate buttons is the
/// pet's DIET affordance, and its hover runs the reference's own
/// `format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))` (ref
/// `PetPaperDollFrame.xml:269`) — the vararg binding feeding the `UIParent.lua` joiner.
///
/// Driven through the frame's real `OnEnter`, not by re-typing the expression, so a guard left in
/// the XML or a mis-anchored `GetScript` slot fails here.
#[test]
fn hovering_the_diet_icon_lists_what_the_pet_eats() {
let mut s = load_pet_page();
give_pet(&mut s);
s.run(r#"ToggleCharacter("BenillaPetPaperDollFrame")"#)
.unwrap();
assert!(
s.eval::<bool>("return BenillaPetPaperDollPetInfo:IsVisible()")
.unwrap(),
"the diet icon is shown for a hunter pet"
);
s.run(
"local f = BenillaPetPaperDollPetInfo \
f:GetScript(\"OnEnter\")(f)",
)
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Diet: Meat, Fish, Cheese, Bread, Fungus, Fruit",
"PET_DIET_TEMPLATE around a plain \", \" join — 1.12's BuildListString has no \"and\""
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// `BuildListString`'s own edges, on the shipped `UIParent.xml` (decision 1062). The reference's is
/// a bare comma join with **no** "and" and **no** localization string, and it answers **nil** for
/// zero arguments — the shape `format("%s", …)` would error on, which is exactly why the diet icon
/// is hunter-gated. Quietly returning `""` here would hide that coupling.
#[test]
fn build_list_string_is_a_plain_comma_join_that_nils_on_nothing() {
let s = load_pet_page();
assert_eq!(
s.eval::<String>("return BuildListString('Meat', 'Fish', 'Cheese')")
.unwrap(),
"Meat, Fish, Cheese"
);
assert_eq!(
s.eval::<String>("return BuildListString('Meat')").unwrap(),
"Meat",
"one item is the item, with no separator anywhere"
);
assert!(
s.eval::<bool>("return BuildListString() == nil").unwrap(),
"zero arguments is nil, not an empty string"
);
}
+135 -1
View File
@@ -179,6 +179,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
count: 1,
quality,
usable: true,
..Default::default()
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Detail,
@@ -239,9 +240,17 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
);
// The detail panel's choice rows are informational only (ref: only the REWARD panel's rows
// select) — clicking one must not raise (no OnClick wired) and must not set an itemChoice.
// select). They DO carry an OnClick now (`BenillaQuestItem_OnClick`, the ref's own
// QuestItemTemplate script — the ctrl/shift fork, decisions 1059/1060), but it has no select
// arm at all: an unmodified click must not raise and must not set an itemChoice.
s.run("BenillaQuestDetailChoice1:Click()").ok();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
0,
"a detail-panel row never selects a reward"
);
}
/// The reward panel's choice rows ARE selectable (ref `QuestRewardItem_OnClick`) — clicking one moves
@@ -263,6 +272,7 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
count: 1,
quality: 1,
usable: true,
..Default::default()
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Reward,
@@ -646,3 +656,127 @@ fn greeting_panel_title_rows_grow_to_their_wrapped_titles() {
"row 2 starts at/below row 1's bottom: row1 bottom {b1}, row2 top {t2}"
);
}
/// The questgiver rows' modifier fork (decisions 1059/1060), on the panel where it can do the most
/// damage: the REWARD panel, whose choice rows are also the quest's reward *selection*. Ref
/// `QuestRewardItem_OnClick` (QuestFrame.lua:127-141) — ctrl previews, shift posts the link, and the
/// choice SELECT is the third arm, so neither modified click may also pick the reward. The fixed
/// reward row beside it rides the plainer `QuestItem_OnClick` (l.115-125, the base
/// QuestItemTemplate's own OnClick) and previews the same way with no select arm to disturb.
///
/// Ordering note: the dressing room docks the same left UIPanel slot as the questgiver window
/// (`UiPanels.xml`, `pushable = 2` vs the giver's `0`), so the ctrl arm is exercised last — opening
/// it closes the giver window, exactly as it does in play.
#[test]
fn reward_rows_preview_and_post_without_selecting_the_choice() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
const SWORD: &str = "|cffffffff|Hitem:2299:0:0:0|h[Worn Sword]|h|r";
const MACE: &str = "|cffffffff|Hitem:2300:0:0:0|h[Worn Mace]|h|r";
let item = |id: u32, name: &str, link: &str| benilla_ui::script::QuestItemView {
item_id: id,
name: Some(name.into()),
texture: None,
count: 1,
quality: 1,
usable: true,
link: Some(link.into()),
};
s.set_quest(Some(QuestState {
panel: QuestPanel::Reward,
title: "A Threat Within".into(),
body: "Well done.".into(),
choices: vec![
item(2299, "Worn Sword", SWORD),
item(2300, "Worn Mace", MACE),
],
rewards: vec![item(
2504,
"Worn Shortbow",
"|cffffffff|Hitem:2504:0:0:0|h[Worn Shortbow]|h|r",
)],
..QuestState::default()
}));
s.fire_event(
"QUEST_COMPLETE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
// arm). Row 2 is picked here so the modified clicks below — aimed at row 1 — would visibly
// move it if they leaked into the select arm.
s.run("BenillaQuestRewardChoice2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"a plain click still selects the reward choice"
);
assert!(s
.eval::<bool>("return BenillaQuestRewardChoiceHighlight:IsShown()")
.unwrap());
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
);
// SHIFT + chat open → the row's full escaped link, and the selection is untouched.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
SWORD,
"shift-click posted choice 1's link"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"the shift arm returns — it must NOT also select the clicked choice"
);
// A FIXED reward row (plain BenillaQuestItemTemplate → BenillaQuestItem_OnClick) posts too.
s.run("ChatFrameEditBox:SetText(\"\")").unwrap();
s.set_modifiers(true, false, false);
s.run("BenillaQuestRewardReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"|cffffffff|Hitem:2504:0:0:0|h[Worn Shortbow]|h|r"
);
// CTRL → the dressing room wearing the clicked reward, and STILL no reselect. The intent pair
// is ordered: Dress (the room was closed, so it re-dresses in the player's own gear) then TryOn.
s.set_modifiers(false, true, false);
s.run("BenillaQuestRewardChoice1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![
benilla_ui::script::DressUpIntent::Dress,
benilla_ui::script::DressUpIntent::TryOn(2299)
],
"ctrl-click opened the room wearing choice 1"
);
assert_eq!(
s.eval::<i64>("return BenillaQuestFrame.itemChoice")
.unwrap(),
2,
"the ctrl arm returns — it must NOT also select the clicked choice"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -80,11 +80,18 @@ fn eight_entries() -> QuestLogState {
count: 1,
quality: 1,
usable: true,
// The reward row's ctrl/shift payload (`GetQuestLogItemLink`, decisions 1059/1060)
// — `ui_quest_log.rs` builds it through `ui_items::item_link` once the template
// lands, so the fixture carries that exact shape.
link: Some(HAMMER_LINK.into()),
}],
}),
}
}
/// The fixture reward's full escaped link — a real 1.12 item, quality white.
const HAMMER_LINK: &str = "|cffffffff|Hitem:2024:0:0:0|h[Militia Hammer]|h|r";
/// The loader itself: every file the window depends on parses and materializes with no errors.
#[test]
fn shipped_questlog_frame_loads_clean() {
@@ -594,6 +601,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
count: 1,
quality: 1,
usable: true,
..Default::default()
},
QuestItemView {
item_id: 0,
@@ -602,6 +610,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
count: 1,
quality: 1,
usable: true,
..Default::default()
},
],
rewards: vec![QuestItemView {
@@ -611,6 +620,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
});
s.set_quest_log(state);
@@ -711,6 +721,7 @@ fn overflowing_entry() -> QuestLogState {
count: 1,
quality: 1,
usable: true,
..Default::default()
}],
}),
}
@@ -985,3 +996,138 @@ fn popup_children_inherit_the_dialog_stratum() {
"the buttons draw ABOVE their dialog's backdrop (button z {button_z:x} vs backdrop z {backdrop_z:x})"
);
}
/// The quest log's reward rows carry the same modifier fork as the questgiver's (decisions
/// 1059/1060) — ref `QuestLogRewardItem_OnClick` (QuestLogFrame.lua:542-552), the OnClick the ref's
/// own `QuestLogRewardItemTemplate` adds (ref QuestLogFrame.xml:118-120). There is no third arm
/// here: a quest-log reward row is never selectable, so a plain click stays inert — which is the
/// regression this pins, since the row had NO OnClick at all before this arc.
///
/// Ordering note: the dressing room docks the same left UIPanel slot as the log window, so the ctrl
/// arm runs last — opening the room closes the log, exactly as it does in play.
#[test]
fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return BenillaQuestLogReward1:IsShown()")
.unwrap(),
"the fixture's one fixed reward row is laid out"
);
let _ = s.take_dressup_intents();
// A PLAIN click is inert: no room, no chat text, no error (the row has no select arm).
s.run("BenillaQuestLogReward1:Click()").unwrap();
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"",
"a plain click posts nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// SHIFT + chat open → the reward's full escaped link (ref l.547-550).
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("BenillaQuestLogReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
HAMMER_LINK,
"shift-click posted the reward's link"
);
assert!(
s.take_dressup_intents().is_empty(),
"the shift arm never opens the room"
);
// CTRL → the room wearing the reward (ref l.543-546): re-dress first, then try it on.
s.set_modifiers(false, true, false);
s.run("BenillaQuestLogReward1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
vec![
benilla_ui::script::DressUpIntent::Dress,
benilla_ui::script::DressUpIntent::TryOn(2024)
],
"ctrl-click opened the room wearing the reward"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A quest log TITLE row is a quest NAME, not an item — ref `QuestLogTitleButton_OnClick`
/// (QuestLogFrame.lua:469-505): with the chat edit box up, shift-click inserts the row's own text
/// with its leading indent trimmed (l.478-480, plain text — 1.12 has no `|Hquest:` link); the
/// quest-watch toggle is that branch's `else` (l.481-500). Both halves in one test because it is the
/// *fork* that matters: watching is the behaviour that already shipped, and the insert must not have
/// eaten it. The select-and-repaint tail (l.503-504) runs either way.
#[test]
fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_it_closed() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
s.set_quest_log(eight_entries());
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Row 1's centre — the same seat `shift_click_toggles_the_watch_checkbox_and_the_tracker_hud`
// computes (list TOPLEFT+(19,-75), 300×16).
s.resolve();
let win = frame_rect(&s.extract(), 384.0, 512.0);
let (rx, ry) = (win.left + 19.0 + 150.0, win.top - 75.0 - 8.0);
let shift_click = |s: &mut UiScript| {
s.set_modifiers(true, false, false);
s.mouse_button(rx, ry, "LeftButton", true);
s.mouse_button(rx, ry, "LeftButton", false);
s.set_modifiers(false, false, false);
};
// Chat CLOSED → the watch toggle, exactly as before.
shift_click(&mut s);
assert!(
s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"with chat closed, shift-click still toggles the quest watch"
);
shift_click(&mut s); // and back off, so the assertion below is unambiguous
assert!(!s.eval::<bool>("return IsQuestWatched(1)").unwrap());
// Chat OPEN → the bare quest name, and NO watch.
assert!(s.focus_editbox("ChatFrameEditBox"));
shift_click(&mut s);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap(),
"Quest 1",
"the trimmed quest name landed in the chat box (ref's gsub result, no row indent)"
);
assert!(
!s.eval::<bool>("return IsQuestWatched(1)").unwrap(),
"with chat open the watch must NOT toggle — the ref makes watching the else"
);
// The tail runs either way (ref l.503-504): the click still selects the row.
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -98,7 +98,8 @@ fn loading_the_shipped_ui_queues_no_sounds() {
///
/// The shape half runs everywhere; the resolve half needs client data and skips without it (a
/// hand-kept list of real file names would rot into agreeing with itself — the `text=` sweep's
/// argument below). Resolution goes through the renderer's own `sprite_key`, not a copy of it.
/// argument below). Resolution goes through the renderer's own `sprite_candidates`, not a copy of it —
/// including its `.blp`/`.tga` fallback, so the sweep accepts exactly what the renderer accepts.
#[test]
fn every_shipped_texture_path_resolves_in_the_client_archives() {
use benilla_ui::framexml::{Element, TopLevel};
@@ -163,7 +164,11 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
let chain = benilla_formats::open_chain(&data).expect("open chain");
let missing: Vec<String> = refs
.iter()
.filter(|(_, _, path)| !chain.contains(&crate::assets::sprite_key(path)))
.filter(|(_, _, path)| {
!crate::assets::sprite_candidates(path)
.iter()
.any(|c| chain.contains(c))
})
.map(|(file, tag, path)| format!("{file}: <{tag} file=\"{path}\">"))
.collect();
assert!(
@@ -425,3 +425,299 @@ fn solo_target_inspect_click_reaches_inspect_unit() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The PET menu (decision 1066; report B219) ───────────────────────────────────────────────────
/// The pet menu's own prefix. Two things join the popup prefix: `UiPanels.xml` (the StaticPopup
/// engine, because two of the four rows go behind a dialog) and `PetActionBar.xml`, which is where
/// those three dialogs are registered — with the pet arc, not with the rows. `Cooldown.xml` and
/// `ActionBar.xml` are the pet bar's own load deps, not this menu's.
fn load_pet_menu_frames(s: &UiScript) {
for file in [
"Fonts.xml",
"UIParent.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"UnitFrames.xml",
"Cooldown.xml",
"ActionBar.xml",
"PetActionBar.xml",
] {
load_xml(s, file);
}
}
/// The PET rows' labels and both dialogs' text, verbatim from the real
/// `Interface\FrameXML\GlobalStrings.lua` off the 1.12.1 patch chain (l.3028-3052 and l.3) — which
/// is what the app itself runs at boot; this only stands in for it in a bare harness.
fn bake_pet_strings(s: &UiScript) {
s.run(
r#"
PET_PAPERDOLL = "Pet Details"
PET_RENAME = "Rename"
PET_ABANDON = "Abandon"
PET_DISMISS = "Dismiss"
CANCEL = "Cancel"
OKAY = "Okay"
ACCEPT = "Accept"
YES = "Yes"
NO = "No"
ABANDON_PET = "Are you sure you want to permanently abandon your pet?"
PET_RENAME_LABEL = "Enter desired name of pet:"
PET_RENAME_CONFIRMATION = "Name your pet '%s'?"
-- ToggleCharacter lives in CharacterFrame.xml, which the paperdoll row needs and this
-- isolation prefix does not load. Recorded rather than stubbed away, so the row's click
-- is still proven to reach the right panel name.
function ToggleCharacter(tab) BENILLA_TEST_TOGGLED = tab end
"#,
)
.unwrap();
}
/// A pet that exists, so `UnitExists("pet")` passes the dropdown's own gate.
fn a_pet(s: &mut UiScript, name: &str) {
s.set_unit(
"pet",
Some(UnitState {
exists: true,
name: Some(name.into()),
health: 100,
max_health: 100,
level: 20,
..UnitState::default()
}),
);
s.fire_event(
"UNIT_PET",
vec![benilla_ui::script::ScriptValue::Str("player".into())],
);
}
/// Open the pet menu through the real hit path, and return nothing — the assertions read
/// `DropDownList1` afterwards.
fn right_click_the_pet_frame(s: &mut UiScript) {
s.resolve();
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaPetFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
s.resolve();
}
/// **A hunter's pet shows Abandon and hides Dismiss; a warlock's demon does the reverse** — the
/// one predicate that forks the whole menu (`UnitPopup.lua:402-417`).
///
/// This is the assertion B219 turns on. Getting the sense backwards is silent both ways: a hunter
/// offered only Dismiss still cannot get past a taming step, and a demon offered Abandon is
/// offered a row the reference never shows.
#[test]
fn the_pet_menu_forks_between_abandon_and_dismiss() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Bruce");
// A freshly tamed hunter pet: both bits set.
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"right-clicking the pet frame opens the PET menu"
);
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
5,
"title + Pet Details + Rename + Abandon + Cancel — no Dismiss"
);
for (row, text) in [
(2, "Pet Details"),
(3, "Rename"),
(4, "Abandon"),
(5, "Cancel"),
] {
assert_eq!(
s.eval::<String>(&format!("return DropDownList1Button{row}:GetText()"))
.unwrap(),
text
);
}
// The same pet after one rename — the server clears only the rename bit, so only that row goes.
s.run("CloseDropDownMenus()").unwrap();
s.set_pet_menu(true, false);
right_click_the_pet_frame(&mut s);
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
4,
"title + Pet Details + Abandon + Cancel"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button3:GetText()")
.unwrap(),
"Abandon",
"Rename is gone and Abandon has moved up into its row"
);
// A warlock's demon: neither bit. The menu flips whole.
s.run("CloseDropDownMenus()").unwrap();
s.set_pet_menu(false, false);
right_click_the_pet_frame(&mut s);
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
3,
"title + Dismiss + Cancel"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button2:GetText()")
.unwrap(),
"Dismiss"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Dismiss goes straight out; Abandon goes behind the confirm** — the reference's own asymmetry
/// (`UnitPopup_OnClick` l.590-593), and the one that matters: sending a summon away costs nothing,
/// giving up a tamed pet is a server-side delete.
#[test]
fn dismiss_sends_immediately_and_abandon_waits_for_the_confirm() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Snuffles");
// A demon: Dismiss is row 2, and clicking it queues the verb with no dialog in between.
s.set_pet_menu(false, false);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 2);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"Dismiss asks nothing"
);
assert_eq!(s.take_pet_gives_up(), (0, 1), "one dismiss, no abandon");
// A hunter pet: Abandon is row 4, and clicking it only opens the confirm.
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 4);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"Abandon opens the ABANDON_PET confirm"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Are you sure you want to permanently abandon your pet?"
);
assert_eq!(
s.take_pet_gives_up(),
(0, 0),
"and sends NOTHING until it is accepted"
);
// Cancel really cancels.
click_frame(&mut s, "StaticPopup1Button2");
assert_eq!(s.take_pet_gives_up(), (0, 0));
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
// Accept sends.
right_click_the_pet_frame(&mut s);
click_row(&mut s, 4);
click_frame(&mut s, "StaticPopup1Button1");
assert_eq!(s.take_pet_gives_up(), (1, 0), "one abandon");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The rename is a chain of two dialogs**, and the second reads the typed name back before
/// anything is sent (`RENAME_PET` → `PETRENAMECONFIRM`, ref StaticPopup.lua l.1069-1102 + l.365).
///
/// The chain is also what proves the popup engine's two instances are both live, and why the
/// dialogs must read their OWN edit box rather than `StaticPopup1EditBox` by name: the confirm
/// opens while the name dialog is still up, so the second one lands in instance 2.
#[test]
fn renaming_a_pet_reads_the_name_back_before_sending_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Bruce");
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 3);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Enter desired name of pet:"
);
assert!(
s.eval::<bool>("return StaticPopup1EditBox:IsVisible()")
.unwrap(),
"the name dialog carries an edit box"
);
s.run(r#"StaticPopup1EditBox:SetText("Rexxar")"#).unwrap();
click_frame(&mut s, "StaticPopup1Button1");
assert_eq!(
s.take_pet_renames(),
Vec::<String>::new(),
"accepting the NAME dialog sends nothing — it only asks again"
);
assert!(
s.eval::<bool>("return StaticPopup2:IsVisible()").unwrap(),
"the confirm opens in the second instance, while the first is still up"
);
assert_eq!(
s.eval::<String>("return StaticPopup2Text:GetText()")
.unwrap(),
"Name your pet 'Rexxar'?",
"and it reads the typed name back"
);
click_frame(&mut s, "StaticPopup2Button1");
assert_eq!(
s.take_pet_renames(),
vec!["Rexxar".to_string()],
"only the CONFIRM sends, and it sends the name from the box"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The paperdoll row reaches the pet paper doll panel (decision 1057) — the fifth build the menu
/// was blocked on, and the only row here that opens a window rather than a wire verb.
#[test]
fn the_pet_details_row_opens_the_pet_paper_doll() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
bake_pet_strings(&s);
load_pet_menu_frames(&s);
a_pet(&mut s, "Bruce");
s.set_pet_menu(true, true);
right_click_the_pet_frame(&mut s);
click_row(&mut s, 2);
assert_eq!(
s.eval::<String>("return BENILLA_TEST_TOGGLED").unwrap(),
"BenillaPetPaperDollFrame"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Click an open dropdown row through the real hit path.
fn click_row(s: &mut UiScript, row: u32) {
click_frame(s, &format!("DropDownList1Button{row}"));
}
/// Click any named frame through the real hit path.
fn click_frame(s: &mut UiScript, name: &str) {
let (x, y) = s
.eval::<(f64, f64)>(&format!("return {name}: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();
}