- the social window's fourth tab, Raid: the eight-subgroup grid over the roster, drag a member between subgroups, Convert To Raid, promote / demote assistant, uninvite, Ready Check with its popup, and the Raid Info flyout on the new `CMSG_REQUEST_RAID_INFO` / `SMSG_RAID_INSTANCE_INFO` wire (byte-golden against vmangos, an empty answer decoding as an empty list); `raid1`..`raid40` unit tokens, and `/partytest raid` stages a roster for a solo look - an imbue pressed with an empty weapon hand says "Your weapon hand is empty" instead of raising a cursor — the attribute bit has two readers in the reference and the second is the error (corrects the day's first cut) - sounds route onto the reference's own voice buses with its per-bus caps (the 0x87ce60 table, pinned as bytes): weapon impacts and clangs share four voices, footsteps six, the attacker's grunt one, the victim's injury two — so a pack swinging together makes about a dozen voices where it made dozens Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
848 lines
34 KiB
Rust
848 lines
34 KiB
Rust
//! The social window's fourth tab — the raid pane (decision 1549, `RaidFrame.xml`): the tab, the
|
|
//! two states the pane has, the 8x5 grid's seating and colouring, the drag's three landings, the
|
|
//! row menu, the ready-check popup and the saved-instance panel.
|
|
//!
|
|
//! Its own module rather than more of `friends_tests` for `guild_tests`' reason: the pane is Lua
|
|
//! over a **raid** roster, so every test here has to push one first, and a file about the friends
|
|
//! list must not be in that business.
|
|
//!
|
|
//! What these guard that `ui_party`'s Rust tests structurally cannot: the pane is Lua over a
|
|
//! snapshot, so a row seated in the wrong slot, a colour rule inverted, a drag wired to the wrong
|
|
//! verb, or a menu row shown to the wrong person are all invisible there and green in the parse
|
|
//! sweep. Each test below fails on exactly one of those.
|
|
|
|
use benilla_ui::script::{
|
|
PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo, UiScript,
|
|
};
|
|
|
|
/// Load one shipped `assets/ui/<file>`, panicking on any loader error **or unknown-template
|
|
/// warning** — `friends_tests::load_xml`'s reason verbatim: `inherits=` a template this house does
|
|
/// not ship is a warning, the frame still loads, and every behavioural test stays green while the
|
|
/// art is missing.
|
|
fn load_xml(s: &UiScript, file: &str) {
|
|
let text = std::fs::read_to_string(
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("assets/ui")
|
|
.join(file),
|
|
)
|
|
.unwrap();
|
|
let doc = benilla_ui::framexml::parse(&text).unwrap();
|
|
let report = benilla_ui::loader::load(s, &doc, &|_| None);
|
|
assert!(
|
|
report.errors.is_empty(),
|
|
"{file}: loader errors: {:?}",
|
|
report.errors
|
|
);
|
|
let missing: Vec<&String> = report
|
|
.warnings
|
|
.iter()
|
|
.filter(|w| w.contains("unknown template"))
|
|
.collect();
|
|
assert!(
|
|
missing.is_empty(),
|
|
"{file}: inherits a template this house does not ship (the frame loads, its ART does \
|
|
not): {missing:?}"
|
|
);
|
|
}
|
|
|
|
/// The window's slice of the manifest, in `load_default_ui` order. `UIParent.xml` is in it for
|
|
/// two functions the pane really calls — `MouseIsOver` (the drag's hover sweep) and
|
|
/// `SecondsToTime` (the lockout rows) — and for the `READY_CHECK` arm that opens the popup.
|
|
fn setup() -> UiScript {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_screen_size(1024.0, 768.0);
|
|
load_xml(&s, "Fonts.xml");
|
|
load_xml(&s, "UiPanels.xml");
|
|
load_xml(&s, "UIParent.xml");
|
|
load_xml(&s, "GameTooltip.xml");
|
|
load_xml(&s, "UIDropDownMenu.xml");
|
|
load_xml(&s, "UnitPopup.xml");
|
|
load_xml(&s, "ScrollTemplates.xml");
|
|
load_xml(&s, "UIPanelTemplates.xml");
|
|
load_xml(&s, "FriendsFrame.xml");
|
|
load_xml(&s, "RaidFrame.xml");
|
|
s
|
|
}
|
|
|
|
fn visible(s: &UiScript, frame: &str) -> bool {
|
|
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
|
|
.unwrap()
|
|
}
|
|
|
|
fn text_of(s: &UiScript, region: &str) -> String {
|
|
s.eval::<String>(&format!("return {region}:GetText() or \"\""))
|
|
.unwrap()
|
|
}
|
|
|
|
/// One raid row. `rank` is 2 leader / 1 assistant / 0 member; `subgroup` is the **1-based** number
|
|
/// the pane shows, which the record stores 0-based — the binding is what adds the one
|
|
/// (`RaidMemberInfo::subgroup`'s own doc, the reference's `0x4bb61a inc`). Converting here rather
|
|
/// than at every call site is the point: a fixture that hands the record a 1-based number seats
|
|
/// every row one group to the right, which looks exactly like a seating bug in the pane.
|
|
fn row(
|
|
name: &str,
|
|
rank: u32,
|
|
subgroup: u32,
|
|
class: &str,
|
|
online: bool,
|
|
dead: bool,
|
|
) -> RaidMemberInfo {
|
|
RaidMemberInfo {
|
|
name: name.to_string(),
|
|
guid: 0xF000 + u64::from(subgroup) * 16 + name.len() as u64,
|
|
rank,
|
|
subgroup: subgroup - 1,
|
|
level: 60,
|
|
class: Some(class.to_string()),
|
|
class_file: Some(class.to_uppercase()),
|
|
zone: Some("Molten Core".to_string()),
|
|
online,
|
|
ninth: dead,
|
|
}
|
|
}
|
|
|
|
/// Push a roster and fire the event that follows it, exactly as `ui_party::feed_party` does.
|
|
fn push_raid(s: &mut UiScript, raid: Vec<RaidMemberInfo>) {
|
|
// `members` is our own SUBGROUP's slice — non-empty is what makes `IsPartyLeader()`/
|
|
// `IsRaidLeader()` answer 1 for leader_index 0 (the shared `leads_the_group` predicate).
|
|
let members = raid
|
|
.iter()
|
|
.skip(1)
|
|
.take(4)
|
|
.map(|r| PartyMemberInfo {
|
|
name: r.name.clone(),
|
|
guid: r.guid,
|
|
})
|
|
.collect();
|
|
s.set_party(PartyState {
|
|
members,
|
|
leader_index: 0,
|
|
raid,
|
|
loot_method: "group".into(),
|
|
master_looter: None,
|
|
loot_threshold: 2,
|
|
});
|
|
s.fire_event("RAID_ROSTER_UPDATE", Vec::new());
|
|
}
|
|
|
|
/// A 12-member raid across three subgroups, us leading — the shape most tests want.
|
|
fn twelve() -> Vec<RaidMemberInfo> {
|
|
let mut raid = vec![row("Me", 2, 1, "Warrior", true, false)];
|
|
for i in 1..12u32 {
|
|
let subgroup = i / 4 + 1;
|
|
raid.push(row(
|
|
&format!("Member{i}"),
|
|
if i == 1 { 1 } else { 0 },
|
|
subgroup,
|
|
"Priest",
|
|
true,
|
|
false,
|
|
));
|
|
}
|
|
raid
|
|
}
|
|
|
|
/// Open the tab **and drain what opening it queues**. `RaidFrame`'s OnShow calls
|
|
/// `RequestRaidInfo()` — the reference's own, and the reason the Raid Info button can ever be
|
|
/// right — so every `take_party_requests` after this would otherwise start with that ask and no
|
|
/// test about a button would be about that button.
|
|
fn open_raid_tab(s: &mut UiScript) {
|
|
s.run("ToggleFriendsFrame(4)").unwrap();
|
|
assert_eq!(
|
|
s.take_party_requests(),
|
|
vec![PartyRequest::RequestRaidInfo],
|
|
"showing the pane asks the server for our lockouts"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The tab
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Tab 4 exists, opens the pane, titles the window RAID, and puts every other subframe away.
|
|
///
|
|
/// This is the test the whole decision exists for: `FriendsFrame.xml` shipped three tabs and said
|
|
/// so in its header, and the tab strip is the one place a missing pane is visible without a raid.
|
|
#[test]
|
|
fn the_fourth_tab_opens_the_raid_pane() {
|
|
let mut s = setup();
|
|
assert_eq!(
|
|
s.eval::<i64>("return FriendsFrame.numTabs").unwrap(),
|
|
4,
|
|
"PanelTemplates_SetNumTabs says four now — it said three while the pane did not exist"
|
|
);
|
|
open_raid_tab(&mut s);
|
|
assert!(visible(&s, "RaidFrame"), "the pane opens");
|
|
assert_eq!(text_of(&s, "FriendsFrameTitleText"), "Raid");
|
|
for other in [
|
|
"FriendsListFrame",
|
|
"IgnoreListFrame",
|
|
"WhoFrame",
|
|
"GuildFrame",
|
|
] {
|
|
assert!(!visible(&s, other), "{other} goes away");
|
|
}
|
|
// And the tab's own OnClick is wired — a tab that PanelTemplates enables but that does
|
|
// nothing when clicked is exactly the dead tab the guild arc had to fix.
|
|
s.run("FriendsFrameTab1:Click()").unwrap();
|
|
assert!(!visible(&s, "RaidFrame"));
|
|
s.run("FriendsFrameTab4:Click()").unwrap();
|
|
assert!(visible(&s, "RaidFrame"), "the tab button opens it too");
|
|
}
|
|
|
|
/// Closing the window closes the Raid Info flyout with it — the ref's fourth satellite.
|
|
#[test]
|
|
fn hiding_the_window_closes_the_raid_info_panel() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
s.run("RaidInfoFrame:Show()").unwrap();
|
|
assert!(visible(&s, "RaidInfoFrame"));
|
|
s.run("HideUIPanel(FriendsFrame)").unwrap();
|
|
assert!(
|
|
!visible(&s, "RaidInfoFrame"),
|
|
"the flyout goes with the window"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The not-in-a-raid state
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Out of a raid the pane is the blurb plus Convert To Raid, and the button is live only for the
|
|
/// leader of an actual party — the three states, in order.
|
|
#[test]
|
|
fn convert_to_raid_is_live_only_for_a_party_leader() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
assert!(visible(&s, "RaidFrameRaidDescription"), "solo: the blurb");
|
|
assert!(visible(&s, "RaidFrameConvertToRaidButton"));
|
|
assert_eq!(
|
|
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
|
|
.unwrap(),
|
|
0,
|
|
"solo there is no party to convert"
|
|
);
|
|
assert!(
|
|
!visible(&s, "RaidFrameReadyCheckButton"),
|
|
"and no raid verbs"
|
|
);
|
|
assert!(!visible(&s, "RaidFrameAddMemberButton"));
|
|
|
|
// A party we lead: `leader_index == 0` with members is the shared leads-the-group predicate.
|
|
s.set_party(PartyState {
|
|
members: vec![PartyMemberInfo {
|
|
name: "Alice".into(),
|
|
guid: 0xA,
|
|
}],
|
|
leader_index: 0,
|
|
..Default::default()
|
|
});
|
|
s.fire_event("PARTY_MEMBERS_CHANGED", Vec::new());
|
|
assert_eq!(
|
|
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
|
|
.unwrap(),
|
|
1,
|
|
"a party leader can convert"
|
|
);
|
|
s.run("RaidFrameConvertToRaidButton:Click()").unwrap();
|
|
assert!(
|
|
s.take_party_requests()
|
|
.contains(&PartyRequest::ConvertToRaid),
|
|
"and the button really sends it"
|
|
);
|
|
|
|
// A party we do NOT lead.
|
|
s.set_party(PartyState {
|
|
members: vec![PartyMemberInfo {
|
|
name: "Alice".into(),
|
|
guid: 0xA,
|
|
}],
|
|
leader_index: 1,
|
|
..Default::default()
|
|
});
|
|
s.fire_event("PARTY_LEADER_CHANGED", Vec::new());
|
|
assert_eq!(
|
|
s.eval::<i64>("return RaidFrameConvertToRaidButton:IsEnabled() and 1 or 0")
|
|
.unwrap(),
|
|
0,
|
|
"a party member cannot"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The grid
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// The roster paints: each row lands in ITS SUBGROUP's next free slot, carries its rank token, and
|
|
/// rows past the roster stay hidden.
|
|
///
|
|
/// The seating is the assertion with teeth. Row order and slot order are different orders — row 5
|
|
/// of a raid whose first four are in group 1 is group 2's *first* slot — and every drag, kick and
|
|
/// menu action downstream addresses a row by the index this seating implies.
|
|
#[test]
|
|
fn the_grid_seats_each_row_in_its_own_subgroups_next_free_slot() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(&mut s, twelve());
|
|
|
|
// Groups 1-3 hold 4/4/4; the roster is 12 with us at row 1 in group 1.
|
|
assert_eq!(
|
|
s.eval::<String>("return RaidGroupButton1.slot").unwrap(),
|
|
"RaidGroup1Slot1",
|
|
"row 1 (us) takes group 1's first seat"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return RaidGroupButton5.slot").unwrap(),
|
|
"RaidGroup2Slot1",
|
|
"row 5 is the first of group 2 — NOT group 1's fifth seat"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return RaidGroupButton12.slot").unwrap(),
|
|
"RaidGroup3Slot4"
|
|
);
|
|
// The slot knows its occupant too — the link the drop code reads to decide move-vs-swap.
|
|
assert_eq!(
|
|
s.eval::<String>("return RaidGroup2Slot1.button").unwrap(),
|
|
"RaidGroupButton5"
|
|
);
|
|
|
|
assert_eq!(text_of(&s, "RaidGroupButton1Name"), "Me");
|
|
assert_eq!(
|
|
text_of(&s, "RaidGroupButton1Rank"),
|
|
"(L)",
|
|
"the leader token"
|
|
);
|
|
assert_eq!(
|
|
text_of(&s, "RaidGroupButton2Rank"),
|
|
"(A)",
|
|
"the assistant token"
|
|
);
|
|
assert_eq!(
|
|
text_of(&s, "RaidGroupButton3Rank"),
|
|
"",
|
|
"and a plain member has none"
|
|
);
|
|
assert_eq!(text_of(&s, "RaidGroupButton1Level"), "60");
|
|
|
|
assert!(visible(&s, "RaidGroupButton12"), "the last real row shows");
|
|
assert!(
|
|
!visible(&s, "RaidGroupButton13"),
|
|
"and the first empty one does not"
|
|
);
|
|
assert!(
|
|
visible(&s, "RaidGroup8"),
|
|
"all eight groups show while in a raid"
|
|
);
|
|
|
|
// Leaving the raid puts the whole grid away and brings the blurb back.
|
|
push_raid(&mut s, Vec::new());
|
|
assert!(!visible(&s, "RaidGroup1"));
|
|
assert!(!visible(&s, "RaidGroupButton1"));
|
|
assert!(visible(&s, "RaidFrameRaidDescription"));
|
|
}
|
|
|
|
/// The colour ladder: offline grey beats everything, then dead red, then the class colour — and
|
|
/// all three columns take it together.
|
|
///
|
|
/// Inverting any two of these is invisible to every other test in this file: the rows still seat,
|
|
/// the names still read, the clicks still fire.
|
|
#[test]
|
|
fn a_rows_colour_is_offline_then_dead_then_class() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(
|
|
&mut s,
|
|
vec![
|
|
row("Me", 2, 1, "Warrior", true, false),
|
|
row("Corpse", 0, 1, "Mage", true, true),
|
|
row("Gone", 0, 1, "Mage", false, false),
|
|
// Offline AND dead: grey wins, because a disconnected member's health is not news.
|
|
row("GoneDead", 0, 1, "Mage", false, true),
|
|
],
|
|
);
|
|
let color = |button: &str| {
|
|
s.eval::<(f32, f32, f32)>(&format!(
|
|
"local r, g, b = {button}Name:GetTextColor() return r, g, b"
|
|
))
|
|
.unwrap()
|
|
};
|
|
let round = |(r, g, b): (f32, f32, f32)| {
|
|
(
|
|
(r * 100.0).round() / 100.0,
|
|
(g * 100.0).round() / 100.0,
|
|
(b * 100.0).round() / 100.0,
|
|
)
|
|
};
|
|
// WARRIOR = 0.78, 0.61, 0.43 (Fonts.xml's RAID_CLASS_COLORS, the reference's own values).
|
|
let warrior: (f32, f32, f32) = s
|
|
.eval("return RAID_CLASS_COLORS.WARRIOR.r, RAID_CLASS_COLORS.WARRIOR.g, RAID_CLASS_COLORS.WARRIOR.b")
|
|
.unwrap();
|
|
assert_eq!(
|
|
round(color("RaidGroupButton1")),
|
|
round(warrior),
|
|
"alive: class colour"
|
|
);
|
|
assert_eq!(
|
|
round(color("RaidGroupButton2")),
|
|
(1.0, 0.1, 0.1),
|
|
"dead: RED_FONT_COLOR"
|
|
);
|
|
assert_eq!(
|
|
round(color("RaidGroupButton3")),
|
|
(0.5, 0.5, 0.5),
|
|
"offline: GRAY_FONT_COLOR"
|
|
);
|
|
assert_eq!(
|
|
round(color("RaidGroupButton4")),
|
|
(0.5, 0.5, 0.5),
|
|
"offline AND dead is grey — offline is tested first"
|
|
);
|
|
// The level column takes the same colour as the name, never a different one. (The CLASS
|
|
// column is a Button rather than a FontString — the reference makes it one because it is the
|
|
// class pullout's drag handle — and a Button in this engine has `SetTextColor` but no getter,
|
|
// so it cannot be read back here. `RaidGroupButton_SetRowColor` writes all three from one
|
|
// colour in three adjacent lines; these two are the readable half of that.)
|
|
assert_eq!(
|
|
round(color("RaidGroupButton2")),
|
|
round(
|
|
s.eval::<(f32, f32, f32)>(
|
|
"local r, g, b = RaidGroupButton2Level:GetTextColor() return r, g, b"
|
|
)
|
|
.unwrap()
|
|
),
|
|
"name and level share the row's colour"
|
|
);
|
|
}
|
|
|
|
/// `UNIT_HEALTH`/`UNIT_LEVEL` repaint ONE row and do not go through the roster event.
|
|
///
|
|
/// This is the reason `RAID_ROSTER_UPDATE` is fired on the roster's identity rather than on every
|
|
/// field: a 40-row rebuild per point of damage taken is what the other choice costs.
|
|
#[test]
|
|
fn a_units_health_and_level_repaint_only_that_row() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
let mut raid = twelve();
|
|
push_raid(&mut s, raid.clone());
|
|
assert_eq!(text_of(&s, "RaidGroupButton3Level"), "60");
|
|
|
|
// The row dies. Only the per-unit event is fired — no RAID_ROSTER_UPDATE.
|
|
raid[2].ninth = true;
|
|
s.set_party(PartyState {
|
|
members: vec![PartyMemberInfo {
|
|
name: "Member1".into(),
|
|
guid: raid[1].guid,
|
|
}],
|
|
leader_index: 0,
|
|
raid: raid.clone(),
|
|
..Default::default()
|
|
});
|
|
s.fire_event(
|
|
"UNIT_HEALTH",
|
|
vec![benilla_ui::script::ScriptValue::Str("raid3".into())],
|
|
);
|
|
let red = s
|
|
.eval::<f32>("local r, g = RaidGroupButton3Name:GetTextColor() return g")
|
|
.unwrap();
|
|
assert!(red < 0.2, "row 3 went red without a roster rebuild ({red})");
|
|
|
|
// A unit event for something that is not a raid token is ignored rather than mis-parsed.
|
|
s.fire_event(
|
|
"UNIT_HEALTH",
|
|
vec![benilla_ui::script::ScriptValue::Str("target".into())],
|
|
);
|
|
s.fire_event(
|
|
"UNIT_LEVEL",
|
|
vec![benilla_ui::script::ScriptValue::Str("player".into())],
|
|
);
|
|
assert_eq!(
|
|
text_of(&s, "RaidGroupButton3Level"),
|
|
"60",
|
|
"and nothing else moved"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The management buttons
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Ready Check is the leader's alone; Add Member shows for everyone in a raid. Both are hidden
|
|
/// outright out of one — they share their seat with Convert To Raid.
|
|
#[test]
|
|
fn ready_check_is_the_leaders_and_add_member_is_everyones() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(&mut s, twelve());
|
|
assert!(
|
|
visible(&s, "RaidFrameReadyCheckButton"),
|
|
"we lead, so we may ask"
|
|
);
|
|
assert!(visible(&s, "RaidFrameAddMemberButton"));
|
|
assert!(
|
|
!visible(&s, "RaidFrameConvertToRaidButton"),
|
|
"already a raid"
|
|
);
|
|
assert!(!visible(&s, "RaidFrameRaidDescription"));
|
|
|
|
s.run("RaidFrameReadyCheckButton:Click()").unwrap();
|
|
assert!(s
|
|
.take_party_requests()
|
|
.contains(&PartyRequest::ReadyCheckStart));
|
|
|
|
// A member, not the leader: no Ready Check button at all.
|
|
let raid = twelve();
|
|
s.set_party(PartyState {
|
|
members: vec![PartyMemberInfo {
|
|
name: "Member1".into(),
|
|
guid: raid[1].guid,
|
|
}],
|
|
leader_index: 1,
|
|
raid,
|
|
..Default::default()
|
|
});
|
|
s.fire_event("RAID_ROSTER_UPDATE", Vec::new());
|
|
assert!(!visible(&s, "RaidFrameReadyCheckButton"));
|
|
assert!(
|
|
visible(&s, "RaidFrameAddMemberButton"),
|
|
"but Add Member still shows"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The drag
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// The drag's three landings: an empty slot in another group MOVES, an occupied one SWAPS, and
|
|
/// anything else — including this row's own group — sends nothing and springs the row home.
|
|
///
|
|
/// Driven through the real handlers with `TARGET_RAID_SLOT` standing in for the hover sweep, which
|
|
/// needs a live cursor this harness has no way to place.
|
|
#[test]
|
|
fn dragging_a_row_moves_swaps_or_springs_back() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(&mut s, twelve());
|
|
|
|
let drag = |s: &UiScript, button: &str, onto: &str| {
|
|
s.run(&format!(
|
|
"MOVING_RAID_MEMBER = {button}; TARGET_RAID_SLOT = {onto}; \
|
|
RaidGroupButton_OnDragStop({button})"
|
|
))
|
|
.unwrap();
|
|
};
|
|
|
|
// Row 12 (group 3, seat 4) onto group 3's fifth seat — its OWN group. Nothing goes out.
|
|
drag(&s, "RaidGroupButton12", "RaidGroup3Slot5");
|
|
assert!(
|
|
s.take_party_requests().is_empty(),
|
|
"a drop inside the row's own group is not a move"
|
|
);
|
|
|
|
// Row 12 onto group 5's empty first seat — a MOVE, and the subgroup is the Lua 1-based one.
|
|
drag(&s, "RaidGroupButton12", "RaidGroup5Slot1");
|
|
assert_eq!(
|
|
s.take_party_requests(),
|
|
vec![PartyRequest::SetSubgroup {
|
|
index: 12,
|
|
group: 5
|
|
}]
|
|
);
|
|
|
|
// Row 12 onto group 1's second seat, which row 2 is in — a SWAP, by the two ROW indices.
|
|
drag(&s, "RaidGroupButton12", "RaidGroup1Slot2");
|
|
assert_eq!(
|
|
s.take_party_requests(),
|
|
vec![PartyRequest::SwapSubgroup {
|
|
index: 12,
|
|
other: 2
|
|
}]
|
|
);
|
|
}
|
|
|
|
/// A non-leader cannot drag at all — the gate is on both ends, so a drag that never started also
|
|
/// never stops.
|
|
#[test]
|
|
fn only_the_leader_may_drag() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
let raid = twelve();
|
|
s.set_party(PartyState {
|
|
members: vec![PartyMemberInfo {
|
|
name: "Member1".into(),
|
|
guid: raid[1].guid,
|
|
}],
|
|
leader_index: 1,
|
|
raid,
|
|
..Default::default()
|
|
});
|
|
s.fire_event("RAID_ROSTER_UPDATE", Vec::new());
|
|
s.run(
|
|
"MOVING_RAID_MEMBER = RaidGroupButton12; TARGET_RAID_SLOT = RaidGroup5Slot1; \
|
|
RaidGroupButton_OnDragStop(RaidGroupButton12)",
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
s.take_party_requests().is_empty(),
|
|
"a member's drag sends nothing"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The row's click and menu
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Left-clicking a row targets that row's `raidN` token — the same token the tooltip reads.
|
|
#[test]
|
|
fn left_clicking_a_row_targets_its_raid_token() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(&mut s, twelve());
|
|
s.run("RaidGroupButton7:Click(\"LeftButton\")").unwrap();
|
|
assert_eq!(s.take_target_requests(), vec!["raid7".to_string()]);
|
|
}
|
|
|
|
/// Right-clicking a row opens the RAID menu, and each of its four rows appears for exactly the
|
|
/// person the reference shows it to.
|
|
///
|
|
/// The menu is addressed by ROW INDEX, and every rank rule re-reads that row's rank — so a menu
|
|
/// opened on the wrong index would offer Promote on somebody who is already an assistant.
|
|
#[test]
|
|
fn the_row_menu_offers_the_rank_verbs_by_rank() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(&mut s, twelve());
|
|
|
|
// Row 3 is a plain member: as the leader we may promote it, not demote it, and may kick it.
|
|
s.run("RaidGroupButton3:Click(\"RightButton\")").unwrap();
|
|
assert!(
|
|
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
|
|
"the menu opens"
|
|
);
|
|
let shown = |s: &UiScript, key: &str| {
|
|
s.eval::<bool>(&format!(
|
|
"for i = 1, UIDROPDOWNMENU_MAXBUTTONS do \
|
|
local b = getglobal(\"DropDownList1Button\"..i) \
|
|
if b and b:IsShown() and b.value == \"{key}\" then return true end \
|
|
end return false"
|
|
))
|
|
.unwrap()
|
|
};
|
|
assert!(
|
|
shown(&s, "RAID_PROMOTE"),
|
|
"a plain member can be made an assistant"
|
|
);
|
|
assert!(!shown(&s, "RAID_DEMOTE"), "and cannot be demoted from one");
|
|
assert!(shown(&s, "RAID_LEADER"), "and can be handed the lead");
|
|
assert!(shown(&s, "RAID_REMOVE"));
|
|
|
|
// Row 2 is the assistant: demote, not promote.
|
|
s.run("HideDropDownMenu(1) RaidGroupButton2:Click(\"RightButton\")")
|
|
.unwrap();
|
|
assert!(shown(&s, "RAID_DEMOTE"));
|
|
assert!(!shown(&s, "RAID_PROMOTE"));
|
|
|
|
// Row 1 is the leader — us. Nothing may be done to the leader.
|
|
s.run("HideDropDownMenu(1) RaidGroupButton1:Click(\"RightButton\")")
|
|
.unwrap();
|
|
assert!(!shown(&s, "RAID_LEADER"));
|
|
assert!(
|
|
!shown(&s, "RAID_REMOVE"),
|
|
"the leader cannot be kicked, not even by themself"
|
|
);
|
|
}
|
|
|
|
/// The menu's verbs reach the engine addressed the way the wire wants them — three by name, and
|
|
/// the kick by the row index.
|
|
#[test]
|
|
fn the_menu_verbs_queue_the_right_requests() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(&mut s, twelve());
|
|
let click = |s: &UiScript, key: &str| {
|
|
s.run(&format!(
|
|
"for i = 1, UIDROPDOWNMENU_MAXBUTTONS do \
|
|
local b = getglobal(\"DropDownList1Button\"..i) \
|
|
if b and b:IsShown() and b.value == \"{key}\" then b:Click() return end \
|
|
end error(\"{key} not shown\")"
|
|
))
|
|
.unwrap();
|
|
};
|
|
s.run("RaidGroupButton3:Click(\"RightButton\")").unwrap();
|
|
click(&s, "RAID_PROMOTE");
|
|
assert_eq!(
|
|
s.take_party_requests(),
|
|
vec![PartyRequest::AssistantLeader {
|
|
name: "Member2".into(),
|
|
grant: true
|
|
}]
|
|
);
|
|
|
|
s.run("HideDropDownMenu(1) RaidGroupButton3:Click(\"RightButton\")")
|
|
.unwrap();
|
|
click(&s, "RAID_REMOVE");
|
|
assert_eq!(
|
|
s.take_party_requests(),
|
|
vec![PartyRequest::UninviteRaid(3)],
|
|
"the kick goes by ROW INDEX, which is what the reference passes"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// Raid Info
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// The saved-instance panel fills from `GetSavedInstanceInfo`, and its button is live only once
|
|
/// the server has answered with something.
|
|
#[test]
|
|
fn the_raid_info_panel_lists_the_saved_lockouts() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
// The first UPDATE_INSTANCE_INFO only arms the ref's latch (`RaidFrame.hasRaidInfo`).
|
|
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
|
|
s.set_saved_instances(vec![
|
|
SavedInstanceInfo {
|
|
name: "Molten Core".into(),
|
|
instance: 1234,
|
|
reset: 3 * 86_400,
|
|
},
|
|
SavedInstanceInfo {
|
|
name: "Onyxia's Lair".into(),
|
|
instance: 77,
|
|
reset: 3_600,
|
|
},
|
|
]);
|
|
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
|
|
assert_eq!(
|
|
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0")
|
|
.unwrap(),
|
|
1
|
|
);
|
|
// The rows live inside the flyout, so `IsVisible` is false until it is open — open it, which
|
|
// is what the button does, and then ask.
|
|
s.run("RaidInfoFrame:Show()").unwrap();
|
|
assert!(visible(&s, "RaidInfoInstance1"));
|
|
assert!(visible(&s, "RaidInfoInstance2"));
|
|
assert!(
|
|
!visible(&s, "RaidInfoInstance3"),
|
|
"rows past the list stay away"
|
|
);
|
|
assert_eq!(text_of(&s, "RaidInfoInstance1Name"), "Molten Core");
|
|
assert_eq!(text_of(&s, "RaidInfoInstance1ID"), "1234");
|
|
assert!(
|
|
text_of(&s, "RaidInfoInstance1Reset").starts_with("Resets in 3 Days"),
|
|
"the reset is a REMAINING duration through SecondsToTime, not a timestamp: {:?}",
|
|
text_of(&s, "RaidInfoInstance1Reset")
|
|
);
|
|
assert!(
|
|
!visible(&s, "RaidInfoScrollFrameScrollBar"),
|
|
"four rows fit, so two need no bar"
|
|
);
|
|
|
|
// Bound to nothing: the button dies, and the panel does not keep a stale bar standing.
|
|
s.set_saved_instances(Vec::new());
|
|
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
|
|
assert_eq!(
|
|
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled() and 1 or 0")
|
|
.unwrap(),
|
|
0
|
|
);
|
|
assert!(!visible(&s, "RaidInfoInstance1"));
|
|
|
|
// The button toggles the flyout.
|
|
assert!(visible(&s, "RaidInfoFrame"));
|
|
s.run("RaidFrameRaidInfoButton:GetScript(\"OnClick\")()")
|
|
.unwrap();
|
|
assert!(!visible(&s, "RaidInfoFrame"), "and closes it again");
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The ready check
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// `READY_CHECK` opens the popup naming the leader, and the two buttons answer opposite ways —
|
|
/// including the No button's argument-less call, which is the reference's own spelling.
|
|
#[test]
|
|
fn the_ready_check_popup_opens_and_answers() {
|
|
let mut s = setup();
|
|
open_raid_tab(&mut s);
|
|
push_raid(&mut s, twelve());
|
|
assert!(!visible(&s, "ReadyCheckFrame"), "nothing to answer yet");
|
|
|
|
s.fire_event("READY_CHECK", Vec::new());
|
|
assert!(visible(&s, "ReadyCheckFrame"), "UIParent's arm opens it");
|
|
assert!(
|
|
text_of(&s, "ReadyCheckFrameText").starts_with("Me has initiated a ready check."),
|
|
"named for the rank-2 row: {:?}",
|
|
text_of(&s, "ReadyCheckFrameText")
|
|
);
|
|
|
|
s.run("ReadyCheckFrameYesButton:Click()").unwrap();
|
|
assert_eq!(
|
|
s.take_party_requests(),
|
|
vec![PartyRequest::ReadyCheckAnswer(true)]
|
|
);
|
|
assert!(!visible(&s, "ReadyCheckFrame"));
|
|
|
|
s.fire_event("READY_CHECK", Vec::new());
|
|
s.run("ReadyCheckFrameNoButton:Click()").unwrap();
|
|
assert_eq!(
|
|
s.take_party_requests(),
|
|
vec![PartyRequest::ReadyCheckAnswer(false)],
|
|
"No passes no argument at all, and absent must mean not-ready"
|
|
);
|
|
|
|
// The raid ending ends the question.
|
|
s.fire_event("READY_CHECK", Vec::new());
|
|
assert!(visible(&s, "ReadyCheckFrame"));
|
|
push_raid(&mut s, Vec::new());
|
|
assert!(
|
|
!visible(&s, "ReadyCheckFrame"),
|
|
"the raid went, so the question went"
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
// The reference-geometry diff (decision 0675's discipline)
|
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Every element this file shares with the reference carries the reference's numbers.
|
|
///
|
|
/// The pane's specification is TWO reference files — `RaidFrame.xml` from FrameXML and
|
|
/// `Blizzard_RaidUI.xml` from the load-on-demand addon — so this runs twice. The addon half is
|
|
/// read straight out of the patch chain rather than from an extraction on disk: `_extracted_framexml/`
|
|
/// holds FrameXML only, and requiring someone to have extracted the addon first would leave half
|
|
/// the window's geometry guarded by nothing.
|
|
#[test]
|
|
fn the_geometry_matches_both_reference_files() {
|
|
// The FrameXML half: the pane, its two buttons, the info flyout and the lockout row template.
|
|
if let Some(reference) = super::framexml_diff::reference("RaidFrame.xml") {
|
|
super::framexml_diff::assert_geometry_matches(
|
|
"RaidFrame.xml",
|
|
&reference,
|
|
&[
|
|
// The reference's close button inherits `UIPanelCloseButton`, which carries the
|
|
// 32x32; no such template ships in this house (FriendsFrame's own close button
|
|
// says the same), so ours states the size the template would have given it. The
|
|
// ANCHOR offsets match; only the extra size pair differs.
|
|
"RaidInfoCloseButton",
|
|
],
|
|
10,
|
|
);
|
|
}
|
|
|
|
// The addon half: the row, the slot, the group frame and the ready-check popup.
|
|
let Some(data) = benilla_formats::wow_data() else {
|
|
return;
|
|
};
|
|
let chain = benilla_formats::open_chain(&data).expect("open chain");
|
|
let bytes = chain
|
|
.read("Interface\\AddOns\\Blizzard_RaidUI\\Blizzard_RaidUI.xml")
|
|
.expect("Blizzard_RaidUI.xml is in the patch chain");
|
|
super::framexml_diff::assert_geometry_matches_text(
|
|
"RaidFrame.xml",
|
|
&String::from_utf8_lossy(&bytes),
|
|
&[],
|
|
10,
|
|
);
|
|
}
|