From 3a89f825bf106c07808e7dd2bbc6fc391211f940 Mon Sep 17 00:00:00 2001 From: grom Date: Sat, 31 Oct 2026 12:09:51 -0600 Subject: [PATCH] The realm ships its own ArenaFrame, so our stand-in only shadowed it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CharacterFrameAdapters.xml` existed on one premise: "There is no `ArenaFrame.lua` or `.xml` anywhere in the player's chain". That is no longer true — the realm's FrameXML carries `Turtle_ArenaUI\Turtle_ArenaFrame.xml` (toc rows 106-110, which `benilla.toc` already loads at 270-274), and it defines `ArenaFrame` with `enableMouse="true"` and `id="5"`, sharing the PvP tab rather than inventing a sixth. Our empty stub loaded 100 rows earlier and was overwritten, and wherever it loaded last it replaced the realm's real arena window with a frame that has no content. `frame_flag_gate` had both halves of that pinned as divergences (mouse false, id 6). Twelve more red UI tests, each one a file the realm rewrote under an assertion written against stock 1.12: - `inspect_tests` (4): all four died in `InspectArenaFrame.xml`'s own OnLoad, which indexes `_G[this:GetName().."Text"]` — a child of `ArenaTeamTemplate`, which lives in FrameXML and so is always there live. The rotate buttons are `InspectModelFrame…`, not stock's `InspectModel…`. - `character_tests` (4): `HonorLevelText` keeps its authored placeholder because the realm commented that `SetText` out and hides the string instead (`PaperDollFrame.lua:126-127`); `PaperDollFrame.xml` builds 77 frames, not 75, the two being the realm's player-titles dropdown and its template's `$parentButton` (read off `FrameXML_Debug(1)`'s own trace, which also proved each of the 77 is created exactly once — a grown file, not a double load); `CHARACTERFRAME_SUBFRAMES` has six names; and one sound at load is the reference's own — `UIDropDownMenu_Initialize` runs its init function on the spot and `UnitPopup_ShowMenu` ends in `PlaySound("igMainMenuOpen")`. - `loot_tests` (4): same load-time blip, drained and named; and the master-loot menu is rewritten end to end — the party arm drops any candidate it cannot resolve to a party unit, the raid arm groups by class with `hasArrow` submenus instead of by subgroup, and the event is now a toggle whose memory `DropDownList1`'s own OnHide clears. It also needs `MAX_RAID_MEMBERS`, so the loot window's prereq row names `RaidFrame.lua`. - `dressup_tests` (2): `CharacterFrame_OnShow` gsubs `UnitPVPName("player")` unguarded, so the fixture's player needs the name a real one always has. The one accepted flag difference is now written down with its reason: our options window keeps 1.12's `toplevel` where the realm's rewrite of `OptionsFrame.xml` took `movable` instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn --- .../assets/ui/CharacterFrameAdapters.xml | 16 -- crates/benilla-app/assets/ui/OptionsFrame.xml | 4 +- crates/benilla-app/assets/ui/benilla.toc | 7 +- .../src/ui_script/character_tests.rs | 52 ++++- .../src/ui_script/dressup_tests.rs | 4 + .../src/ui_script/frame_flag_gate.rs | 7 +- .../src/ui_script/inspect_tests.rs | 26 ++- .../benilla-app/src/ui_script/loot_tests.rs | 213 ++++++++++++++++-- .../src/ui_script/shipped_xml_tests.rs | 4 - crates/benilla-app/src/ui_script/test_ui.rs | 21 +- 10 files changed, 295 insertions(+), 59 deletions(-) delete mode 100644 crates/benilla-app/assets/ui/CharacterFrameAdapters.xml diff --git a/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml b/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml deleted file mode 100644 index 7c4e2419..00000000 --- a/crates/benilla-app/assets/ui/CharacterFrameAdapters.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 90009c46..e283e8e4 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -2672,7 +2672,9 @@ + toplevel as 1.12's OptionsFrame.xml:5. The realm's rewrite of that file dropped the flag + and made its window `movable` instead; ours is pinned at CENTER, so it keeps the flag. + That difference is the one entry in `frame_flag_gate`'s KNOWN table. --> diff --git a/crates/benilla-app/assets/ui/benilla.toc b/crates/benilla-app/assets/ui/benilla.toc index 4e035470..b077e478 100644 --- a/crates/benilla-app/assets/ui/benilla.toc +++ b/crates/benilla-app/assets/ui/benilla.toc @@ -159,8 +159,6 @@ Interface\FrameXML\ReputationFrame.xml Interface\FrameXML\SkillFrame.xml # Brings HonorFrameTemplates.xml, whose row templates `Blizzard_InspectUI` inherits. Interface\FrameXML\HonorFrame.xml -# Ours: the sixth tab page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped. -CharacterFrameAdapters.xml Interface\FrameXML\DressUpFrame.xml # Deliberately absent, the chain's `StatsFrame.xml` would sit here (its `FrameXML.toc` 60): the # debug-build frame-stat overlay, `hidden="true"` with an OnUpdate that is the only reader of @@ -267,6 +265,11 @@ Interface\FrameXML\SpellChatLinks.lua # reading `$parentText`, `$parentDetails` and nine `$parentDetails*` children by name — without # that row the inspect window raised four times per inspect, three OnLoads and # `InspectArenaFrame_OnShow`. +# +# `Turtle_ArenaFrame.xml` is also the sixth page the realm's `CharacterFrame.lua` appends to +# `CHARACTERFRAME_SUBFRAMES` (`ArenaFrame`, `id="5"`, sharing the PvP tab), which the window's own +# sweep hides unguarded. We shipped an empty stand-in for that name until 2026-10-31; the realm's +# file defines it, so the stand-in only shadowed the real window. Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUI.lua Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaFrame.xml diff --git a/crates/benilla-app/src/ui_script/character_tests.rs b/crates/benilla-app/src/ui_script/character_tests.rs index 121d4d7d..85b8fe05 100644 --- a/crates/benilla-app/src/ui_script/character_tests.rs +++ b/crates/benilla-app/src/ui_script/character_tests.rs @@ -113,6 +113,12 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState { } /// A frame count fingerprints the player's own file, not a target: it moves only when that does. +/// +/// 77, not stock 1.12's 75: the realm added its player-titles control, which is two frames — +/// `PaperDollFrameTitlesDropdown` (`PaperDollFrame.xml:761`, a `UIDropDownMenuTemplate` button) and +/// the `$parentButton` that template brings. Read off the loader's own `-- Creating %s named %s` +/// trace with `FrameXML_Debug(1)`, which also showed every one of the 77 created exactly once — +/// the check that tells a grown file from a double load. #[test] fn shipped_character_frame_loads_clean() { let _data = benilla_formats::wow_data_or_skip!(); @@ -129,7 +135,7 @@ fn shipped_character_frame_loads_clean() { ); assert_eq!( counts.get("Interface\\FrameXML\\PaperDollFrame.xml"), - Some(&75), + Some(&77), "the reference's own PaperDollFrame.xml" ); } @@ -151,9 +157,15 @@ fn shipped_character_frame_drives_end_to_end() { s.set_player_combat_stats(Some(combat_stats())); s.set_inventory_slots(inventory_with_head_item()); - assert!( - s.take_sounds().is_empty(), - "no sound at load (never transitions)" + // One sound at load, and it is the reference's own: `PlayerFrameDropDown_OnLoad` calls + // `UIDropDownMenu_Initialize(this, …, "MENU")`, which runs the init function on the spot + // (`UIDropDownMenu.lua:51-54`, "Set the initialize function and call it"), and that function is + // `UnitPopup_ShowMenu`, whose last line is `PlaySound("igMainMenuOpen")` + // (`UnitPopup.lua:295`). The menu blip at UI load is 1.12's, not a transition of ours. + assert_eq!( + s.take_sounds(), + vec![SoundRequest::KitName("igMainMenuOpen".into())], + "the load-time menu blip, and nothing else (no panel transition)" ); assert!(!s.eval::("return CharacterFrame:IsVisible()").unwrap()); @@ -393,10 +405,18 @@ fn level_line_reads_the_snapshot_and_repaints_on_unit_level() { .unwrap(), "Level 12 Night Elf Warrior" ); - // Written by the same call, which is why `HonorFrame.xml` is in the load list. + // The honor copy of the line is NOT written: the realm commented that `SetText` out and + // replaced it with `HonorLevelText:Hide()` (`PaperDollFrame.lua:126-127`), so the string keeps + // `HonorFrame.xml:60`'s authored placeholder and stays down. `HonorFrame.xml` is still in the + // load list because `PaperDollFrame_SetLevel` indexes the global unguarded. assert_eq!( s.eval::("return HonorLevelText:GetText()").unwrap(), - "Level 12 Night Elf Warrior" + "Level level race class", + "the authored placeholder, untouched — the realm no longer writes this line" + ); + assert!( + !s.eval::("return HonorLevelText:IsShown()").unwrap(), + "and the realm's own `PaperDollFrame_SetLevel` hides it" ); // A level-up as the app sends it: a new snapshot, then `UNIT_LEVEL` for the player. @@ -931,11 +951,27 @@ fn a_keybind_page_switch_moves_the_tab_row_with_it() { "{page} must be in CHARACTERFRAME_SUBFRAMES" ); } + // The sixth name is the realm's own arena page, which has no tab of its own: it reuses + // Honor's `id="5"` (`Turtle_ArenaFrame.xml:3`), so `ToggleCharacter("ArenaFrame")` leaves the + // tab row on PvP, and it is in the list only so the sweep hides it with the rest. + assert_eq!( + s.eval::("return ArenaFrame:GetID()").unwrap(), + 5, + "the arena page shares the PvP tab's id" + ); + assert!( + s.eval::( + "for _, v in CHARACTERFRAME_SUBFRAMES do if v == \"ArenaFrame\" then return true end \ + end return false" + ) + .unwrap(), + "ArenaFrame must be in CHARACTERFRAME_SUBFRAMES" + ); assert_eq!( s.eval::("return getn(CHARACTERFRAME_SUBFRAMES)") .unwrap(), - 5, - "…and there is nothing past the end for a sixth page to arrive into unnoticed" + 6, + "…and there is nothing past the end for a seventh page to arrive into unnoticed" ); let selected = |s: &mut UiScript| { diff --git a/crates/benilla-app/src/ui_script/dressup_tests.rs b/crates/benilla-app/src/ui_script/dressup_tests.rs index d715b811..79a5afb2 100644 --- a/crates/benilla-app/src/ui_script/dressup_tests.rs +++ b/crates/benilla-app/src/ui_script/dressup_tests.rs @@ -100,6 +100,10 @@ fn shown_paper_doll() -> UiScript { "player", Some(UnitState { exists: true, + // The name is not decoration here: `CharacterFrame_OnShow` runs + // `gsub(UnitPVPName("player"), "\n.+", "")` unguarded (`CharacterFrame.lua:85`), and + // `gsub` raises on a nil first argument. A player who exists always has one. + name: Some("Gromgosh".into()), level: 60, race: Some("Human".into()), race_file: Some("Human".into()), diff --git a/crates/benilla-app/src/ui_script/frame_flag_gate.rs b/crates/benilla-app/src/ui_script/frame_flag_gate.rs index 5eec88f3..160aefa4 100644 --- a/crates/benilla-app/src/ui_script/frame_flag_gate.rs +++ b/crates/benilla-app/src/ui_script/frame_flag_gate.rs @@ -84,10 +84,15 @@ impl std::fmt::Display for Flag { } } -/// The accepted differences, each with its reason; empty, so any new divergence fails the gate. +/// The accepted differences, each with its reason; anything not named here fails the gate. const KNOWN: &[Known] = &[ // A parent entry is acceptable only inside `UIParent`'s tree: a seat crossing between it and // the top level is a defect, since `SetFullScreenFrame` hides `UIParent` and all below it. + Known { + frame: "BenillaOptionsFrame", + flag: Flag::Toplevel, + why: "The `Benilla` prefix pairs this with the chain's `OptionsFrame`, but the realm rewrote that file and its window took a different trade: `movable=\"true\"` and no `toplevel` (stock 1.12's `OptionsFrame.xml:5` had `toplevel=\"true\"` and no `movable`). Ours is pinned at CENTER and never dragged, so it keeps the 1.12 flag it was built from: clicking it raises it inside HIGH, which is what a window the game menu opens over everything else needs.", + }, ]; /// Every stock FrameXML document off the player's chain, in `FrameXML.toc` order with each diff --git a/crates/benilla-app/src/ui_script/inspect_tests.rs b/crates/benilla-app/src/ui_script/inspect_tests.rs index 3494da2a..cd3d628f 100644 --- a/crates/benilla-app/src/ui_script/inspect_tests.rs +++ b/crates/benilla-app/src/ui_script/inspect_tests.rs @@ -114,6 +114,15 @@ fn armed() -> UiScript { // `ItemButtonTemplate`, the honor page the honor row templates `HonorFrame.xml` brings. load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml"); + // `ArenaTeamTemplate`, which the realm's own `InspectArenaFrame.xml` inherits for its three + // team buttons. It lives in FrameXML (`Turtle_ArenaUI\Turtle_ArenaUITemplates.xml`, toc row + // 107), so the live client always has it before a LoadOnDemand addon runs; without it the + // `inherits=` resolves to nothing, `$parentText` is never built, and the button's own OnLoad + // raises on `_G[this:GetName().."Text"]`. + load_xml( + &s, + r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml", + ); // A LoadOnDemand addon, seated off the chain as a registry row and loaded by the stock // `InspectFrame_LoadUI` (`UIParent.lua:170`), which `InspectUnit` calls (`UIParent.lua:223`). super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI"); @@ -146,6 +155,15 @@ fn shipped_inspect_frame_loads_clean() { load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml"); load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml"); load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml"); + // `ArenaTeamTemplate`, which the realm's own `InspectArenaFrame.xml` inherits for its three + // team buttons. It lives in FrameXML (`Turtle_ArenaUI\Turtle_ArenaUITemplates.xml`, toc row + // 107), so the live client always has it before a LoadOnDemand addon runs; without it the + // `inherits=` resolves to nothing, `$parentText` is never built, and the button's own OnLoad + // raises on `_G[this:GetName().."Text"]`. + load_xml( + &s, + r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml", + ); super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI"); s.run("InspectFrame_LoadUI()").unwrap(); // Each slot carries its `GetInventorySlotInfo` id (1..=19, no ammo slot) as its frame ID, set @@ -390,14 +408,16 @@ fn shipped_inspect_frame_drives_end_to_end() { ); // Rotate: left subtracts 0.03 from the booth yaw and right adds it, each click with its sound. - s.run("InspectModelRotateLeftButton:Click()").unwrap(); + // The realm's own `InspectPaperDollFrame.xml:159` names these `InspectModelFrame…`, not stock + // 1.12's `InspectModel…` — it rewrote the file, so the stock name is nil here and live. + s.run("InspectModelFrameRotateLeftButton:Click()").unwrap(); assert!( (s.model_pane_facing("InspectModelFrame") - 0.58).abs() < 0.0001, "rotate-left subtracts 0.03, got {}", s.model_pane_facing("InspectModelFrame") ); - s.run("InspectModelRotateRightButton:Click()").unwrap(); - s.run("InspectModelRotateRightButton:Click()").unwrap(); + s.run("InspectModelFrameRotateRightButton:Click()").unwrap(); + s.run("InspectModelFrameRotateRightButton:Click()").unwrap(); assert!( (s.model_pane_facing("InspectModelFrame") - 0.64).abs() < 0.0001, "rotate-right adds 0.03, got {}", diff --git a/crates/benilla-app/src/ui_script/loot_tests.rs b/crates/benilla-app/src/ui_script/loot_tests.rs index 240d47f0..f209d108 100644 --- a/crates/benilla-app/src/ui_script/loot_tests.rs +++ b/crates/benilla-app/src/ui_script/loot_tests.rs @@ -254,6 +254,21 @@ fn shipped_loot_frame_drives_end_to_end() { assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } +/// The load-time menu blips this file set makes, drained and checked. +/// +/// Not ours and not a defect: every `UIDropDownMenu_Initialize(frame, init, "MENU")` runs its init +/// function on the spot (`UIDropDownMenu.lua:51-54`, "Set the initialize function and call it"), +/// and each unit-popup init ends in `PlaySound("igMainMenuOpen")` (`UnitPopup.lua:295`). The +/// reference plays one per such dropdown as the UI loads, which is why a `/reload` blips. +fn drain_load_blips(s: &mut UiScript) { + let blips = s.take_sounds(); + let menu_open = SoundRequest::KitName("igMainMenuOpen".into()); + assert!( + blips.iter().all(|k| *k == menu_open), + "only the load-time menu blips belong here, got {blips:?}" + ); +} + /// Only an empty loot plays a Lua sound, `LOOTWINDOWOPENEMPTY` (LootFrame.lua:135-136). #[test] fn loot_empty_roll_plays_the_empty_open_kit() { @@ -264,6 +279,7 @@ fn loot_empty_roll_plays_the_empty_open_kit() { load_xml(&s, f); } load_xml(&s, "Interface\\FrameXML\\LootFrame.xml"); + drain_load_blips(&mut s); s.set_loot(Some(coin_and_two_items())); s.fire_event("LOOT_OPENED", vec![]); @@ -301,6 +317,7 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() { load_xml(&s, f); } load_xml(&s, "Interface\\FrameXML\\LootFrame.xml"); + drain_load_blips(&mut s); let has_icon = |quads: &[ExtractedQuad], needle: &str| { quads.iter().any(|q| { matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)) @@ -673,15 +690,54 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() { /// Master loot: an item at or above `MASTER_LOOT_THREHOLD` (4, epic; the misspelling is the /// reference's, LootFrame.lua:3) asks `CONFIRM_LOOT_DISTRIBUTION` before it is given. +/// +/// The realm rewrote this menu. Its party arm does not simply list `GetMasterLootCandidate(i)` +/// the way stock 1.12 did: it resolves each candidate back to `"player"` or a `"party"..j` token +/// and **drops any candidate it cannot** (`LootFrame.lua:317-349`), because it colours the row by +/// `RAID_CLASS_COLORS[class]` off that unit's own `UnitClass`. So a party with the candidates in +/// it is not decoration here — without one the menu has no rows at all and never opens. #[test] fn shipped_loot_frame_hands_a_master_row_to_a_candidate() { benilla_formats::wow_data_or_skip!(); + use benilla_ui::script::{PartyMemberInfo, PartyState, UnitState}; + let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); for f in super::test_ui::LOOT_UI { load_xml(&s, f); } load_xml(&s, "Interface\\FrameXML\\LootFrame.xml"); + drain_load_blips(&mut s); + + // `GetNumPartyMembers() > 0` picks the party arm (`LootFrame.lua:317`). + s.set_party(PartyState { + members: vec![ + PartyMemberInfo { + name: "Thrall".into(), + guid: 0x11, + }, + PartyMemberInfo { + name: "Cairne".into(), + guid: 0x12, + }, + ], + loot_method: "master".into(), + master_looter: Some(0), + loot_threshold: 2, + ..PartyState::default() + }); + let member = |name: &str, class: &str, token: &str| UnitState { + exists: true, + name: Some(name.into()), + is_player: true, + player_controlled: true, + class: Some(class.into()), + class_file: Some(token.into()), + ..UnitState::default() + }; + s.set_unit("player", Some(member("Gromgosh", "Warrior", "WARRIOR"))); + s.set_unit("party1", Some(member("Thrall", "Shaman", "SHAMAN"))); + s.set_unit("party2", Some(member("Cairne", "Druid", "DRUID"))); let row = |name: &str, quality: u32| { Some(LootRow { @@ -793,24 +849,60 @@ fn shipped_loot_frame_hands_a_master_row_to_a_candidate() { "row 2 to candidate 1, only after the accept" ); - // A row click hides a standing confirmation (LootFrame.lua:156). + // A row click hides a standing confirmation (LootFrame.lua:156). The confirmation needs the + // epic row, so the menu re-opens on row 2 first — and this runs while the menu is closed, + // because an open `DropDownList1` sits over the loot rows and takes the click itself. + click(&mut s, "LootButton2"); + let _ = s.take_loot_picks(); s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]); s.resolve(); click(&mut s, "DropDownList1Button1"); assert!(s.eval::("return StaticPopup1:IsVisible()").unwrap()); + let _ = s.take_loot_master_gives(); click(&mut s, "LootButton1"); assert!( !s.eval::("return StaticPopup1:IsVisible()").unwrap(), "clicking another row closes the standing confirmation" ); + + // The realm made the event a toggle (`LootFrame.lua:102-109`): it remembers which row the + // open was for and, asked again for that same row, only hides the list. The memory is not + // sticky — `LootFrame_OnLoad` hooks `DropDownList1`'s own OnHide to clear it + // (`LootFrame.lua:38-42`) — so it closes only while the menu is still up, and the ask after + // any close opens again. Stock 1.12 had neither the memory nor the hook. + click(&mut s, "LootButton1"); + let _ = s.take_loot_picks(); + s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]); + s.resolve(); + assert!( + s.eval::("return DropDownList1:IsVisible()").unwrap(), + "the first ask for this row opens it" + ); + s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]); + s.resolve(); + assert!( + !s.eval::("return DropDownList1:IsVisible()").unwrap(), + "the same row again, menu still up: the second ask only hides it" + ); + s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]); + s.resolve(); + assert!( + s.eval::("return DropDownList1:IsVisible()").unwrap(), + "…and the hide cleared the memory, so the next ask opens again" + ); } -/// In a raid each candidate sits in its subgroup's five-slot block, and the menu keeps a "Group N" -/// row only for an occupied block (LootFrame.lua:197-213). +/// In a raid the realm's menu is grouped by **class**, not by subgroup: a `GIVE_LOOT` title, then +/// one arrowed row per class with anyone in it, in `DropDownSorted`'s fixed order, each opening a +/// level-2 list of that class's players (`LootFrame.lua:268-316`). +/// +/// Stock 1.12 listed a "Group N" row per occupied five-slot block; the realm replaced that whole +/// arm, and `LootFrame_UpdateRoster` (`:120-153`) is what fills the rows — off `GetRaidRosterInfo` +/// for the name and `UnitClass("raid"..i)` for the class, so both have to be seated. #[test] -fn the_master_loot_menu_groups_raid_candidates_by_subgroup() { +fn the_master_loot_menu_groups_raid_candidates_by_class() { benilla_formats::wow_data_or_skip!(); - use benilla_ui::script::{PartyState, RaidMemberInfo}; + use benilla_ui::script::{PartyState, RaidMemberInfo, UnitState}; let mut s = UiScript::new().unwrap(); s.set_screen_size(1024.0, 768.0); @@ -818,17 +910,52 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() { load_xml(&s, f); } load_xml(&s, "Interface\\FrameXML\\LootFrame.xml"); + drain_load_blips(&mut s); - // A raid: `GetNumRaidMembers() > 0` is the nested arm's whole gate (LootFrame.lua:189). + // A raid: `GetNumRaidMembers() > 0` is the raid arm's whole gate (`LootFrame.lua:293`), and + // the roster walk reads one row per member, so the vector's length is the member count. + let roster = [ + ("Thrall", "Shaman", "SHAMAN"), + ("Cairne", "Druid", "DRUID"), + ("Sylvanas", "Rogue", "ROGUE"), + ]; s.set_party(PartyState { - raid: vec![RaidMemberInfo::default(); 7], + raid: roster + .iter() + .enumerate() + .map(|(i, (name, class, token))| RaidMemberInfo { + name: (*name).into(), + guid: 0x10 + i as u64, + subgroup: 0, + level: 60, + class: Some((*class).into()), + class_file: Some((*token).into()), + online: true, + ..RaidMemberInfo::default() + }) + .collect(), loot_method: "master".into(), master_looter: Some(0), loot_threshold: 2, ..PartyState::default() }); + // `UnitClass("raid"..i)`, which is where the roster takes the class from — not the roster row. + for (i, (name, class, token)) in roster.iter().enumerate() { + s.set_unit( + &format!("raid{}", i + 1), + Some(UnitState { + exists: true, + name: Some((*name).into()), + is_player: true, + player_controlled: true, + class: Some((*class).into()), + class_file: Some((*token).into()), + ..UnitState::default() + }), + ); + } - // Subgroup 1 holds slots 1-2 and subgroup 3 slot 11, the rest holes, as the client files them. + // The candidate slots the server filled, with the holes the client files them into. let mut candidates = vec![None; 12]; candidates[0] = Some("Thrall".to_string()); candidates[1] = Some("Cairne".to_string()); @@ -865,21 +992,67 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() { "Give Loot To:", "the raid arm opens with the title row the party arm has no such thing for" ); - assert_eq!( - s.eval::("return DropDownList1Button2:GetText()") + // `DropDownSorted`'s order (`LootFrame.lua:267`), not the roster's: Druid, Rogue, Shaman. + for (button, class) in [(2, "Druid"), (3, "Rogue"), (4, "Shaman")] { + assert_eq!( + s.eval::(&format!("return DropDownList1Button{button}:GetText()")) + .unwrap(), + class, + "button {button} is the localized class name, in DropDownSorted order" + ); + assert!( + s.eval::(&format!( + "return DropDownList1Button{button}ExpandArrow:IsShown()" + )) .unwrap(), - "Group 1" - ); - assert_eq!( - s.eval::("return DropDownList1Button3:GetText()") - .unwrap(), - "Group 3", - "the empty group 2 block contributes no row — and group 3 is NOT relabelled 2" - ); + "a class row carries the submenu arrow (`info.hasArrow`, shown at \ + `UIDropDownMenu.lua:265-266`)" + ); + } assert_eq!( s.eval::("return DropDownList1.numButtons").unwrap(), - 3, - "title + two occupied groups, out of eight blocks" + 4, + "title + one row per class with anyone in it, out of the nine DropDownSorted names" + ); + + // Level 2: the class row's own players, each carrying its candidate index as the value the + // give reads (`LootFrame.lua:271-291`). + // The submenu is the **arrow's** own OnEnter, not the row's + // (`UIDropDownMenuTemplates.xml:100-102`: `$parentExpandArrow` hovers into + // `ToggleDropDownMenu(level + 1, this:GetParent().value)`). + s.resolve(); + let (ax, ay) = s + .eval::<(f64, f64)>("return DropDownList1Button4ExpandArrow:GetCenter()") + .unwrap(); + s.mouse_move(ax as f32, ay as f32); + s.resolve(); + assert!( + s.eval::("return DropDownList2:IsVisible()").unwrap(), + "hovering the class row's arrow opens level 2" + ); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert_eq!( + s.eval::("return DropDownList2Button1:GetText()") + .unwrap(), + "Thrall" + ); + assert_eq!( + s.eval::("return DropDownList2.numButtons").unwrap(), + 1, + "one shaman in the raid, one row" + ); + // Thrall is candidate 1, so the click gives slot 1 to candidate 1. + let (bx, by) = s + .eval::<(f64, f64)>("return DropDownList2Button1:GetCenter()") + .unwrap(); + s.mouse_button(bx as f32, by as f32, "LeftButton", true); + s.mouse_button(bx as f32, by as f32, "LeftButton", false); + s.resolve(); + assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); + assert_eq!( + s.take_loot_master_gives(), + vec![(1, 1)], + "row 1 to the candidate index the submenu row carried" ); } diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs index ae02e069..ec401f6e 100644 --- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs +++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs @@ -1957,10 +1957,6 @@ fn assets_ui_does_not_grow() { // falls through into `attempt to call local 'handledata'`. There is no stock file to // migrate — AIO is the realm's, not Blizzard's. The file's own header has the trace. "AIOHandlers.lua", - // An `ArenaFrame` stub, the one name TurtleWoW's patched `CharacterFrame.lua` appends to - // `CHARACTERFRAME_SUBFRAMES` with no file anywhere in the chain to define it; without it - // the stock tab loop raises on every tab change. The file's own header has the trace. - "CharacterFrameAdapters.xml", "ContainerFrameAdapters.xml", "GameMenuFrame.xml", "KeyBindingsPage.xml", diff --git a/crates/benilla-app/src/ui_script/test_ui.rs b/crates/benilla-app/src/ui_script/test_ui.rs index 44b1394c..cc971afa 100644 --- a/crates/benilla-app/src/ui_script/test_ui.rs +++ b/crates/benilla-app/src/ui_script/test_ui.rs @@ -123,7 +123,16 @@ const PREREQS: &[(&str, &[&str])] = &[ ), ( "lootframe.xml", - &[r"Interface\FrameXML\ItemButtonTemplate.xml"], + &[ + r"Interface\FrameXML\ItemButtonTemplate.xml", + // The `.lua` alone, for `MAX_RAID_MEMBERS` (`RaidFrame.lua:1`): the limit the realm's + // rewritten master-loot submenu counts candidates to (`LootFrame.lua:273`). A runtime + // need, not a load one — the loot window is toc row 76 and `RaidFrame.xml` is 82, so + // the live client has the constant long before a menu can open, while a harness that + // seats the loot window alone raises `'for' limit must be a number` on the first + // class-row hover. + r"Interface\FrameXML\RaidFrame.lua", + ], ), ( "tradeframe.xml", @@ -666,9 +675,13 @@ pub(super) const CHARACTER_UI: &[&str] = &[ r"Interface\FrameXML\ReputationFrame.xml", "Interface\\FrameXML\\SkillFrame.xml", "Interface\\FrameXML\\HonorFrame.xml", - // Ours: the sixth page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped. It loads - // here for the reason it does in `benilla.toc`: the subframe list is swept unguarded. - "CharacterFrameAdapters.xml", + // The sixth page TurtleWoW's `CharacterFrame.lua` lists and 1.12 never shipped. The realm + // ships it itself, in FrameXML (toc rows 106-110), and the subframe list is swept unguarded, + // so it must be seated before the window opens. The `.lua` leads: the window's `` + // calls `ArenaFrame_OnLoad`, and the templates precede the frame that inherits them. + r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUI.lua", + r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaUITemplates.xml", + r"Interface\FrameXML\Turtle_ArenaUI\Turtle_ArenaFrame.xml", ]; /// The manifest slice stock `FriendsFrame.xml` and `RaidFrame.xml` reach at load or on show, in its