Files
benilla-octo/crates/benilla-app/src/ui_script/unit_popup_tests.rs
T
SamandClaude Fable 5.1 404708859a every dialog is the reference's own, and the trainer loads on demand
- every dialog is the reference's own: `StaticPopup.xml` + `StaticPopup.lua` load off the player's chain, and 0308's engine with its 37 dialogs leaves `UiPanels.xml`; all 76 of the reference's dialogs are the stock table's, 28 of which no file of ours had ever registered (the resurrect family among them); the nine hidden listener frames that existed only because benilla once had no `UIParent_OnEvent` come home to it verbatim from UIParent.lua, and the seven files that were nothing but a dialog and its driver are deleted; what the swap surfaced: the reference capitalises the countdown's unit word ("5 Minutes until release") where ours had lowercased it, and ESC in the typed delete confirm's box is swallowed — the entry names no escape handler; arms for events this engine never fires (PLAYER_SKINNED, TRADE_REQUEST) wait on their feeds rather than being armed blind (1960)
- the social window and the unit menus are the reference's own files: `FriendsFrame.xml` loads off the chain and our transcription is deleted — its twelve dialogs re-homed, `FRIENDLIST_SHOW` fired as the answer to an explicit `ShowFriends()`, and `Get`/`SetLookingForGroup` a client-side flag until their bodies are carved; `UnitPopup.xml` loads off the chain, our row catalogue and its driver frame go, and nine verbs are built for it — `HasFullControl` off the player-control flag the reference reads at `0x51a158`, `UnitPlayerOrPetInParty`/`InRaid` over a new unit owner, and the six pet one-shots, each pressing the bar's own reaction or command slot word through the same latch and `CMSG_PET_ACTION`, confirmed by wow-re's carve; the Guild Leave/Promote confirms 1958 believed nothing registered were our social file's all along, and the duplicate went with it (1958, 1959)
- the trainer loads on demand: our `TrainerFrame.xml` is deleted and no row replaces it — `Blizzard_TrainerUI` loads off the player's own chain through the reference's `ClassTrainerFrame_LoadUI`, and the LoadOnDemand `Blizzard_*` addons are registry rows read off the chain; the timing IS the mechanism: the list builder writes the state-filter mask to 3 on every `SMSG_TRAINER_LIST` (`0x4d75d9`) and the stock addon pushes its saved filters exactly once, on its own ADDON_LOADED — which fires on the session's first trainer, after that packet, so the saved filters win that open and an eager load would never have applied them; the loader holds an anchor to a sibling not built yet and applies it once the subtree exists; and `UNIT_FLAGS` fires only on a change between two snapshots — wow-re's trio found the per-field watch bridge runs no notify pass on the create leg, correcting 1953 (1957)
- the additive window, built and withdrawn the same hour: under `BLEND_ADD` every additive particle draw commutes with every other, so same-key additive draws can be regrouped exactly — built in the effect lane's merge walk and measured on the crowd rig (Stormwind, forty partybots): the adjacency merge already folds about half the effect items and the regroup found two more folds in a frame of ninety-three, so the pre-regroup walk is what runs; what stays: an `fx=` items/merged census on the FPS probe line, a `DRESS_PART` line per drawn part of the self body on the dress census, and the regression the A/B uncovered: 1948's stock chat migration dropped the drain's plain-line sender, so every `WOW_PROBE_CHAT` rig — the crowd rig's forty `.partybot add`s among them — had gone silent on the server; a probe's slash-less line reaches the wire as SAY again (1955)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:57:24 +07:00

837 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 [
"Interface\\FrameXML\\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",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua", // `TEXT`, read at file scope below
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\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",
"Interface\\FrameXML\\FriendsFrame.xml",
// Declares ChatFrameEditBox, which the rename dialog's OnHide refocuses (1960).
"Interface\\FrameXML\\FloatingChatFrame.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 + Target + Cancel — the reference's FRIEND menu with its two
// guild rows hidden by their own predicates (no guild, no guild frame). Our transcription
// had deferred Target; the stock row is `TargetByName`, which exists (1958).
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
5,
"title + Whisper + Invite + Target + 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): the stock
// `ChatFrame_SendTell` puts the box into WHISPER mode at the name and opens it (1960).
s.run(r#"SetItemRef("player:Carol", "|Hplayer:Carol|h[Carol]|h", "LeftButton")"#)
.unwrap();
s.tick(0.05);
assert_eq!(
s.eval::<(String, String)>("return ChatFrameEditBox.chatType, ChatFrameEditBox.tellTarget")
.unwrap(),
("WHISPER".to_string(), "Carol".to_string()),
"left-click a chat name opens a whisper"
);
assert!(
s.eval::<bool>("return ChatFrameEditBox:IsVisible()")
.unwrap(),
"and the box is open"
);
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. What joins the popup prefix is `UiPanels.xml` — the StaticPopup
/// engine, because two of the four rows go behind a dialog, and since 1953 where the pet arc's
/// three dialogs are registered (they rode our pet-bar file until it became the reference's).
/// `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 [
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"MoneyFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua", // `TEXT`, read at file scope below
"Interface\\FrameXML\\StaticPopup.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\UIMenu.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\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",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// Declares ChatFrameEditBox, which the rename dialog's OnHide refocuses (1960).
"Interface\\FrameXML\\FloatingChatFrame.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());
}
/// 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();
}