- 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>
837 lines
33 KiB
Rust
837 lines
33 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 [
|
||
"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();
|
||
}
|