- the player, party, target and pet frames are the reference's own files (`BuffFrame`, `UnitFrame`, `CombatFeedback`, `PlayerFrame`, `PartyFrame`, `TargetFrame`, `PetFrame`, in the reference's toc order) — our 2741-line `UnitFrames.xml` and our `PartyFrame.xml` are deleted; four engine verbs built for them (`PartialPlayTime`, `NoPlayTime`, `UnitPlayerControlled`, `UnitIsCivilian`, each answering 1/nil), the whole-UI load bracketed in the counted sound-suppression scope `0x48fbf0` self-brackets (the reference fires a `PlaySound` during load and throws it away), and `GetBillingTimeRested` read off `SMSG_AUTH_RESPONSE`'s third billing field rather than hardcoded; the differences the stock files bring are pinned rather than papered — `PlayerFrame` never hides, an unresolved name is BLANK not "Unknown", the target frame has no numerals at all (a bar hover pops the unit tooltip instead) - what the swap surfaced, each a hard break under the stock files: `UnitBuff`/`UnitDebuff` returned the Classic Era ten-value tuple, name first, where 1.12 returns two and three values TEXTURE first (`0x519500`/`0x5198f0`) — a raise twice per `UNIT_AURA` on any debuffed target, and all 184 corpus call sites bind the 1.12 shape; the power events are named per resource (`UNIT_MANA`/`RAGE`/`FOCUS`/`ENERGY`/`HAPPINESS` and their MAX twins) and we fired the Era pair, so no mana, rage or energy bar could live-update; `SetRaidTarget` is the engine verb and `SetRaidTargetIcon` the FrameXML toggle wrapper — we had registered the wrapper's name on the engine's body, so every raid mark in the unit popup raised - the underwater drift cloud: the reference draws a fixed 4000-mote field whenever the camera eye is inside a liquid — not a particle system, nothing spawns, ages or dies; a population scattered once into a 30 yd camera-relative cube, advected by the camera's own motion plus a gust and wrapped, so the field stands still in the world; benilla had the murk and nothing in it; a dry frame commits no draw, and the submerged one is bounded by the reference's own 666-quad cap (633–644 of 4000 measured live, which is the expected survivor count of a uniform cube through the 90° cone — an independent check on population, distribution and cone at once) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
949 lines
36 KiB
Rust
949 lines
36 KiB
Rust
//! The unit right-click popups reached the two ways a solo player invites someone (director
|
||
//! report, 2026-07-17): right-clicking a **target** unit frame, and right-clicking a **chat
|
||
//! name**. Both drive the shared UnitPopup engine (`UnitPopup.xml`); the chat path adds the
|
||
//! `SetItemRef` player branch — the reference's own since 1751 window 6 — and the FRIEND dropdown
|
||
//! it opens, which lives in `FriendsFrame.xml` where 1.12 keeps it. Proven serverless through the
|
||
//! real hit/route paths.
|
||
|
||
use benilla_ui::script::{FollowRequest, PartyRequest, UiScript, UnitState};
|
||
|
||
use super::test_ui::load_ui as load_xml;
|
||
|
||
/// The production prefix the popups need, in `benilla.toc`'s own order: the dropdown kit, the
|
||
/// unit popups, the reference's chat-link router, the unit frames — and `FriendsFrame.xml`, which
|
||
/// is where `FriendsFrame_ShowDropdown` and the `FriendsDropDown` host live in 1.12 and where they
|
||
/// live here since window 6 (our ItemRef.xml used to carry a private second copy of both).
|
||
fn load_popup_frames(s: &UiScript) {
|
||
for file in [
|
||
"Fonts.xml",
|
||
"UIParent.xml",
|
||
// `SmallMoneyFrame_OnLoad`, which UiPanels' own StaticPopup money rows call at load.
|
||
"MoneyFrame.xml",
|
||
// `StaticPopupDialogs` and the `PanelTemplates_*` family, both of which FriendsFrame.xml
|
||
// reaches at LOAD (its tab row and its confirm dialogs).
|
||
"UiPanels.xml",
|
||
"GameTooltip.xml",
|
||
"UIDropDownMenu.xml",
|
||
"UnitPopup.xml",
|
||
"Interface\\FrameXML\\ItemRef.xml",
|
||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||
"Interface\\FrameXML\\BuffFrame.xml",
|
||
"Interface\\FrameXML\\UnitFrame.xml",
|
||
"Interface\\FrameXML\\CombatFeedback.xml",
|
||
"Interface\\FrameXML\\PlayerFrame.xml",
|
||
"Interface\\FrameXML\\PartyFrame.xml",
|
||
"Interface\\FrameXML\\TargetFrame.xml",
|
||
"Interface\\FrameXML\\PetFrame.xml",
|
||
"ScrollTemplates.xml",
|
||
"UIPanelTemplates.xml",
|
||
"FriendsFrame.xml",
|
||
] {
|
||
load_xml(s, file);
|
||
}
|
||
}
|
||
|
||
/// The row labels the FRIEND / PLAYER menus bake at load (production reads GlobalStrings.lua; a
|
||
/// bare harness doesn't). Only the rows these menus actually SHOW need real text — the DEFERRED
|
||
/// rows are hidden before they're added.
|
||
fn bake_strings(s: &UiScript) {
|
||
s.run(
|
||
r#"
|
||
WHISPER = "Whisper"
|
||
PARTY_INVITE = "Invite"
|
||
TRADE = "Trade"
|
||
DUEL = "Duel"
|
||
-- The inspect row's label (decision 0631). Verified against the real
|
||
-- `Interface\FrameXML\GlobalStrings.lua:2327` off the 1.12.1 patch chain — which is what the
|
||
-- app itself runs at boot (`load_global_strings`); this stub only stands in for it here.
|
||
INSPECT = "Inspect"
|
||
-- The follow row's label (decision 0893), likewise the real
|
||
-- `GlobalStrings.lua:1981` value off the 1.12.1 patch chain.
|
||
FOLLOW = "Follow"
|
||
CANCEL = "Cancel"
|
||
RAID_TARGET_ICON = "Raid Target Icon"
|
||
-- The newbie tooltip the stock unit frame raises on a HOVER, which every test in this file
|
||
-- takes on its way to a right-click. `UnitFrame_OnEnter` (ref `UnitFrame.lua:58-65`) runs
|
||
-- the detailed-tip branch whenever `SHOW_NEWBIE_TIPS == "1"` — 1.12's own default
|
||
-- (`UIOptionsFrame.lua:100`), which our `GameTooltip.xml:46` sets at load — and for a
|
||
-- player-controlled target that is not us it calls
|
||
-- `GameTooltip_AddNewbieTip(PLAYER_OPTIONS_LABEL, 1, 1, 1, NEWBIE_TOOLTIP_PLAYEROPTIONS)`.
|
||
-- Both are nil in a bare harness, and `GameTooltip:SetText(nil)` raises. Verbatim from the
|
||
-- real `Interface\FrameXML\GlobalStrings.lua` off the 1.12.1 chain (l.3081 and l.2755).
|
||
-- Our deleted `UnitFrames.xml` never reached this arm; the stock file does.
|
||
PLAYER_OPTIONS_LABEL = "Player Options"
|
||
NEWBIE_TOOLTIP_PLAYEROPTIONS = "Right-click to bring up special commands for interacting with another player. You can inspect their equipment, issue a party invite, initiate a trade, or challenge a player to a duel. A group leader can promote or remove that player from the group."
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
/// Right-clicking a **chat player name** opens the name-only FRIEND dropdown (the ref's
|
||
/// `FriendsFrame_ShowDropdown`): Whisper + Invite for a stranger. Clicking Invite queues an
|
||
/// invite-by-name; a plain left-click on the name whispers instead. This is the bug the director
|
||
/// hit — the player branch of `SetItemRef` used to be a no-op stub.
|
||
#[test]
|
||
fn chat_name_right_click_opens_the_invite_menu() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
bake_strings(&s);
|
||
load_popup_frames(&s);
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
|
||
|
||
assert!(
|
||
s.eval::<bool>("return not DropDownList1:IsVisible()")
|
||
.unwrap(),
|
||
"no menu before any click"
|
||
);
|
||
|
||
// Right-click the |Hplayer:Bob|h link the chat frame emits (ChatFrame OnHyperlinkClick →
|
||
// SetItemRef(link, text, "RightButton")).
|
||
s.run(r#"SetItemRef("player:Bob", "|Hplayer:Bob|h[Bob]|h", "RightButton")"#)
|
||
.unwrap();
|
||
s.resolve();
|
||
assert!(
|
||
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
|
||
"the chat-name right-click opens the FRIEND dropdown"
|
||
);
|
||
// title (Bob) + Whisper + Invite + Cancel — the rest of FRIEND is DEFERRED/hidden.
|
||
assert_eq!(
|
||
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
|
||
4,
|
||
"title + Whisper + Invite + Cancel"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button1:GetText()")
|
||
.unwrap(),
|
||
"Bob",
|
||
"the clicked name titles the menu"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||
.unwrap(),
|
||
"Invite"
|
||
);
|
||
|
||
// Click Invite through the real hit path → UnitPopup_OnClick → InviteByName("Bob").
|
||
let (ix, iy) = s
|
||
.eval::<(f64, f64)>("return DropDownList1Button3:GetCenter()")
|
||
.unwrap();
|
||
s.mouse_button(ix as f32, iy as f32, "LeftButton", true);
|
||
s.mouse_button(ix as f32, iy as f32, "LeftButton", false);
|
||
assert_eq!(
|
||
s.take_party_requests(),
|
||
vec![PartyRequest::InviteName("Bob".into())],
|
||
"Invite on a chat name queues an invite-by-name"
|
||
);
|
||
|
||
// A plain LEFT-click on the name whispers instead (ref's else branch).
|
||
s.run(r#"SetItemRef("player:Carol", "|Hplayer:Carol|h[Carol]|h", "LeftButton")"#)
|
||
.unwrap();
|
||
assert_eq!(
|
||
s.take_tell_requests(),
|
||
vec!["Carol".to_string()],
|
||
"left-click a chat name opens a whisper"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// Right-clicking a **friendly player target** solo opens the PLAYER menu with Whisper + Invite
|
||
/// (the target-frame half of the director's report). Confirms the solo target path is NOT gated
|
||
/// off for players — only NPC/self targets, whose every row needs a party, open nothing.
|
||
#[test]
|
||
fn solo_target_right_click_invites_a_player() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
bake_strings(&s);
|
||
load_popup_frames(&s);
|
||
|
||
s.set_unit(
|
||
"player",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Me".into()),
|
||
is_player: true,
|
||
player_controlled: true,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
// A same-faction friendly PLAYER target (is_player + reaction 5 → UnitCanCooperate == 1).
|
||
s.set_unit(
|
||
"target",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Ally".into()),
|
||
health: 40,
|
||
max_health: 40,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
reaction: 5,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
|
||
|
||
// Right-click the target frame through the real hit path.
|
||
let (cx, cy) = s
|
||
.eval::<(f64, f64)>("return TargetFrame: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();
|
||
assert!(
|
||
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
|
||
"a friendly player target opens the PLAYER menu solo"
|
||
);
|
||
// title (Ally) + Whisper + Inspect + Invite + Trade + Follow + Duel + Cancel — nothing in the
|
||
// PLAYER menu is DEFERRED any more, and only the raid-target submenu (which needs a party) is
|
||
// absent. (Trade came out of the deferred set in decision 0592 P1, Inspect in 0631, Duel in
|
||
// 0633, Follow in 0893 — Inspect precedes Invite/Trade/Follow/Duel in the reference's own
|
||
// PLAYER menu order, which is why every row below it sits one later than it did before 0631,
|
||
// and Duel one later again than before 0893.)
|
||
assert_eq!(
|
||
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
|
||
8,
|
||
"title + Whisper + Inspect + Invite + Trade + Follow + Duel + Cancel"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||
.unwrap(),
|
||
"Inspect"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button4:GetText()")
|
||
.unwrap(),
|
||
"Invite"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button5:GetText()")
|
||
.unwrap(),
|
||
"Trade"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button6:GetText()")
|
||
.unwrap(),
|
||
"Follow"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button7:GetText()")
|
||
.unwrap(),
|
||
"Duel"
|
||
);
|
||
|
||
// Click Invite → UnitPopup_OnClick → InviteToParty("target") → InviteUnit.
|
||
let (ix, iy) = s
|
||
.eval::<(f64, f64)>("return DropDownList1Button4:GetCenter()")
|
||
.unwrap();
|
||
s.mouse_button(ix as f32, iy as f32, "LeftButton", true);
|
||
s.mouse_button(ix as f32, iy as f32, "LeftButton", false);
|
||
assert_eq!(
|
||
s.take_party_requests(),
|
||
vec![PartyRequest::InviteUnit("target".into())],
|
||
"Invite on a player target queues an invite by unit"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// Clicking the **Trade** row (the un-deferred 0592 P1 button) through the real hit path fires
|
||
/// `InitiateTrade("target")` — the exact seam the director hit as "click Trade, nothing happens".
|
||
/// The other button tests click Invite; this is the only test that drives the Trade row's OnClick,
|
||
/// so a break between `this.value == "TRADE"` and the queued initiate token shows up here.
|
||
#[test]
|
||
fn solo_target_trade_click_queues_an_initiate() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
bake_strings(&s);
|
||
load_popup_frames(&s);
|
||
|
||
s.set_unit(
|
||
"player",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Me".into()),
|
||
is_player: true,
|
||
player_controlled: true,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.set_unit(
|
||
"target",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Ally".into()),
|
||
health: 40,
|
||
max_health: 40,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
reaction: 5,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
|
||
|
||
// Right-click the target frame → PLAYER menu.
|
||
let (cx, cy) = s
|
||
.eval::<(f64, f64)>("return TargetFrame: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();
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button5:GetText()")
|
||
.unwrap(),
|
||
"Trade",
|
||
"Trade is the fifth row (title + Whisper + Inspect + Invite + Trade) — Inspect joined \
|
||
ahead of it in decision 0631"
|
||
);
|
||
|
||
// Click Trade through the real hit path → UnitPopup_OnClick's TRADE arm → InitiateTrade("target").
|
||
let (tx, ty) = s
|
||
.eval::<(f64, f64)>("return DropDownList1Button5:GetCenter()")
|
||
.unwrap();
|
||
s.mouse_button(tx as f32, ty as f32, "LeftButton", true);
|
||
s.mouse_button(tx as f32, ty as f32, "LeftButton", false);
|
||
assert_eq!(
|
||
s.take_trade_initiates(),
|
||
vec!["target".to_string()],
|
||
"clicking Trade queues an initiate against the target token"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// Clicking the **Follow** row (un-deferred in decision 0893) through the real hit path reaches
|
||
/// `FollowByName("Ally", 1)` — and the `1` is the point of the test, not decoration. That second
|
||
/// argument is the resolver's exact-only flag: the menu already knows the unit's name to the
|
||
/// letter, so unlike `/follow rag` it must not prefix-match its way onto a bystander. A dispatch
|
||
/// that dropped the argument would still queue a follow and still look right on screen, which is
|
||
/// exactly the kind of break a row-label assertion cannot see.
|
||
#[test]
|
||
fn solo_target_follow_click_queues_an_exact_by_name_follow() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
bake_strings(&s);
|
||
load_popup_frames(&s);
|
||
|
||
s.set_unit(
|
||
"player",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Me".into()),
|
||
is_player: true,
|
||
player_controlled: true,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.set_unit(
|
||
"target",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Ally".into()),
|
||
health: 40,
|
||
max_health: 40,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
reaction: 5,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
|
||
|
||
let (cx, cy) = s
|
||
.eval::<(f64, f64)>("return TargetFrame: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();
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button6:GetText()")
|
||
.unwrap(),
|
||
"Follow",
|
||
"Follow is the sixth row (title + Whisper + Inspect + Invite + Trade + Follow)"
|
||
);
|
||
|
||
let (fx, fy) = s
|
||
.eval::<(f64, f64)>("return DropDownList1Button6:GetCenter()")
|
||
.unwrap();
|
||
s.mouse_button(fx as f32, fy as f32, "LeftButton", true);
|
||
s.mouse_button(fx as f32, fy as f32, "LeftButton", false);
|
||
assert_eq!(
|
||
s.take_follow_requests(),
|
||
vec![FollowRequest::ByName {
|
||
name: "Ally".into(),
|
||
exact: true,
|
||
}],
|
||
"the popup follows by NAME and exactly — not by unit token, and not prefix-matched"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// Clicking the **Inspect** row (un-deferred in decision 0631) through the real hit path reaches
|
||
/// `InspectUnit("target")` — the same "the row is there but the click does nothing" seam 0592 hit on
|
||
/// Trade, which is a dispatch break (`button == "INSPECT"` never matching) that a row-label test
|
||
/// cannot see. `InspectUnit` is stubbed rather than loading the whole window: this file tests the
|
||
/// popup's dispatch, and `inspect_tests.rs` owns what the window then does.
|
||
#[test]
|
||
fn solo_target_inspect_click_reaches_inspect_unit() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
bake_strings(&s);
|
||
load_popup_frames(&s);
|
||
s.run("BENILLA_TEST_INSPECTED = nil; function InspectUnit(unit) BENILLA_TEST_INSPECTED = unit end")
|
||
.unwrap();
|
||
|
||
s.set_unit(
|
||
"player",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Me".into()),
|
||
is_player: true,
|
||
player_controlled: true,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.set_unit(
|
||
"target",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Ally".into()),
|
||
health: 40,
|
||
max_health: 40,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
reaction: 5,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
s.resolve();
|
||
|
||
let (cx, cy) = s
|
||
.eval::<(f64, f64)>("return TargetFrame: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();
|
||
assert_eq!(
|
||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||
.unwrap(),
|
||
"Inspect",
|
||
"Inspect is the third row (title + Whisper + Inspect)"
|
||
);
|
||
|
||
let (ix, iy) = s
|
||
.eval::<(f64, f64)>("return DropDownList1Button3:GetCenter()")
|
||
.unwrap();
|
||
s.mouse_button(ix as f32, iy as f32, "LeftButton", true);
|
||
s.mouse_button(ix as f32, iy as f32, "LeftButton", false);
|
||
assert_eq!(
|
||
s.eval::<String>("return BENILLA_TEST_INSPECTED").unwrap(),
|
||
"target",
|
||
"clicking Inspect calls InspectUnit against the target token"
|
||
);
|
||
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",
|
||
"MoneyFrame.xml",
|
||
"UiPanels.xml",
|
||
"GameTooltip.xml",
|
||
"UIDropDownMenu.xml",
|
||
"UnitPopup.xml",
|
||
"Interface\\FrameXML\\TextStatusBar.lua",
|
||
"Interface\\FrameXML\\TextStatusBar.xml",
|
||
"Interface\\FrameXML\\BuffFrame.xml",
|
||
"Interface\\FrameXML\\UnitFrame.xml",
|
||
"Interface\\FrameXML\\CombatFeedback.xml",
|
||
"Interface\\FrameXML\\PlayerFrame.xml",
|
||
"Interface\\FrameXML\\PartyFrame.xml",
|
||
"Interface\\FrameXML\\TargetFrame.xml",
|
||
"Interface\\FrameXML\\PetFrame.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) {
|
||
// A player first: `PetFrame` is a CHILD of `PlayerFrame` (our UnitFrames.xml:1792 and the
|
||
// reference's PetFrame.xml:4 both say `parent="PlayerFrame"`), and `UnitFrame_Update` hides a
|
||
// frame whose unit does not exist — so a pet with no player leaves the pet frame inside a
|
||
// hidden parent and the right-click never lands. A pet without a player is not a state the game
|
||
// can be in; the fixture was only ever getting away with it because the loader used to ignore
|
||
// the `parent=` attribute.
|
||
s.set_unit(
|
||
"player",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Me".into()),
|
||
health: 100,
|
||
max_health: 100,
|
||
level: 60,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event(
|
||
"UNIT_HEALTH",
|
||
vec![benilla_ui::script::ScriptValue::Str("player".into())],
|
||
);
|
||
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.
|
||
///
|
||
/// **The click lands in the frame's own HIT RECT, not at its centre** — and on the reference's
|
||
/// file those are two different places. Stock `PetFrame.xml:15-17` ships
|
||
/// `<HitRectInsets><AbsInset left="7" right="66" top="6" bottom="7"/></HitRectInsets>` on a
|
||
/// 128×53 button (`PetFrame.xml:4-7`), so the clickable
|
||
/// band is the PORTRAIT half (x = left+7 … left+62) and the geometric centre (x = left+64) is two
|
||
/// pixels outside it. Worse, the centre sits *inside* `PetFrameHealthBar` (`PetFrame.xml:125-135`:
|
||
/// TOPLEFT 47,-22, 70×8), which is mouse-enabled by the `TextStatusBar` template's OnEnter
|
||
/// (ref `TextStatusBar.xml:11-27`) and sits at `PetFrame.level + 1` — so under the hit law it
|
||
/// takes the point and the click is consumed there. Unlike stock `PlayerFrame`, stock `PetFrame`
|
||
/// ships **no** `<OnMouseUp>` forwarder on its bars, so that click is genuinely eaten in the real
|
||
/// client too; a right-click on a pet's health bar opens nothing. Our deleted transcription had no
|
||
/// hit-rect insets on the pet frame, which is the only reason `GetCenter()` ever worked here.
|
||
fn right_click_the_pet_frame(s: &mut UiScript) {
|
||
s.resolve();
|
||
let (cx, cy) = s
|
||
.eval::<(f64, f64)>(
|
||
"local il, ir, it, ib = PetFrame:GetHitRectInsets() \
|
||
return (PetFrame:GetLeft() + il + PetFrame:GetRight() - ir) / 2, \
|
||
(PetFrame:GetBottom() + ib + PetFrame:GetTop() - it) / 2",
|
||
)
|
||
.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 _data = benilla_formats::wow_data_or_skip!();
|
||
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 _data = benilla_formats::wow_data_or_skip!();
|
||
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 _data = benilla_formats::wow_data_or_skip!();
|
||
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 _data = benilla_formats::wow_data_or_skip!();
|
||
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(),
|
||
"PetPaperDollFrame"
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
// ── The enable-pass driver (BenillaUnitPopupDriver) ─────────────────────────────────────────────
|
||
|
||
/// **The idle popup driver parks itself** (decision 1396's class, the audit's driver-hide item):
|
||
/// with no unit menu of ours open, `UnitPopup_OnUpdate`'s early-out hides the driver's own frame —
|
||
/// and the engine dispatches OnUpdate only to visible frames (`tick.rs`), so the per-frame scan
|
||
/// stops entirely. `UnitPopup_ShowMenu` — the one opener every unit menu goes through (grep:
|
||
/// UnitFrames/PartyFrame/FriendsFrame/ItemRef all call it) — wakes it again.
|
||
///
|
||
/// The probe wraps `UnitPopup_OnUpdate` in a counter: after the first tick parks the shipped-shown
|
||
/// driver, ten idle ticks never run the pass. The control: an open target menu still runs the
|
||
/// per-frame enable pass — a disconnected target's Whisper row greys, and greys back live when the
|
||
/// unit reconnects mid-open — and closing the menu re-parks the driver.
|
||
#[test]
|
||
fn an_idle_unit_popup_driver_parks_itself_off_the_tick() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
bake_strings(&s);
|
||
load_popup_frames(&s);
|
||
|
||
s.set_unit(
|
||
"player",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Me".into()),
|
||
is_player: true,
|
||
player_controlled: true,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
// A friendly player target, DISCONNECTED (is_connected defaults to false): the Whisper row's
|
||
// grey predicate is live from the first enable pass.
|
||
s.set_unit(
|
||
"target",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Ally".into()),
|
||
health: 40,
|
||
max_health: 40,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
reaction: 5,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
s.resolve();
|
||
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
|
||
|
||
s.run(
|
||
"BENILLA_TEST_POPUP_TICKS = 0\n\
|
||
local real = UnitPopup_OnUpdate\n\
|
||
function UnitPopup_OnUpdate(elapsed)\n\
|
||
BENILLA_TEST_POPUP_TICKS = BENILLA_TEST_POPUP_TICKS + 1\n\
|
||
real(elapsed)\n\
|
||
end",
|
||
)
|
||
.unwrap();
|
||
|
||
// The driver ships shown; the first tick's early-out parks it, and then nothing runs.
|
||
s.tick(0.016);
|
||
s.resolve();
|
||
assert!(
|
||
!s.eval::<bool>("return BenillaUnitPopupDriver:IsShown()")
|
||
.unwrap(),
|
||
"no menu open: the first tick parks the driver"
|
||
);
|
||
s.run("BENILLA_TEST_POPUP_TICKS = 0").unwrap();
|
||
for _ in 0..10 {
|
||
s.tick(0.016);
|
||
s.resolve();
|
||
}
|
||
assert_eq!(
|
||
s.eval::<i64>("return BENILLA_TEST_POPUP_TICKS").unwrap(),
|
||
0,
|
||
"a parked driver never runs the enable pass"
|
||
);
|
||
|
||
// Open the PLAYER menu through the real hit path: the driver wakes...
|
||
let (cx, cy) = s
|
||
.eval::<(f64, f64)>("return TargetFrame: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();
|
||
assert!(
|
||
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
|
||
"the menu opened"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return BenillaUnitPopupDriver:IsShown()")
|
||
.unwrap(),
|
||
"UnitPopup_ShowMenu wakes the driver"
|
||
);
|
||
|
||
// ...and the pass runs per frame: the disconnected target's Whisper row greys...
|
||
s.tick(0.016);
|
||
s.resolve();
|
||
assert!(
|
||
s.eval::<i64>("return BENILLA_TEST_POPUP_TICKS").unwrap() > 0,
|
||
"an open menu ticks the enable pass"
|
||
);
|
||
assert!(
|
||
!s.eval::<bool>("return DropDownList1Button2:IsEnabled() ~= 0")
|
||
.unwrap(),
|
||
"Whisper greys against a disconnected target"
|
||
);
|
||
|
||
// ...and un-greys LIVE when the unit reconnects while the menu is up — the whole reason the
|
||
// pass is per-frame rather than baked at open.
|
||
s.set_unit(
|
||
"target",
|
||
Some(UnitState {
|
||
exists: true,
|
||
name: Some("Ally".into()),
|
||
health: 40,
|
||
max_health: 40,
|
||
is_player: true,
|
||
player_controlled: true,
|
||
reaction: 5,
|
||
is_connected: true,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.tick(0.016);
|
||
s.resolve();
|
||
assert!(
|
||
s.eval::<bool>("return DropDownList1Button2:IsEnabled() ~= 0")
|
||
.unwrap(),
|
||
"Whisper enables the frame the target reconnects"
|
||
);
|
||
|
||
// Closing the menu re-parks the driver on its next tick.
|
||
s.run("CloseDropDownMenus()").unwrap();
|
||
s.tick(0.016);
|
||
s.resolve();
|
||
assert!(
|
||
!s.eval::<bool>("return BenillaUnitPopupDriver:IsShown()")
|
||
.unwrap(),
|
||
"a closed menu parks the driver again"
|
||
);
|
||
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();
|
||
}
|