Files
benilla-octo/crates/benilla-app/src/ui_script/unit_popup_tests.rs
T
SamandClaude Fable 5.1 7e3a67a02e the unit frames are the reference's own, and the water has motes in it
- 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>
2026-09-02 17:04:32 +07:00

949 lines
36 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 [
"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();
}