Files
benilla-octo/crates/benilla-app/src/ui_script/guild_tests.rs
T
SamandClaude Fable 5.1 9105fa72a4 the flight map goes stock, and vendors refuse in 1.12's own words
- the pet stable and the flight map are the reference's own files: `TaxiRouteFrame` maps to a plain Frame (factory `0x495ba0` allocates the base size, the ctor adds no field, no new virtual, no method table — and `GetObjectType` is not overridden, so its own kind would have been the divergence), and the swap found three silent behaviours — the close button did nothing (an unknown `inherits=` is a WARNING, so it loaded as a bare button), the "no paths" line drew empty, and the title is READ off `UnitName("npc")`, not received; the WMO skybox rides the interior crossfade — one weight (`[0xce9bdc]`, the same number the MFOG lerp rides), a 4 s alpha fade, so a skybox the flood publishes through a doorway draws at weight 0 instead of popping in whole
- a widget predicate answers the NUMBER 1 or nil, never a boolean: eleven of them (`IsShown`, `IsVisible`, `IsMovable`, `GetChecked`…) move to the 1.12 shape — the reference proves it in its own source (`tostring(this:GetChecked())` saved and read back `== "1"`; `tostring(true)` would never match and buff timers would never appear), 21 corpus sites compare directly (`IsVisible() == nil`, `GetChecked() == 1`), and one of our own tests had the guild rank editor sending `true` where a real client sends 1
- the vendor's refusals are the reference's table: `SMSG_BUY_FAILED`/`SMSG_SELL_ITEM` route through `0x5dcd60`/`0x5dd1f0`'s switches, six lines were wrong (code 3 printed "That vendor can't be found." — vmangos's identifier — where 1.12 prints "That merchant doesn't like you."), silence is now expressible, and keying them gives the vendor a voice (`ERR_NOT_ENOUGH_MONEY` carries error-speech line 0x28); the bank's three ids were right and its "purchase failed (n)" an invention; the logout refusal is one key, `ERR_LOGOUT_FAILED`, for every non-zero reason; the two mail codes we said had no string have one (`ERR_PLAYER_WRONG_FACTION`, `ERR_RESTRICTED_ACCOUNT`) and a sent mail says `ERR_MAIL_SENT` in yellow; a test runs the chain's own `GlobalStrings.lua` and requires non-empty text for all 193 `ERR_*` the workspace names
- a clicked quest timer no longer raises — stock `QuestTimerFrame.lua` calls `QuestLog_SetSelection`/`QuestLog_Update`, which existed nowhere in this tree; our quest log owns the reference's two names now, which also shrinks the delta when that window itself migrates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:05:15 +07:00

1410 lines
54 KiB
Rust

