//! 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::("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::("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::("return DropDownList1.numButtons").unwrap(), 4, "title + Whisper + Invite + Cancel" ); assert_eq!( s.eval::("return DropDownList1Button1:GetText()") .unwrap(), "Bob", "the clicked name titles the menu" ); assert_eq!( s.eval::("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::("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::("return DropDownList1.numButtons").unwrap(), 8, "title + Whisper + Inspect + Invite + Trade + Follow + Duel + Cancel" ); assert_eq!( s.eval::("return DropDownList1Button3:GetText()") .unwrap(), "Inspect" ); assert_eq!( s.eval::("return DropDownList1Button4:GetText()") .unwrap(), "Invite" ); assert_eq!( s.eval::("return DropDownList1Button5:GetText()") .unwrap(), "Trade" ); assert_eq!( s.eval::("return DropDownList1Button6:GetText()") .unwrap(), "Follow" ); assert_eq!( s.eval::("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::("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::("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::("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::("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 /// `` 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** `` 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::("return DropDownList1:IsVisible()").unwrap(), "right-clicking the pet frame opens the PET menu" ); assert_eq!( s.eval::("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::(&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::("return DropDownList1.numButtons").unwrap(), 4, "title + Pet Details + Abandon + Cancel" ); assert_eq!( s.eval::("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::("return DropDownList1.numButtons").unwrap(), 3, "title + Dismiss + Cancel" ); assert_eq!( s.eval::("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::("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::("return StaticPopup1:IsVisible()").unwrap(), "Abandon opens the ABANDON_PET confirm" ); assert_eq!( s.eval::("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::("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::("return StaticPopup1Text:GetText()") .unwrap(), "Enter desired name of pet:" ); assert!( s.eval::("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::::new(), "accepting the NAME dialog sends nothing — it only asks again" ); assert!( s.eval::("return StaticPopup2:IsVisible()").unwrap(), "the confirm opens in the second instance, while the first is still up" ); assert_eq!( s.eval::("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::("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::("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::("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::("return DropDownList1:IsVisible()").unwrap(), "the menu opened" ); assert!( s.eval::("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::("return BENILLA_TEST_POPUP_TICKS").unwrap() > 0, "an open menu ticks the enable pass" ); assert!( !s.eval::("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::("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::("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(); }