- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938) - a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943) - a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942) - the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
956 lines
36 KiB
Rust
956 lines
36 KiB
Rust
//! The unit right-click popups reached the two ways a solo player invites someone (director
|
||
//! report, 2026-07-17): right-clicking a **target** unit frame, and right-clicking a **chat
|
||
//! name**. Both drive the shared UnitPopup engine (`UnitPopup.xml`); the chat path adds the
|
||
//! `SetItemRef` player branch — the reference's own since 1751 window 6 — and the FRIEND dropdown
|
||
//! it opens, which lives in `FriendsFrame.xml` where 1.12 keeps it. Proven serverless through the
|
||
//! real hit/route paths.
|
||
|
||
use benilla_ui::script::{FollowRequest, PartyRequest, UiScript, UnitState};
|
||
|
||
use super::test_ui::load_ui as load_xml;
|
||
|
||
/// The production prefix the popups need, in `benilla.toc`'s own order: the dropdown kit, the
|
||
/// unit popups, the reference's chat-link router, the unit frames — and `FriendsFrame.xml`, which
|
||
/// is where `FriendsFrame_ShowDropdown` and the `FriendsDropDown` host live in 1.12 and where they
|
||
/// live here since window 6 (our ItemRef.xml used to carry a private second copy of both).
|
||
fn load_popup_frames(s: &UiScript) {
|
||
for file in [
|
||
"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",
|
||
"GameTooltip.xml",
|
||
"Interface\\FrameXML\\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",
|
||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||
r"Interface\FrameXML\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 [
|
||
"Interface\\FrameXML\\Fonts.xml",
|
||
"UIParent.xml",
|
||
"MoneyFrame.xml",
|
||
"UiPanels.xml",
|
||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||
"GameTooltip.xml",
|
||
"Interface\\FrameXML\\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",
|
||
"Interface\\FrameXML\\ActionButtonTemplate.xml",
|
||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||
"Interface\\FrameXML\\MainMenuBar.xml",
|
||
"Interface\\FrameXML\\ActionBarFrame.xml",
|
||
"Interface\\FrameXML\\BonusActionBarFrame.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();
|
||
}
|