Files
benilla-octo/crates/benilla-app/src/ui_script/raid_tests.rs
T
SamandClaude Fable 5 c4f432d0c3 the world map blips are the reference's, and particles go untinted
- the world map speaks the reference's names: WorldMapTooltip is declared (six corpus addons index it; pfUI dies on it at file scope), the blackout frame takes the reference's spelling BlackoutWorld, and the four hand-written party blips become the reference's own unit-blip templates — all 44 blips as instances, each with its published $parentIcon, the name pfUI recolours group blips by class through; the 40 raid ones are declared but not positioned yet, named in the OUT list rather than faked
- the templates addons build their own buttons from are published: ItemButtonTemplate, ContainerFrameItemButtonTemplate and the bag-slot chain — most of the handler scripts were live the whole time (this client executes the reference's own container and paper-doll code off the player's patch chain), so what was missing was mostly declarations, not behaviour — the bank arm stays out and is named as the omission it is; OptionsFrameSliders becomes real, with the three Graphics rows reading it and re-reading on show, so an addon's write to a slider's bounds actually moves the slider; and the template census that had ranked these at "zero demand" learns to see a local bound to a literal — its table went from 3 rows to 7
- the bindings answer in 1.12's own shapes: Button:IsEnabled() answers the NUMBER 1 or 0 (0 is truthy in Lua, so the reference's own == 0 tests got false forever, and three of our files had hand-worked around the divergence rather than fixed it); the constructors' string arguments are four shapes, not one rule — pfUI passes a Button where a template name goes and the reference constructs the FontString and ignores it; SetInventoryItem returns THREE values, the reference's own paper doll destructuring repairCost; UnitIsVisible is object presence and nothing else (57 bytes, one branch, no distance — an out-of-range party member is exists-yes, visible-nil); the tapped pair reads the two dynamic-flags bits, and its repaint wire fires UNIT_FACTION on the tap bit — the half a passing predicate test never sees; UnitIsPartyLeader is two ORed legs covering disjoint failures (a held stranger leading their own party, and an out-of-range member with no object to read a flag from); and the four nameplate verbs take no argument and return zero values
- the ghost's wisps compose onto its body instead of over it — the four emitters drew at full strength over a body at alpha 0.5, and now inherit the instance alpha down the attachment chain the way the reference recomposes it per frame; the residual we had parked ("the wisps should inherit the tint too") is REFUTED at the bytes: a particle batch commits hardcoded white RGB, and even that never reaches the fragment — particles are never tinted, only the alpha crosses, applied to the alpha byte alone — so the shipped shader split was already right and no code changes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:15:16 +07:00