//! The four guild windows (decision 1257, `FriendsFrame.xml` l.1719-3438 of the reference): the
//! roster pane and its two views, the rank editor, the guild-information notice board, and the
//! member detail card.
//!
//! What these guard that the Rust-side unit tests structurally cannot: the windows are Lua over an
//! engine snapshot, so a getter whose ten returns are in the wrong order, a row template whose
//! FontString is misnamed, a rank comparison written `>=` where the reference wrote `>`, or a
//! button wired to the wrong verb are all invisible to `script::guild`'s own tests and green in the
//! parse sweep. Each test below fails on exactly one of those.
//!
//! **The engine API is STOOD IN FOR here, deliberately.** `benilla-ui/src/script/guild.rs` binds
//! the ~47 real globals over a pushed snapshot; these tests replace all of them with the Lua
//! fixture below *before* the XML loads, so what is under test is the window and nothing else. It
//! also means the window's tests do not need the app's feed seated, and that a change to the
//! engine's plumbing cannot quietly turn one of these green. The fixture's shapes are the ones
//! `script::guild` promises, and two of them are easy to get wrong and worth restating:
//!
//! - **era booleans are `1`/`nil`, never `true`/`false`** — `IsInGuild`, `IsGuildLeader`, every
//! `Can*`, and `online` out of `GetGuildRosterInfo`;
//! - **two index bases coexist**: the roster's `rankIndex` is **0-based** (0 = guild master), while
//! the whole `GuildControl*` family is **1-based**, driven straight off dropdown IDs.
use benilla_ui::script::{GuildState, ScriptValue, UiScript, UnitState};
use super::test_ui::load_ui_strict as load_xml;
/// The guild engine API, stood in for in Lua (see the module header).
///
/// One mutable table, `BenillaGuildFixture`, is the whole model; every getter reads it and every
/// verb appends to `.calls`, which a test drains with `BenillaGuildCalls()`. Tests mutate the
/// table's FIELDS — never replace the table, since the closures below hold it as an upvalue.
///
/// The three seeded members are chosen to cover the rank comparisons the detail card turns on:
/// the guild master (ourselves), a rank-1 officer, and an offline member sitting in the BOTTOM
/// rank — which is also the one that blocks removing that rank in the editor.
const GUILD_FIXTURE: &str = r#"
BenillaGuildFixture = {
inGuild = 1,
isLeader = 1,
guildName = "Legacy of Steel",
myRankIndex = 0,
motd = "Raid Tuesday at eight.",
infoText = "Be excellent to each other.",
selection = 0,
showOffline = 1,
ranks = { "Guild Master", "Officer", "Veteran", "Member", "Initiate", "Peon" },
rights = {
promote = 1, demote = 1, invite = 1, remove = 1, editMOTD = 1,
editPublicNote = 1, viewOfficerNote = 1, editOfficerNote = 1, editGuildInfo = 1,
},
-- The 13-flag rank buffer, per 1-based rank. Only rank 1 is seeded; the others answer
-- all-nil, which is the shape a never-loaded rank has.
flags = { [1] = { 1, 1, 1, 1, nil, nil, 1, nil, 1, 1, 1, nil, nil } },
controlRank = 1,
calls = {},
members = {
{ name = "Tigole", rank = "Guild Master", rankIndex = 0, level = 60, class = "Warrior",
zone = "Ironforge", note = "", officernote = "", online = 1, status = "" },
{ name = "Furor", rank = "Officer", rankIndex = 1, level = 60, class = "Rogue",
zone = "Orgrimmar", note = "raid lead", officernote = "trusted", online = 1,
status = "<AFK>" },
{ name = "Kaplan", rank = "Peon", rankIndex = 5, level = 12, class = "Mage",
zone = "Elwynn Forest", note = "alt", officernote = "", online = nil, status = "",
lastOnline = { 0, 0, 3, 0 } },
},
}
local F = BenillaGuildFixture
function BenillaGuildRecord(call)
table.insert(F.calls, call)
end
-- Drain the recorded verbs as one "|"-joined string, so a test asserts on the whole sequence
-- rather than on "it happened at least once".
function BenillaGuildCalls()
local out = table.concat(F.calls, "|")
F.calls = {}
return out
end
function IsInGuild() return F.inGuild end
function IsGuildLeader() return F.isLeader end
function GetGuildInfo(unit)
if not F.inGuild then return nil end
return F.guildName, F.ranks[F.myRankIndex + 1], F.myRankIndex
end
function GetNumGuildMembers() return table.getn(F.members) end
function GetGuildRosterInfo(index)
local m = F.members[index]
if not m then return nil end
return m.name, m.rank, m.rankIndex, m.level, m.class, m.zone, m.note, m.officernote,
m.online, m.status
end
function GetGuildRosterLastOnline(index)
local m = F.members[index]
if not m or not m.lastOnline then return 0, 0, 0, 0 end
return m.lastOnline[1], m.lastOnline[2], m.lastOnline[3], m.lastOnline[4]
end
function GetGuildRosterMOTD() return F.motd end
function GetGuildRosterSelection() return F.selection end
function SetGuildRosterSelection(index)
F.selection = index
BenillaGuildRecord("SetGuildRosterSelection:" .. index)
end
function GetGuildRosterShowOffline() return F.showOffline end
function SetGuildRosterShowOffline(value)
F.showOffline = value
BenillaGuildRecord("SetGuildRosterShowOffline:" .. tostring(value))
end
function SortGuildRoster(field) BenillaGuildRecord("SortGuildRoster:" .. field) end
function GuildRoster() BenillaGuildRecord("GuildRoster") end
function GetGuildInfoText() return F.infoText end
function SetGuildInfoText(text)
F.infoText = text
BenillaGuildRecord("SetGuildInfoText:" .. text)
end
function GuildSetMOTD(text)
F.motd = text
BenillaGuildRecord("GuildSetMOTD:" .. text)
end
function GuildRosterSetPublicNote(index, text)
BenillaGuildRecord("GuildRosterSetPublicNote:" .. index .. ":" .. text)
end
function GuildRosterSetOfficerNote(index, text)
BenillaGuildRecord("GuildRosterSetOfficerNote:" .. index .. ":" .. text)
end
function GuildInviteByName(name) BenillaGuildRecord("GuildInviteByName:" .. name) end
function GuildUninviteByName(name) BenillaGuildRecord("GuildUninviteByName:" .. name) end
function GuildPromoteByName(name) BenillaGuildRecord("GuildPromoteByName:" .. name) end
function GuildDemoteByName(name) BenillaGuildRecord("GuildDemoteByName:" .. name) end
function GuildSetLeaderByName(name) BenillaGuildRecord("GuildSetLeaderByName:" .. name) end
function GuildLeave() BenillaGuildRecord("GuildLeave") end
function GuildDisband() BenillaGuildRecord("GuildDisband") end
function AcceptGuild() BenillaGuildRecord("AcceptGuild") end
function DeclineGuild() BenillaGuildRecord("DeclineGuild") end
function CanGuildPromote() return F.rights.promote end
function CanGuildDemote() return F.rights.demote end
function CanGuildInvite() return F.rights.invite end
function CanGuildRemove() return F.rights.remove end
function CanEditMOTD() return F.rights.editMOTD end
function CanEditPublicNote() return F.rights.editPublicNote end
function CanViewOfficerNote() return F.rights.viewOfficerNote end
function CanEditOfficerNote() return F.rights.editOfficerNote end
function CanEditGuildInfo() return F.rights.editGuildInfo end
function GuildControlGetNumRanks() return table.getn(F.ranks) end
function GuildControlGetRankName(index) return F.ranks[index] end
function GuildControlSetRank(index)
F.controlRank = index
BenillaGuildRecord("GuildControlSetRank:" .. index)
end
function GuildControlGetRankFlags()
local f = F.flags[F.controlRank or 1] or {}
return f[1], f[2], f[3], f[4], f[5], f[6], f[7], f[8], f[9], f[10], f[11], f[12], f[13]
end
function GuildControlSetRankFlag(index, on)
local f = F.flags[F.controlRank or 1]
if not f then
f = {}
F.flags[F.controlRank or 1] = f
end
if on then f[index] = 1 else f[index] = nil end
BenillaGuildRecord("GuildControlSetRankFlag:" .. index .. ":" .. tostring(on))
end
function GuildControlSaveRank(name) BenillaGuildRecord("GuildControlSaveRank:" .. name) end
function GuildControlAddRank(name)
table.insert(F.ranks, name)
BenillaGuildRecord("GuildControlAddRank:" .. name)
end
function GuildControlDelRank(name) BenillaGuildRecord("GuildControlDelRank:" .. name) end
"#;
/// The window's own manifest slice, in `benilla.toc` order, with the fixture seated first.
///
/// `UIPanelTemplates.xml` is in the slice where `friends_tests` does not need it: the guild frames
/// inherit the reference's shared `UIPanelButtonTemplate` / `UIPanelCloseButton` /
/// `UIPanelScrollFrameTemplate` rather than a private copy, exactly as the reference's own XML
/// does. `BasicControls.xml` is here for `message()`, which `GuildControlCheckboxUpdate` calls on
/// a missing checkbox.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// Before the XML, so `FriendsFrame_OnLoad`'s `GetGuildRosterMOTD()` and
// `GuildControlPopupFrame_OnLoad`'s `GuildControlGetRankFlags()` read the fixture.
s.run(GUILD_FIXTURE).unwrap();
// The roster's self-checks (`UnitName("player") == name`) need a seated player, and the
// fixture makes us the guild master, so the names must agree.
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Tigole".into()),
level: 60,
..UnitState::default()
}),
);
load_xml(&s, "Fonts.xml");
load_xml(&s, "BasicControls.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.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");
// The social window's fourth tab lives in its own file, and it is part of THIS window's
// manifest slice now: `BENILLA_FRIENDS_SUBFRAMES` names "RaidFrame", and both
// `FriendsFrame_ShowSubFrame` and `FriendsFrame_OnHide` resolve every name in that list
// through `getglobal` and call `:Hide()` on it. The reference's list names it too and never
// guards, because there RaidFrame.xml is FrameXML and always loaded — so the guard belongs in
// the harness's load order, not in shipped Lua defending against a state the client cannot be
// in (decision 1549).
load_xml(&s, "RaidFrame.xml");
s
}
/// Open the window on the guild tab.
fn open(s: &UiScript) {
s.run("ToggleFriendsFrame(3)").unwrap();
}
fn text(s: &UiScript, expr: &str) -> String {
s.eval::<String>(&format!("return {expr}:GetText() or \"\""))
.unwrap_or_else(|e| panic!("{expr}:GetText() — {e}"))
}
fn visible(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap_or_else(|e| panic!("{frame}:IsVisible() — {e}"))
}
fn enabled(s: &UiScript, button: &str) -> bool {
s.eval::<bool>(&format!("return {button}:IsEnabled() ~= 0"))
.unwrap_or_else(|e| panic!("{button}:IsEnabled() — {e}"))
}
fn calls(s: &UiScript) -> String {
s.eval::<String>("return BenillaGuildCalls()").unwrap()
}
/// A region's text colour, rounded to two places. The engine stores colours as `f32`, so 0.82 comes
/// back as 0.8199999928474426 and an exact compare would fail on every one of them.
fn colour(s: &UiScript, region: &str) -> (f64, f64, f64) {
let (r, g, b) = s
.eval::<(f64, f64, f64)>(&format!("return {region}:GetTextColor()"))
.unwrap_or_else(|e| panic!("{region}:GetTextColor() — {e}"));
let round = |v: f64| (v * 100.0).round() / 100.0;
(round(r), round(g), round(b))
}
/// The tab is live for a guilded character, the pane paints its four columns from the roster, the
/// title is the GUILD'S name rather than a constant, and showing the pane ASKS for the roster —
/// which the friend list never has to, because that one arrives unasked.
#[test]
fn the_guild_tab_opens_the_roster_and_asks_for_it() {
let s = setup();
assert_eq!(
s.eval::<i64>("return FriendsFrameTab3.isDisabled or 0")
.unwrap(),
0,
"in a guild, the tab is live"
);
let _ = calls(&s);
open(&s);
assert!(visible(&s, "FriendsFrame"));
assert!(visible(&s, "GuildFrame"));
assert!(
!visible(&s, "FriendsListFrame"),
"the friends list is one of the four exclusive sub-frames"
);
assert_eq!(
text(&s, "FriendsFrameTitleText"),
"Legacy of Steel",
"the guild tab's title is the guild's own name"
);
assert!(
calls(&s).contains("GuildRoster"),
"showing the pane requests the roster"
);
// The player view is the one that opens (FriendsFrame.playerStatusFrame starts 1).
assert!(visible(&s, "GuildPlayerStatusFrame"));
assert!(!visible(&s, "GuildStatusFrame"));
assert_eq!(text(&s, "GuildFrameButton1Name"), "Tigole");
assert_eq!(text(&s, "GuildFrameButton1Zone"), "Ironforge");
assert_eq!(text(&s, "GuildFrameButton1Level"), "60");
assert_eq!(
text(&s, "GuildFrameButton1Class"),
"Warrior",
"the roster's ten returns in order — class is the fifth, not the fourth"
);
assert!(
visible(&s, "GuildFrameButton3"),
"three members, three rows"
);
assert!(
!visible(&s, "GuildFrameButton4"),
"rows past the roster are hidden"
);
// "|cffffffff3|r Guild Members" + "(|cffffffff2|r |cff00ff00Online|r)" — the plural form for
// three, and the online count is a separate string beside it.
assert_eq!(text(&s, "GuildFrameTotals"), "|cffffffff3|r Guild Members");
assert_eq!(
text(&s, "GuildFrameOnlineTotals"),
"(|cffffffff2|r |cff00ff00Online|r)"
);
}
/// An OFFLINE member keeps every column — level, class and zone all come down for offline members
/// too, unlike the friends list where an offline friend has no level at all — and only greys.
#[test]
fn an_offline_member_keeps_its_columns_and_only_greys() {
let s = setup();
open(&s);
assert_eq!(text(&s, "GuildFrameButton3Name"), "Kaplan");
assert_eq!(text(&s, "GuildFrameButton3Zone"), "Elwynn Forest");
assert_eq!(
text(&s, "GuildFrameButton3Level"),
"12",
"an offline member still reports a level"
);
assert_eq!(text(&s, "GuildFrameButton3Class"), "Mage");
assert_eq!(
colour(&s, "GuildFrameButton3Name"),
(0.5, 0.5, 0.5),
"offline rows go flat grey"
);
assert_eq!(
colour(&s, "GuildFrameButton1Name"),
(1.0, 0.82, 0.0),
"an online name keeps the gold"
);
}
/// The little page button between the headers and the list flips to the OTHER view — same 13 rows,
/// same scroll frame, different four columns — and its own label swaps to name the view it would
/// take you back to.
#[test]
fn the_page_button_flips_to_the_guild_status_view() {
let s = setup();
open(&s);
assert_eq!(
text(&s, "GuildFrameGuildListToggleButton"),
"Show Player Status"
);
s.run("GuildFrameGuildListToggleButton:Click()").unwrap();
assert!(visible(&s, "GuildStatusFrame"));
assert!(!visible(&s, "GuildPlayerStatusFrame"));
assert_eq!(
text(&s, "GuildFrameGuildListToggleButton"),
"Show Guild Status"
);
assert_eq!(text(&s, "GuildFrameGuildStatusButton2Name"), "Furor");
assert_eq!(text(&s, "GuildFrameGuildStatusButton2Rank"), "Officer");
assert_eq!(text(&s, "GuildFrameGuildStatusButton2Note"), "raid lead");
assert_eq!(
text(&s, "GuildFrameGuildStatusButton2Online"),
"<AFK>",
"an online member's STATUS tag replaces the plain Online label"
);
assert_eq!(
text(&s, "GuildFrameGuildStatusButton1Online"),
"Online",
"…and an empty status falls back to it"
);
assert_eq!(
text(&s, "GuildFrameGuildStatusButton3Online"),
"3 days",
"an offline member reports how long ago, coarsest unit only"
);
// …and back.
s.run("GuildFrameGuildListToggleButton:Click()").unwrap();
assert!(visible(&s, "GuildPlayerStatusFrame"));
}
/// `GuildFrame_GetLastOnline` takes the COARSEST non-zero unit and never composes two, and all
/// four zero reads "< an hour". Asserted through the function because the four arms are otherwise
/// only reachable by seeding four different members.
#[test]
fn the_last_online_formatter_takes_the_coarsest_unit() {
let s = setup();
open(&s);
let last = |years, months, days, hours| {
s.run(&format!(
"BenillaGuildFixture.members[3].lastOnline = {{ {years}, {months}, {days}, {hours} }}"
))
.unwrap();
s.eval::<String>("return GuildFrame_GetLastOnline(3)")
.unwrap()
};
assert_eq!(last(0, 0, 0, 0), "< an hour");
assert_eq!(last(0, 0, 0, 1), "1 hour");
assert_eq!(last(0, 0, 0, 5), "5 hours");
assert_eq!(
last(0, 0, 1, 9),
"1 day",
"days outrank the hours beside them"
);
assert_eq!(last(0, 2, 4, 9), "2 months");
assert_eq!(last(1, 2, 4, 9), "1 year");
assert_eq!(last(3, 0, 0, 0), "3 years");
}
/// A left-click selects the row and opens the detail card; clicking that SAME row again closes it
/// and drops the selection, which is the only way back to "nothing selected".
#[test]
fn a_row_click_toggles_the_detail_card() {
let s = setup();
open(&s);
assert!(!visible(&s, "GuildMemberDetailFrame"));
s.run("GuildFrameButton2:Click()").unwrap();
assert!(visible(&s, "GuildMemberDetailFrame"));
assert_eq!(
s.eval::<i64>("return GetGuildRosterSelection()").unwrap(),
2
);
assert_eq!(text(&s, "GuildMemberDetailName"), "Furor");
assert_eq!(text(&s, "GuildMemberDetailLevel"), "Level 60 Rogue");
assert_eq!(text(&s, "GuildMemberDetailZoneText"), "Orgrimmar");
assert_eq!(text(&s, "GuildMemberDetailRankText"), "Officer");
assert_eq!(text(&s, "GuildMemberDetailOnlineText"), "Online");
assert_eq!(text(&s, "PersonalNoteText"), "raid lead");
assert_eq!(text(&s, "OfficerNoteText"), "trusted");
s.run("GuildFrameButton2:Click()").unwrap();
assert!(
!visible(&s, "GuildMemberDetailFrame"),
"the same row again closes the card"
);
assert_eq!(
s.eval::<i64>("return GetGuildRosterSelection()").unwrap(),
0,
"…and clears the selection"
);
// A DIFFERENT row while the card is open re-points it rather than closing it.
s.run("GuildFrameButton2:Click()").unwrap();
s.run("GuildFrameButton3:Click()").unwrap();
assert!(visible(&s, "GuildMemberDetailFrame"));
assert_eq!(text(&s, "GuildMemberDetailName"), "Kaplan");
assert_eq!(
text(&s, "GuildMemberDetailOnlineText"),
"3 days",
"an offline member's card shows the last-online line, not Online"
);
}
/// The card's four buttons are the rank law, and each of the reference's comparisons matters:
/// promote refuses to make a second-in-command your equal, demote refuses on the bottom rank, and
/// with BOTH arrows dead they leave the card rather than sitting greyed.
#[test]
fn the_detail_buttons_follow_the_rank_comparisons() {
let s = setup();
open(&s);
// Row 2 — the officer directly below us. Nothing to promote him to; demote is fine.
s.run("GuildFrameButton2:Click()").unwrap();
assert!(
!enabled(&s, "GuildFramePromoteButton"),
"rankIndex 1 is already directly below the master"
);
assert!(enabled(&s, "GuildFrameDemoteButton"));
assert!(
visible(&s, "GuildFramePromoteButton"),
"one of the pair live keeps BOTH on screen"
);
assert!(enabled(&s, "GuildMemberRemoveButton"));
assert!(
enabled(&s, "GuildMemberGroupInviteButton"),
"an online guildmate can be invited"
);
// Row 3 — the bottom rank. Promotable, but there is nothing below to demote him to.
s.run("GuildFrameButton3:Click()").unwrap();
assert!(enabled(&s, "GuildFramePromoteButton"));
assert!(
!enabled(&s, "GuildFrameDemoteButton"),
"the bottom rank has nowhere to fall"
);
assert!(
!enabled(&s, "GuildMemberGroupInviteButton"),
"…and he is offline"
);
// Row 1 — ourselves, the guild master. Every rank verb is dead, so the arrows go away.
s.run("GuildFrameButton1:Click()").unwrap();
assert!(!enabled(&s, "GuildFramePromoteButton"));
assert!(!enabled(&s, "GuildFrameDemoteButton"));
assert!(
!visible(&s, "GuildFramePromoteButton"),
"both dead → the arrows leave the card entirely"
);
assert!(!visible(&s, "GuildFrameDemoteButton"));
assert!(
!enabled(&s, "GuildMemberRemoveButton"),
"you cannot remove yourself here"
);
assert!(
!enabled(&s, "GuildMemberGroupInviteButton"),
"nor invite yourself"
);
// The verbs address the selected NAME.
s.run("GuildFrameButton3:Click()").unwrap();
let _ = calls(&s);
s.run("GuildFramePromoteButton:Click()").unwrap();
assert_eq!(calls(&s), "GuildPromoteByName:Kaplan");
assert!(
!enabled(&s, "GuildFramePromoteButton"),
"the arrow disables itself until the roster comes back"
);
}
/// The officer-note pane is a THREE-state affair, and the third state RESIZES the card: a rank that
/// may not even see officer notes gets 60px less window rather than an empty pane.
#[test]
fn the_officer_note_pane_resizes_the_card() {
let s = setup();
open(&s);
s.run("GuildFrameButton2:Click()").unwrap();
assert!(visible(&s, "GuildMemberDetailOfficerNoteLabel"));
assert_eq!(
s.eval::<f64>("return GuildMemberDetailFrame:GetHeight()")
.unwrap(),
255.0
);
// May view but not edit: grey text, mouse-dead pane, and no edit-me placeholder.
s.run("BenillaGuildFixture.rights.editOfficerNote = nil; GuildStatus_Update()")
.unwrap();
assert_eq!(colour(&s, "OfficerNoteText"), (0.65, 0.65, 0.65));
// May not view at all: the pane goes, and the card shrinks.
s.run("BenillaGuildFixture.rights.viewOfficerNote = nil; GuildStatus_Update()")
.unwrap();
assert!(!visible(&s, "GuildMemberDetailOfficerNoteLabel"));
assert!(!visible(&s, "GuildMemberOfficerNoteBackground"));
assert_eq!(
s.eval::<f64>("return GuildMemberDetailFrame:GetHeight()")
.unwrap(),
195.0
);
}
/// An EMPTY note a rank may edit shows the click-here invitation instead of nothing; the same note
/// with no edit right shows the empty string and a dead pane. Without this the only affordance on
/// an empty note is a blank rectangle nobody would click.
#[test]
fn an_editable_empty_note_invites_the_click() {
let s = setup();
open(&s);
s.run("GuildFrameButton1:Click()").unwrap(); // Tigole's public note is ""
assert_eq!(
text(&s, "PersonalNoteText"),
"Click here to set a Public Note."
);
assert_eq!(colour(&s, "PersonalNoteText"), (1.0, 1.0, 1.0));
s.run("BenillaGuildFixture.rights.editPublicNote = nil; GuildStatus_Update()")
.unwrap();
assert_eq!(
text(&s, "PersonalNoteText"),
"",
"no edit right → the empty note stays empty"
);
assert_eq!(colour(&s, "PersonalNoteText"), (0.65, 0.65, 0.65));
}
/// Clicking a note pane opens the WIDE-box dialog, prefilled with the note it edits, and accepting
/// sends it against the SELECTED roster index. This is the first customer of the popup engine's
/// `hasWideEditBox` (UiPanels.xml) and the only test that proves the 420 widen and the box swap.
#[test]
fn the_note_pane_opens_the_wide_dialog_and_sends_it() {
let s = setup();
open(&s);
s.run("GuildFrameButton2:Click()").unwrap();
let _ = calls(&s);
s.run("GuildMemberNoteBackground:GetScript(\"OnMouseUp\")()")
.unwrap();
assert!(visible(&s, "StaticPopup1"));
assert_eq!(text(&s, "StaticPopup1Text"), "Set Player Note:");
assert!(
visible(&s, "StaticPopup1WideEditBox"),
"the wide box is the one that shows"
);
assert!(
!visible(&s, "StaticPopup1EditBox"),
"…and the narrow one is hidden, never both"
);
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
420.0,
"a guild message dialog is the wide one"
);
assert_eq!(
text(&s, "StaticPopup1WideEditBox"),
"raid lead",
"prefilled with the note being edited"
);
s.run("StaticPopup1WideEditBox:SetText(\"main tank\")")
.unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildRosterSetPublicNote:2:main tank");
assert!(!visible(&s, "StaticPopup1"), "accepting closes it");
}
/// The message of the day paints the CACHED value, is click-to-edit only for a rank that may set
/// it, and the GUILD_MOTD event repaints it without a roster round trip.
#[test]
fn the_motd_is_cached_click_to_edit_and_right_gated() {
let mut s = setup();
open(&s);
assert_eq!(text(&s, "GuildFrameNotesText"), "Raid Tuesday at eight.");
assert_eq!(
text(&s, "GuildFrameNotesLabel"),
"Guild Message Of The Day:"
);
assert!(enabled(&s, "GuildMOTDEditButton"));
s.run("BenillaGuildFixture.rights.editMOTD = nil; GuildStatus_Update()")
.unwrap();
assert!(
!enabled(&s, "GuildMOTDEditButton"),
"without the right the MOTD is not clickable"
);
assert_eq!(colour(&s, "GuildFrameNotesText"), (0.65, 0.65, 0.65));
s.run("BenillaGuildFixture.rights.editMOTD = 1; GuildStatus_Update()")
.unwrap();
let _ = calls(&s);
s.run("GuildMOTDEditButton:Click()").unwrap();
assert!(visible(&s, "StaticPopup1WideEditBox"));
assert_eq!(
text(&s, "StaticPopup1WideEditBox"),
"Raid Tuesday at eight.",
"the dialog opens on the cached MOTD"
);
s.run("StaticPopup1WideEditBox:SetText(\"Raid Wednesday.\")")
.unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildSetMOTD:Raid Wednesday.");
// The event is what actually repaints the pane — the roster never carries it back.
s.fire_event(
"GUILD_MOTD",
vec![ScriptValue::Str("Raid Thursday.".to_string())],
);
assert_eq!(text(&s, "GuildFrameNotesText"), "Raid Thursday.");
}
/// Guild Control is the guild MASTER's alone and Add Member follows the invite right — the two
/// pane-level buttons, both driven by the repaint rather than by a click.
#[test]
fn the_pane_buttons_follow_the_rights() {
let s = setup();
open(&s);
assert!(enabled(&s, "GuildFrameControlButton"));
assert!(enabled(&s, "GuildFrameAddMemberButton"));
s.run("BenillaGuildFixture.isLeader = nil; BenillaGuildFixture.rights.invite = nil; GuildStatus_Update()")
.unwrap();
assert!(
!enabled(&s, "GuildFrameControlButton"),
"rank control is the master's alone"
);
assert!(!enabled(&s, "GuildFrameAddMemberButton"));
// Add Member opens the name dialog, and it is the NARROW box (a name, not a sentence).
s.run("BenillaGuildFixture.rights.invite = 1; GuildStatus_Update()")
.unwrap();
let _ = calls(&s);
s.run("GuildFrameAddMemberButton:Click()").unwrap();
assert!(visible(&s, "StaticPopup1EditBox"));
assert!(!visible(&s, "StaticPopup1WideEditBox"));
assert_eq!(
s.eval::<f64>("return StaticPopup1:GetWidth()").unwrap(),
320.0
);
s.run("StaticPopup1EditBox:SetText(\"Thrall\")").unwrap();
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildInviteByName:Thrall");
}
/// The rank editor loads its thirteen checkboxes from the rank BUFFER, opens with Accept dead, and
/// arms it on the first edit. The checkbox IDs are the option indices the engine maps to bits, so
/// the click has to carry the index it was declared with.
#[test]
fn the_rank_editor_loads_its_flags_and_arms_accept_on_an_edit() {
let s = setup();
open(&s);
s.run("GuildFrameControlButton:Click()").unwrap();
assert!(visible(&s, "GuildControlPopupFrame"));
assert!(
!visible(&s, "GuildMemberDetailFrame"),
"the three satellites are mutually exclusive"
);
assert_eq!(
text(&s, "GuildControlPopupFrameCheckbox1Label"),
"Guildchat Listen"
);
assert_eq!(
text(&s, "GuildControlPopupFrameCheckbox13Label"),
"Modify Guild Info",
"all thirteen labels, in the reference's own checkbox order"
);
assert_eq!(text(&s, "GuildControlPopupFrameEditBox"), "Guild Master");
let checked = |n: i32| {
s.eval::<bool>(&format!(
"return GuildControlPopupFrameCheckbox{n}:GetChecked() and true or false"
))
.unwrap()
};
assert!(checked(1) && checked(4) && checked(7) && checked(11));
assert!(!checked(5) && !checked(6) && !checked(8) && !checked(13));
assert!(
!enabled(&s, "GuildControlPopupAcceptButton"),
"Accept is the buffer-is-dirty light; it opens dead"
);
let _ = calls(&s);
s.run("GuildControlPopupFrameCheckbox5:Click()").unwrap();
assert_eq!(
calls(&s),
// The Lua hands `this:GetChecked()` straight through, and in 1.12 that is the NUMBER 1
// (1830) — so 1 is what the reference's own call carries too.
"GuildControlSetRankFlag:5:1",
"the checkbox's own ID is what reaches the engine"
);
assert!(enabled(&s, "GuildControlPopupAcceptButton"));
// Accept flushes the buffer under the name in the box, then closes.
s.run("GuildControlPopupFrameEditBox:SetText(\"Warchief\")")
.unwrap();
let _ = calls(&s);
s.run("GuildControlPopupAcceptButton:Click()").unwrap();
assert!(
calls(&s).starts_with("GuildControlSaveRank:Warchief"),
"one flush, carrying the edited name"
);
assert!(!visible(&s, "GuildControlPopupFrame"));
}
/// Picking another rank in the dropdown re-loads the buffer, repaints the boxes from THAT rank's
/// flags, and re-disarms Accept — the edit you were half-way through does not follow you.
#[test]
fn switching_rank_reloads_the_buffer_and_disarms_accept() {
let s = setup();
open(&s);
s.run("GuildFrameControlButton:Click()").unwrap();
s.run("GuildControlPopupFrameCheckbox5:Click()").unwrap();
assert!(enabled(&s, "GuildControlPopupAcceptButton"));
// Through the real menu, not the handler: the dropdown's rows ARE the ranks, so the row's own
// ID is the rank the buffer loads, and calling the handler by hand would prove nothing about
// that wiring.
let _ = calls(&s);
s.run("ToggleDropDownMenu(1, nil, GuildControlPopupFrameDropDown)")
.unwrap();
assert_eq!(
text(&s, "DropDownList1Button3"),
"Veteran",
"the rows are the ranks, in rank order"
);
s.run("DropDownList1Button3:Click()").unwrap();
assert!(
calls(&s).contains("GuildControlSetRank:3"),
"the buffer is re-loaded from rank 3"
);
assert_eq!(text(&s, "GuildControlPopupFrameEditBox"), "Veteran");
assert!(
!s.eval::<bool>("return GuildControlPopupFrameCheckbox1:GetChecked() and true or false")
.unwrap(),
"rank 3 has no flags seeded, so every box clears"
);
assert!(
!enabled(&s, "GuildControlPopupAcceptButton"),
"switching rank throws the half-made edit away"
);
}
/// The add/remove rank buttons: ten is the ceiling, and the last rank can only be removed once it
/// is EMPTY — which is why the repaint counts `playersInBotRank` at all.
#[test]
fn the_rank_buttons_follow_the_count_and_the_bottom_rank() {
let s = setup();
open(&s);
s.run("GuildFrameControlButton:Click()").unwrap();
s.run("GuildControlPopupFrameAddRankButton_OnUpdate()")
.unwrap();
assert!(
enabled(&s, "GuildControlPopupFrameAddRankButton"),
"six ranks, room for four more"
);
s.run("BenillaGuildFixture.ranks = { \"a\",\"b\",\"c\",\"d\",\"e\",\"f\",\"g\",\"h\",\"i\",\"j\" }")
.unwrap();
s.run("GuildControlPopupFrameAddRankButton_OnUpdate()")
.unwrap();
assert!(
!enabled(&s, "GuildControlPopupFrameAddRankButton"),
"ten is the ceiling"
);
s.run("BenillaGuildFixture.ranks = { \"Guild Master\",\"Officer\",\"Veteran\",\"Member\",\"Initiate\",\"Peon\" }")
.unwrap();
// Remove only shows on the LAST rank, and only past five ranks.
s.run("UIDropDownMenu_SetSelectedID(GuildControlPopupFrameDropDown, 1); GuildControlPopupFrameRemoveRankButton_OnUpdate()")
.unwrap();
assert!(
!visible(&s, "GuildControlPopupFrameRemoveRankButton"),
"you can only ever remove the last rank"
);
s.run("UIDropDownMenu_SetSelectedID(GuildControlPopupFrameDropDown, 6); GuildControlPopupFrameRemoveRankButton_OnUpdate()")
.unwrap();
assert!(visible(&s, "GuildControlPopupFrameRemoveRankButton"));
assert!(
!enabled(&s, "GuildControlPopupFrameRemoveRankButton"),
"Kaplan still sits in the bottom rank"
);
// Move him up and repaint: the counter falls to zero and the button arms.
s.run("BenillaGuildFixture.members[3].rankIndex = 4; GuildStatus_Update()")
.unwrap();
s.run("GuildControlPopupFrameRemoveRankButton_OnUpdate()")
.unwrap();
assert!(enabled(&s, "GuildControlPopupFrameRemoveRankButton"));
let _ = calls(&s);
s.run("GuildControlPopupFrameRemoveRankButton:Click()")
.unwrap();
assert!(
calls(&s).starts_with("GuildControlDelRank:Peon"),
"the LAST rank's name is what goes"
);
}
/// Every column header sorts by its own key, in BOTH views — eight headers over one roster, and a
/// sort is a repaint, never a re-request.
#[test]
fn the_column_headers_sort_by_their_own_keys() {
let s = setup();
open(&s);
for (header, key) in [
("GuildFrameColumnHeader1", "name"),
("GuildFrameColumnHeader2", "zone"),
("GuildFrameColumnHeader3", "level"),
("GuildFrameColumnHeader4", "class"),
] {
let _ = calls(&s);
s.run(&format!("{header}:Click()")).unwrap();
assert_eq!(calls(&s), format!("SortGuildRoster:{key}"));
}
s.run("GuildFrameGuildListToggleButton:Click()").unwrap();
for (header, key) in [
("GuildFrameGuildStatusColumnHeader1", "name"),
("GuildFrameGuildStatusColumnHeader2", "rank"),
("GuildFrameGuildStatusColumnHeader3", "note"),
("GuildFrameGuildStatusColumnHeader4", "online"),
] {
let _ = calls(&s);
s.run(&format!("{header}:Click()")).unwrap();
assert_eq!(calls(&s), format!("SortGuildRoster:{key}"));
}
}
/// **The Show Offline Members checkbox EXISTS.** The reference declares it `virtual="true"` inside
/// a `<Frames>` block, which reads like "this is a template, do not build it" — and is not: the
/// `virtual` attribute is only consulted by the top-level file loader (wow-5875-re rf24,
/// `0x6ede10`), while a `<Frames>` child goes straight to the instantiator via `LoadChildFrames`
/// (rf26). So the box is a real frame in the reference and must be one here.
///
/// Its click also DROPS THE SELECTION before re-filtering: the roster is about to be re-ordered,
/// so index 7 will not be the member index 7 was.
#[test]
fn the_show_offline_checkbox_is_real_and_drops_the_selection() {
let s = setup();
open(&s);
assert!(
s.eval::<bool>("return GuildFrameLFGButton ~= nil").unwrap(),
"the reference's `virtual=` on a <Frames> child does not suppress the frame"
);
assert_eq!(text(&s, "GuildFrameLFGButtonText"), "Show Offline Members");
s.run("GuildFrameButton2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return GetGuildRosterSelection()").unwrap(),
2
);
let _ = calls(&s);
s.run("GuildFrameLFGButton:Click()").unwrap();
let seen = calls(&s);
assert!(
seen.starts_with("SetGuildRosterSelection:0"),
"the selection is dropped FIRST, before the filter changes: {seen}"
);
assert!(
seen.contains("SetGuildRosterShowOffline:"),
"…and the filter really is pushed: {seen}"
);
}
/// The guild-information board is read-only without the right: grey text, a dead Accept, and the
/// stored text rather than the click-here invitation.
#[test]
fn the_guild_information_board_is_right_gated() {
let s = setup();
open(&s);
s.run("GuildFrameGuildInformationButton:Click()").unwrap();
assert!(visible(&s, "GuildInfoFrame"));
assert_eq!(text(&s, "GuildInfoEditBox"), "Be excellent to each other.");
assert!(enabled(&s, "GuildInfoSaveButton"));
let _ = calls(&s);
s.run("GuildInfoEditBox:SetText(\"Read the rules.\")")
.unwrap();
s.run("GuildInfoSaveButton:Click()").unwrap();
let seen = calls(&s);
assert!(
seen.starts_with("SetGuildInfoText:Read the rules."),
"{seen}"
);
assert!(
seen.contains("GuildRoster"),
"saving asks for the roster back: {seen}"
);
assert!(!visible(&s, "GuildInfoFrame"), "…and closes");
// Without the right: grey, dead, and no placeholder even when empty.
s.run("BenillaGuildFixture.rights.editGuildInfo = nil; BenillaGuildFixture.infoText = \"\"")
.unwrap();
s.run("ToggleGuildInfoFrame()").unwrap();
assert!(visible(&s, "GuildInfoFrame"));
assert_eq!(text(&s, "GuildInfoEditBox"), "");
assert!(!enabled(&s, "GuildInfoSaveButton"));
assert_eq!(colour(&s, "GuildInfoEditBox"), (0.65, 0.65, 0.65));
// …and with the right, an empty board shows the invitation.
s.run("ToggleGuildInfoFrame()").unwrap();
s.run("BenillaGuildFixture.rights.editGuildInfo = 1")
.unwrap();
s.run("ToggleGuildInfoFrame()").unwrap();
assert_eq!(text(&s, "GuildInfoEditBox"), "Click here to set message");
}
/// The three satellite windows are mutually exclusive — each one's opener shuts the other two —
/// and closing the social window takes all three with it, since none of them is its child.
#[test]
fn the_three_satellites_are_exclusive_and_close_with_the_window() {
let s = setup();
open(&s);
s.run("GuildFrameButton2:Click()").unwrap();
assert!(visible(&s, "GuildMemberDetailFrame"));
s.run("GuildFrameGuildInformationButton:Click()").unwrap();
assert!(visible(&s, "GuildInfoFrame"));
assert!(!visible(&s, "GuildMemberDetailFrame"));
s.run("GuildFrameControlButton:Click()").unwrap();
assert!(visible(&s, "GuildControlPopupFrame"));
assert!(!visible(&s, "GuildInfoFrame"));
s.run("GuildFrameButton2:Click()").unwrap();
assert!(visible(&s, "GuildMemberDetailFrame"));
assert!(!visible(&s, "GuildControlPopupFrame"));
s.run("HideUIPanel(FriendsFrame)").unwrap();
assert!(!visible(&s, "GuildMemberDetailFrame"));
assert!(!visible(&s, "GuildControlPopupFrame"));
assert!(!visible(&s, "GuildInfoFrame"));
assert!(!visible(&s, "GuildFrame"));
}
/// `GUILD_ROSTER_UPDATE`'s `arg1` is the STALE flag, and it is the difference between a repaint and
/// a wire round-trip: a column-header sort fires this event without it, and re-requesting there
/// would put a server trip behind every click on a header.
#[test]
fn the_roster_event_only_re_requests_when_told_the_roster_is_stale() {
let mut s = setup();
open(&s);
let _ = calls(&s);
s.fire_event("GUILD_ROSTER_UPDATE", vec![]);
assert!(
!calls(&s).contains("GuildRoster"),
"a plain repaint must not ask the server again"
);
s.fire_event("GUILD_ROSTER_UPDATE", vec![ScriptValue::Int(1)]);
assert!(
calls(&s).contains("GuildRoster"),
"…but a STALE roster is re-requested"
);
// With the pane closed the event does nothing at all — the arm's own visibility gate.
s.run("HideUIPanel(FriendsFrame)").unwrap();
let _ = calls(&s);
s.fire_event("GUILD_ROSTER_UPDATE", vec![ScriptValue::Int(1)]);
assert_eq!(calls(&s), "", "the pane is closed; nothing repaints");
}
/// Leaving the guild while STANDING on the guild tab falls back to tab 1 rather than leaving a
/// pane with no guild behind it on screen — the half of `InGuildCheck` that is easy to omit and
/// impossible to notice until it happens.
#[test]
fn losing_the_guild_falls_back_off_the_guild_tab() {
let mut s = setup();
open(&s);
assert!(visible(&s, "GuildFrame"));
s.run("BenillaGuildFixture.inGuild = nil").unwrap();
s.fire_event("PLAYER_GUILD_UPDATE", vec![]);
assert_eq!(
s.eval::<i64>("return FriendsFrameTab3.isDisabled or 0")
.unwrap(),
1,
"the tab greys again"
);
assert!(!visible(&s, "GuildFrame"), "the pane goes");
assert!(
visible(&s, "FriendsListFrame"),
"and the window falls back to the friends list"
);
// The tab also refuses to be selected by number while guildless (the second lock).
s.run("ToggleFriendsFrame(3)").unwrap();
assert!(!visible(&s, "GuildFrame"));
assert!(visible(&s, "FriendsListFrame"));
// Joining one re-arms it.
s.run("BenillaGuildFixture.inGuild = 1").unwrap();
s.fire_event("PLAYER_GUILD_UPDATE", vec![]);
assert_eq!(
s.eval::<i64>("return FriendsFrameTab3.isDisabled or 0")
.unwrap(),
0
);
s.run("FriendsFrameTab3:Click()").unwrap();
assert!(
visible(&s, "GuildFrame"),
"and the tab's own OnClick opens the pane — it had none at all before this arc"
);
}
/// A right-click on a roster row opens the shared FRIEND menu with the two GUILD rows live. They
/// are gated on this pane being visible, which is what keeps them off a friends-list or /who row.
#[test]
fn right_clicking_a_roster_row_offers_the_guild_rows() {
let s = setup();
open(&s);
s.run("GuildFrameButton2:Click(\"RightButton\")").unwrap();
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the menu opens"
);
assert_eq!(
s.eval::<String>("return FriendsDropDown.name").unwrap(),
"Furor",
"addressed by name — a roster row has no unit token behind it"
);
let row = |value: &str| {
format!(
r#"
for i = 1, UIDROPDOWNMENU_MAXBUTTONS do
local b = getglobal("DropDownList1Button" .. i)
if b and b:IsVisible() and b.value == "{value}" then b:Click() return 1 end
end
return nil"#
)
};
let _ = calls(&s);
assert_eq!(
s.eval::<Option<i64>>(&row("GUILD_PROMOTE")).unwrap(),
Some(1),
"the guild master sees Promote on someone else's row"
);
assert!(visible(&s, "StaticPopup1"));
assert_eq!(
text(&s, "StaticPopup1Text"),
"Really promote Furor to Guildmaster?"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildSetLeaderByName:Furor");
// GUILD_LEAVE is only ever offered on YOURSELF…
s.run("GuildFrameButton1:Click(\"RightButton\")").unwrap();
assert_eq!(
s.eval::<Option<i64>>(&row("GUILD_PROMOTE")).unwrap(),
None,
"…and Promote never is"
);
s.run("GuildFrameButton1:Click(\"RightButton\")").unwrap();
let _ = calls(&s);
assert_eq!(s.eval::<Option<i64>>(&row("GUILD_LEAVE")).unwrap(), Some(1));
assert_eq!(
text(&s, "StaticPopup1Text"),
"Really leave Legacy of Steel?"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildLeave");
}
/// The same two rows are ABSENT from a `/who` row's menu, which opens the identical FRIEND menu.
/// That gate is `GuildFrame:IsVisible()`, and without it a guild verb would sit on every name in
/// the game.
#[test]
fn the_guild_rows_stay_off_a_who_row_menu() {
let s = setup();
s.run("ShowWhoPanel()").unwrap();
// NOT our own name: the WHISPER row hides on yourself, and with INVITE gated the same way the
// menu would have nothing to show and never open — which would make the assertion below pass
// for the wrong reason.
s.run("FriendsFrame_ShowDropdown(\"Thrall\", 1)").unwrap();
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
let present = r#"
for i = 1, UIDROPDOWNMENU_MAXBUTTONS do
local b = getglobal("DropDownList1Button" .. i)
if b and b:IsVisible() and (b.value == "GUILD_LEAVE" or b.value == "GUILD_PROMOTE") then
return 1
end
end
return nil"#;
assert_eq!(
s.eval::<Option<i64>>(present).unwrap(),
None,
"the guild pane is not up, so neither guild row is"
);
}
/// A guild invite raises the accept/decline dialog wherever you are — it does not need the social
/// window to have ever been opened, which is why it rides its own hidden driver frame.
#[test]
fn a_guild_invite_raises_its_dialog_without_the_window() {
let mut s = setup();
assert!(!visible(&s, "FriendsFrame"), "the window is shut");
let _ = calls(&s);
s.fire_event(
"GUILD_INVITE_REQUEST",
vec![
ScriptValue::Str("Furor".to_string()),
ScriptValue::Str("Legacy of Steel".to_string()),
],
);
assert!(visible(&s, "StaticPopup1"));
assert_eq!(
text(&s, "StaticPopup1Text"),
"Furor invites you to join Legacy of Steel"
);
assert_eq!(text(&s, "StaticPopup1Button1"), "Accept");
assert_eq!(text(&s, "StaticPopup1Button2"), "Decline");
s.run("StaticPopup1Button2:Click()").unwrap();
assert_eq!(
calls(&s),
"DeclineGuild",
"Cancel DECLINES, it does not drop"
);
s.fire_event(
"GUILD_INVITE_REQUEST",
vec![
ScriptValue::Str("Furor".to_string()),
ScriptValue::Str("Legacy of Steel".to_string()),
],
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "AcceptGuild");
// A withdrawn invite takes its dialog with it.
s.fire_event(
"GUILD_INVITE_REQUEST",
vec![
ScriptValue::Str("Furor".to_string()),
ScriptValue::Str("Legacy of Steel".to_string()),
],
);
assert!(visible(&s, "StaticPopup1"));
s.fire_event("GUILD_INVITE_CANCEL", vec![]);
assert!(!visible(&s, "StaticPopup1"));
}
/// Removing a member goes behind a confirm that NAMES them — the registry line carries a
/// placeholder and OnShow splices the real name in, which is the one dialog in this file whose
/// text is rewritten on show.
#[test]
fn removing_a_member_names_them_in_the_confirm() {
let s = setup();
open(&s);
s.run("GuildFrameButton3:Click()").unwrap();
let _ = calls(&s);
s.run("GuildMemberRemoveButton:Click()").unwrap();
assert_eq!(
text(&s, "StaticPopup1Text"),
"Are you sure you want to remove Kaplan from the guild?"
);
s.run("StaticPopup1Button1:Click()").unwrap();
assert_eq!(calls(&s), "GuildUninviteByName:Kaplan");
assert!(
!visible(&s, "GuildMemberDetailFrame"),
"the card goes with the member"
);
}
/// **`GuildControlGetRankFlags` really answers THIRTEEN values, and the count survives the
/// 5.0→5.1 vararg swap.** The one place the window and the engine are coupled by *arity* rather
/// than by a name.
///
/// The reference walks the 5.0 vararg table with `for i = 1, arg.n`; ours walks
/// `for i = 1, select("#", ...)`. The two agree only while every one of the thirteen returns is
/// actually pushed — and with no rank loaded, ALL THIRTEEN ARE NIL (era booleans are `1`/`nil`).
/// A binding that returned "as many values as are true" would look identical at every other
/// assertion in this file, load without an error, and leave checkboxes stale from the last rank.
/// So this one deliberately does NOT install the fixture: it asks the real `script::guild`.
#[test]
fn the_rank_flags_binding_answers_thirteen_values_even_when_all_are_nil() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select(\"#\", GuildControlGetRankFlags())")
.unwrap(),
13,
"an unloaded buffer is all-nil, and every one of the thirteen must still be pushed — \
`GuildControlCheckboxUpdate` drives checkbox i off argument i and nothing else"
);
// …and each of them is nil, not `false`: the era boolean law, which `SetChecked` reads.
assert_eq!(
s.eval::<i64>(
"local n = 0 \
for i = 1, 13 do if select(i, GuildControlGetRankFlags()) ~= nil then n = n + 1 end end \
return n"
)
.unwrap(),
0
);
}
/// **Each `Can*` predicate reads the bit its checkbox owns, end to end.**
///
/// `RANK_RIGHT_BITS`' own unit test asserts the table as *data*. That is necessary and not
/// sufficient: a wrong table is only a bug because the predicates read it, and a mutation that
/// renumbered Promote/Demote/Invite/Remove into the naive contiguous layout left every window test
/// in this file green — they drive the fixture's stand-ins, not the engine's predicates. So this
/// one pushes a real snapshot with exactly ONE right set and asks the real globals.
///
/// The four it checks are precisely the four the naive `1 << (i - 1)` layout would swap, which is
/// what makes it a falsifier rather than a restatement.
#[test]
fn each_permission_predicate_reads_the_bit_its_checkbox_owns() {
// Index 7, "Invite Member" — bit 0x10. A shift-based table would put 0x10 at index 5,
// "Promote", so this single word separates the two layouts in both directions.
let mut s = UiScript::new().unwrap();
s.set_guild(GuildState {
in_guild: true,
rights: 0x0000_0010,
..Default::default()
});
assert_eq!(
s.eval::<i64>("return CanGuildInvite() and 1 or 0").unwrap(),
1,
"0x10 is Invite Member (checkbox 7)"
);
for global in ["CanGuildPromote", "CanGuildDemote", "CanGuildRemove"] {
assert_eq!(
s.eval::<i64>(&format!("return {global}() and 1 or 0"))
.unwrap(),
0,
"{global} must not read 0x10 — that is Invite's bit, and confusing the two is exactly \
what the non-monotonic table exists to prevent"
);
}
// Index 5, "Promote" — bit 0x80, which the naive layout would call Remove Member.
s.set_guild(GuildState {
in_guild: true,
rights: 0x0000_0080,
..Default::default()
});
assert_eq!(
s.eval::<i64>("return CanGuildPromote() and 1 or 0")
.unwrap(),
1,
"0x80 is Promote (checkbox 5), NOT Remove Member"
);
assert_eq!(
s.eval::<i64>("return CanGuildRemove() and 1 or 0").unwrap(),
0
);
// And a guildless player holds no right at all, whatever the stale word says.
s.set_guild(GuildState {
in_guild: false,
rights: u32::MAX,
..Default::default()
});
for global in ["CanGuildInvite", "CanGuildPromote", "CanEditMOTD"] {
assert_eq!(
s.eval::<i64>(&format!("return {global}() and 1 or 0"))
.unwrap(),
0,
"{global} is false for a guildless player regardless of the rights word"
);
}
}
/// **`GetGuildRosterInfo` really answers TEN values, and the tenth is `status`.** The second
/// place the window and the engine are coupled by arity, and the one that nearly shipped wrong.
///
/// Six of the reference's seven call sites destructure only nine (`FriendsFrame.lua:344`, `:447`,
/// `:479`, `:709`, `StaticPopup.lua:1008`, `:1045`). Only the player-status view takes the tenth
/// (`:541`) and branches on `status == ""` to choose between the "Online" label and the
/// `<AFK>`/`<DND>` tag (`:548-551`). So a nine-value binding leaves exactly one column blank while
/// every other assertion in this file — and every other window — still passes.
///
/// Like the rank-flags falsifier above, this deliberately does NOT install the fixture: the
/// fixture returns ten by construction, so asking it would prove nothing. It asks the real
/// `script::guild`, with an empty roster, where every return is nil and only the *count* is
/// evidence.
#[test]
fn the_roster_binding_answers_ten_values_and_the_tenth_is_status() {
let s = UiScript::new().unwrap();
assert_eq!(
s.eval::<i64>("return select(\"#\", GetGuildRosterInfo(1))")
.unwrap(),
10,
"an out-of-range index still pushes all ten — the reference calls this with \
GetGuildRosterSelection(), which is 0 whenever nothing is selected, on every \
GuildStatus_Update pass before it ever checks `> 0`"
);
assert_eq!(
s.eval::<i64>("return select(\"#\", GetGuildRosterInfo(0))")
.unwrap(),
10,
"…including index 0, the nothing-selected case the reference passes unguarded"
);
}
/// The whole slice runs clean: no Lua error reaches the session's error sink through any of the
/// paths above. A handler that raises mid-way still leaves the frames it already touched looking
/// right, so an assertion on the look is not enough on its own.
#[test]
fn driving_the_guild_windows_raises_no_script_errors() {
let mut s = setup();
open(&s);
s.run("GuildFrameButton1:Click()").unwrap();
s.run("GuildFrameGuildListToggleButton:Click()").unwrap();
s.run("GuildFrameGuildStatusButton2:Click()").unwrap();
s.run("GuildFrameGuildListToggleButton:Click()").unwrap();
s.run("GuildFrameControlButton:Click()").unwrap();
s.run("GuildControlPopupFrameCheckbox2:Click()").unwrap();
s.run("GuildControlPopupFrameCancelButton:Click()").unwrap();
s.run("GuildFrameGuildInformationButton:Click()").unwrap();
s.run("GuildInfoCancelButton:Click()").unwrap();
s.fire_event("GUILD_ROSTER_UPDATE", vec![ScriptValue::Int(1)]);
s.fire_event("GUILD_MOTD", vec![ScriptValue::Str("hi".to_string())]);
s.run("HideUIPanel(FriendsFrame)").unwrap();
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Every list in this window scrolls with the wheel — friends, ignore, who AND guild.
///
/// The guild pane shipped without its `OnMouseWheel` while the comment three lines above its own
/// scroll frame said the list was "driven by `updateFunc` + the pane's OnMouseWheel", and while
/// its three siblings each carried one. A gap that the surrounding prose asserts is closed is
/// invisible to reading, so this asserts the WIRING rather than the comment.
#[test]
fn every_list_in_the_window_takes_the_mouse_wheel() {
let s = setup();
open(&s);
for frame in [
"FriendsListFrame",
"IgnoreListFrame",
"WhoFrame",
"GuildFrame",
] {
assert!(
s.eval::<bool>(&format!(
"return {frame}:GetScript(\"OnMouseWheel\") ~= nil"
))
.unwrap(),
"{frame} has no OnMouseWheel — its list cannot be scrolled with the wheel"
);
}
}