1110 lines
44 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, "MoneyFrame.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,
leader_guid: 0, // the player leads; their guid is unset in this fixture
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()")
.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()")
.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()")
.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()")
.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()")
.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 player with no lockouts at all** — the case that shipped broken (1561). Two answers, both
/// empty, is the whole ordinary session: one for `PLAYER_ENTERING_WORLD`'s `RequestRaidInfo` and
/// one for the pane's own on show. The first arms `RaidFrame.hasRaidInfo` and returns; the second
/// is the one that has to put the button away and empty the panel behind it.
///
/// Its sibling above proves the same arithmetic from a list that had something in it. This one
/// proves it from a list that never did, which is the case a *diff* cannot reach — and the button
/// left live over an empty panel is exactly what the director saw.
#[test]
fn a_player_with_no_lockouts_loses_the_raid_info_button_on_the_second_answer() {
let mut s = setup();
open_raid_tab(&mut s);
// Answer one: the latch, and nothing else. The button is still whatever it loaded as — the
// reference does not disable it in `RaidFrame_OnLoad` either, so this window is its own.
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrame.hasRaidInfo or 0").unwrap(),
1,
"the first answer only arms the latch"
);
// Answer two, saying the same nothing.
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
assert_eq!(
s.eval::<i64>("return RaidFrameRaidInfoButton:IsEnabled()")
.unwrap(),
0,
"an empty lockout list is a dead button"
);
// And the panel behind it is emptied, not left in the state the XML loaded: every row away,
// and no scroll bar standing over a list of nothing.
s.run("RaidInfoFrame:Show()").unwrap();
assert!(!visible(&s, "RaidInfoInstance1"));
assert!(!visible(&s, "RaidInfoInstance10"));
assert!(!visible(&s, "RaidInfoScrollFrameScrollBar"));
s.run("RaidInfoFrame:Hide()").unwrap();
// The button is dead to a real click, not merely drawn grey — the half the director asked for
// by name. Driven through the pointer, because that is the path a press actually takes.
let (bx, by) = {
let l: f32 = s.eval("return RaidFrameRaidInfoButton:GetLeft()").unwrap();
let r: f32 = s.eval("return RaidFrameRaidInfoButton:GetRight()").unwrap();
let t: f32 = s.eval("return RaidFrameRaidInfoButton:GetTop()").unwrap();
let b: f32 = s
.eval("return RaidFrameRaidInfoButton:GetBottom()")
.unwrap();
((l + r) / 2.0, (t + b) / 2.0)
};
s.mouse_button(bx, by, "LeftButton", true);
s.mouse_button(bx, by, "LeftButton", false);
s.tick(0.016);
assert!(
!visible(&s, "RaidInfoFrame"),
"a disabled button does not open the panel"
);
}
/// The scroll bar the fifth lockout brings on has to land on the trough drawn behind it, not where
/// `UIPanelScrollFrameTemplate` seats a bare ScrollFrame's bar (1561).
///
/// Geometry, asserted as the relationship rather than as four numbers: the bar is CENTRED in the
/// trough art on X, and its top is the panel's own -3 rather than the template's -16. Both were
/// wrong before — 2 px left, 13 px low — and neither is visible to any test that only asks whether
/// the bar is shown.
#[test]
fn the_scroll_bar_is_seated_on_the_trough_the_panel_draws_behind_it() {
let mut s = setup();
open_raid_tab(&mut s);
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
s.set_saved_instances(
(1..=6)
.map(|i| SavedInstanceInfo {
name: format!("Instance {i}"),
instance: 1000 + i,
reset: i * 3_600,
})
.collect(),
);
s.fire_event("UPDATE_INSTANCE_INFO", Vec::new());
s.run("RaidInfoFrame:Show()").unwrap();
assert!(
visible(&s, "RaidInfoScrollFrameScrollBar"),
"six lockouts do not fit in four rows"
);
let mid_x = |s: &UiScript, f: &str| -> f32 {
let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap();
(l + r) / 2.0
};
let top = |s: &UiScript, f: &str| -> f32 { s.eval(&format!("return {f}:GetTop()")).unwrap() };
let bar = mid_x(&s, "RaidInfoScrollFrameScrollBar");
let trough = mid_x(&s, "RaidInfoScrollFrameTop");
assert!(
(bar - trough).abs() < 0.01,
"the bar rides the middle of its trough: bar {bar} vs trough {trough}"
);
assert!(
(top(&s, "RaidInfoScrollFrameScrollBar") - (top(&s, "RaidInfoScrollFrame") - 3.0)).abs()
< 0.01,
"and hangs 3 px under the frame's top, not the template's 16"
);
// The up arrow rides above the bar, so re-seating the bar is what lifts it into the trough's
// own cap — the piece of this that is actually visible.
assert!(
top(&s, "RaidInfoScrollFrameScrollBarScrollUpButton") > top(&s, "RaidInfoScrollFrame"),
"the up arrow clears the scroll frame, where the template left it 13 px inside"
);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// 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,
);
}
/// **A drag the cursor carries off the window edge ENDS — it does not glue the row to the mouse
/// for the rest of the session** (B310).
///
/// The defect this pins is not in this file at all; it is the engine's, and the raid grid is
/// simply where it bites hardest. No release is fed once the OS pointer is outside the window, so
/// `UiScript::pointer_left_window` used to drop the gesture silently: `OnDragStop` never ran, this
/// pane's `MOVING_RAID_MEMBER` stayed set, the engine's one `StartMoving` slot stayed taken, and
/// the row followed the cursor around the screen from then on — swallowing every press aimed at
/// any row underneath it. "I moved one member and then could not move any other" is what that
/// looks like from a hand on the mouse.
///
/// The three things this asserts are the three the bug broke, in order of how visible they are:
/// the row goes home, the pane forgets it, and the next row still drags.
#[test]
fn a_drag_carried_off_the_window_edge_ends_instead_of_gluing_the_row_to_the_cursor() {
let mut s = setup();
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
let left_of =
|s: &UiScript, f: &str| -> f32 { s.eval(&format!("return {f}:GetLeft()")).unwrap() };
let centre = |s: &UiScript, f: &str| -> (f32, f32) {
let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap();
let t: f32 = s.eval(&format!("return {f}:GetTop()")).unwrap();
let b: f32 = s.eval(&format!("return {f}:GetBottom()")).unwrap();
((l + r) / 2.0, (t + b) / 2.0)
};
let home = left_of(&s, "RaidGroupButton7");
let (fx, fy) = centre(&s, "RaidGroupButton7");
s.mouse_button(fx, fy, "LeftButton", true);
s.mouse_move(fx + 40.0, fy + 40.0); // past the threshold ⇒ OnDragStart ⇒ StartMoving
s.tick(0.016);
assert_eq!(
s.eval::<String>("return MOVING_RAID_MEMBER:GetName()")
.unwrap(),
"RaidGroupButton7",
"the drag is in flight"
);
s.pointer_left_window();
s.tick(0.016);
assert!(
s.eval::<bool>("return MOVING_RAID_MEMBER == nil").unwrap(),
"the pane's own drag state is cleared by the OnDragStop the abandon fires"
);
assert_eq!(
left_of(&s, "RaidGroupButton7"),
home,
"the row springs back to its slot — a drop on nothing is not a move"
);
// The row is no longer following anything.
s.mouse_move(400.0, 300.0);
s.tick(0.016);
s.mouse_move(500.0, 200.0);
s.tick(0.016);
assert_eq!(
left_of(&s, "RaidGroupButton7"),
home,
"…and it stays there however far the cursor travels"
);
// And the next row drags normally, which is the thing the director actually lost.
let (gx, gy) = centre(&s, "RaidGroupButton8");
let (tx, ty) = centre(&s, "RaidGroup5Slot1");
s.mouse_button(gx, gy, "LeftButton", true);
s.mouse_move(gx + 10.0, gy + 10.0);
s.tick(0.016);
s.mouse_move(tx, ty);
s.tick(0.016);
s.mouse_button(tx, ty, "LeftButton", false);
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::SetSubgroup { index: 8, group: 5 }],
"the row after the abandoned one still moves"
);
assert!(
s.errors().is_empty(),
"and nothing raised: {:?}",
s.errors()
);
}
/// The whole gesture, through the REAL pointer path, three times over — press, cross the
/// threshold, travel, release — with the roster echo in between, exactly as `/partytest raid`
/// feeds it.
///
/// Its sibling `dragging_a_row_moves_swaps_or_springs_back` calls `RaidGroupButton_OnDragStop`
/// with the globals set by hand, which is the *landing* logic and nothing else: it never fires
/// `OnDragStart`, never calls `StartMoving`, never runs the hover sweep, and — the part that
/// mattered — never drags a SECOND row. This one does all four.
#[test]
fn one_drag_does_not_cost_the_next_one() {
let mut s = setup();
open_raid_tab(&mut s);
let mut raid = vec![row("Me", 2, 1, "Warrior", true, false)];
for i in 1..25u32 {
raid.push(row(
&format!("Member{i}"),
0,
i / 5 + 1,
"Priest",
true,
false,
));
}
push_raid(&mut s, raid.clone());
let centre = |s: &UiScript, f: &str| -> (f32, f32) {
let l: f32 = s.eval(&format!("return {f}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {f}:GetRight()")).unwrap();
let t: f32 = s.eval(&format!("return {f}:GetTop()")).unwrap();
let b: f32 = s.eval(&format!("return {f}:GetBottom()")).unwrap();
((l + r) / 2.0, (t + b) / 2.0)
};
for (row_index, group) in [(7u32, 6u32), (8, 6), (9, 7)] {
let from = format!("RaidGroupButton{row_index}");
let seat = if group == 6 && row_index == 8 { 2 } else { 1 };
let to = format!("RaidGroup{group}Slot{seat}");
let (fx, fy) = centre(&s, &from);
let (tx, ty) = centre(&s, &to);
s.mouse_button(fx, fy, "LeftButton", true);
s.mouse_move(fx + 10.0, fy + 10.0);
s.tick(0.016);
s.mouse_move(tx, ty);
s.tick(0.016);
s.mouse_button(tx, ty, "LeftButton", false);
s.tick(0.016);
assert_eq!(
s.take_party_requests(),
vec![PartyRequest::SetSubgroup {
index: row_index,
group
}],
"drag {row_index} onto group {group}"
);
assert!(s.errors().is_empty(), "{from}: {:?}", s.errors());
// The sandbox echo `/partytest raid` supplies, so the next drag starts from a repainted
// grid rather than a frozen one.
raid[row_index as usize - 1].subgroup = group - 1;
push_raid(&mut s, raid.clone());
}
}