the guild window opens: roster, ranks, notes and the message of the day

- the social window's third tab is live — the roster in both its views, the member card with its promote/demote/remove verbs, the rank editor's thirteen permissions, the guild information board, and the invite, leave, kick and note dialogs behind them
- setting your hearthstone at an innkeeper now asks before it takes and says so afterwards, and the innkeeper's gossip lines get their own icons back
- unavailable talents grey out, and every greyed icon in the client now goes through the same luminance fold the original used
- the buff tooltip's remaining-time line reads gold with the client's own hour/minute ladder, the macro editor takes a shift-click to paste a spell, trainer rows repaint after a purchase, and a long book page stops flickering its ellipsis

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-16 17:38:54 +07:00
co-authored by Claude Opus 5
parent 04340a6893
commit 8d3d805ec8
78 changed files with 13155 additions and 271 deletions
@@ -0,0 +1,105 @@
//! The innkeeper bind confirm (decision 1331, BinderConfirm.xml): the dialog `ui_binder`'s feed
//! raises, its Accept, and the range poll that takes it away — driven exactly as that feed and the
//! app's NPC-session guard drive it.
use benilla_ui::script::{ScriptValue, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the duel tests'
/// loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) -> usize {
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
);
report.frames
}
/// The app's own pre-state: a question is pending and in range, which is what
/// `CheckBinderDist()` reports while the dialog is up.
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, "BinderConfirm.xml");
s.set_binder_pending(true);
s
}
/// `CONFIRM_BINDER(area)` raises the dialog with the area name filled in, and Accept queues the
/// one `ConfirmBinder()` that becomes `CMSG_BINDER_ACTIVATE`. The whole point of the arc: before
/// this wiring the click produced no dialog and no packet at all (B249).
#[test]
fn the_confirm_shows_the_area_and_accept_queues_the_bind() {
let mut s = setup();
s.fire_event(
"CONFIRM_BINDER",
vec![ScriptValue::Str("Dolanaar".to_string())],
);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"CONFIRM_BINDER shows the dialog"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Do you want to make Dolanaar your new home?",
"arg1 fills the GlobalStrings template's %s"
);
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
assert_eq!(s.take_binder_confirms(), 1);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"accepting closes it"
);
}
/// Cancel and ESC both send **nothing**: declining an innkeeper is silent on the wire (there is
/// no decline opcode), so the only observable is that no confirm was queued.
#[test]
fn declining_sends_nothing() {
let mut s = setup();
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Goldshire".into())]);
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
assert_eq!(s.take_binder_confirms(), 0);
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Goldshire".into())]);
s.run("ToggleGameMenu()").unwrap();
assert_eq!(s.take_binder_confirms(), 0);
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"hideOnEscape takes it down"
);
}
/// Walking away takes the question off screen: the entry's OnUpdate polls `CheckBinderDist()`,
/// which the app drives from the shared NPC-session range guard. While it holds, ticking changes
/// nothing; the frame it goes false, the dialog hides itself — with no packet either way.
#[test]
fn leaving_the_innkeepers_range_hides_the_confirm() {
let mut s = setup();
s.fire_event("CONFIRM_BINDER", vec![ScriptValue::Str("Kharanos".into())]);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"in range, the tick leaves it alone"
);
s.set_binder_pending(false);
s.run("StaticPopup_OnUpdate(StaticPopup1, 0.1)").unwrap();
assert!(
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"out of range, the dialog takes itself down"
);
assert_eq!(s.take_binder_confirms(), 0, "and sends nothing");
}
@@ -749,3 +749,89 @@ fn the_temporary_enchant_row_shows_a_weapon_enchant_and_moves_the_top_row_aside(
assert!((s.eval::<f64>("return BuffFrame:GetRight()").unwrap() - resting).abs() < 1e-3);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The duration line against the **real shipped strings**, end to end — the leg the engine's own
/// synthetic-template tests cannot reach.
///
/// The engine formats through `tooltip::duration_text` (`0x52fa50`'s ladder, wow-re
/// §3-BUFF-TIME-FORMAT) over whatever `GlobalStrings.lua` the player's install carries; this runs
/// that file into a real VM exactly as the boot's `load_global_strings` does, then asserts the
/// wording that comes back. Every expectation below is a reading the PREVIOUS two-arm formatter got
/// wrong, so this is the carve's whole delta in one place. Skips without client data.
#[test]
fn the_duration_line_reads_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(&String::from_utf8_lossy(&src)).expect("runs clean");
s.set_spell_tooltip(
1459,
benilla_ui::script::SpellTooltipView {
name: "Arcane Intellect".into(),
aura_description: "Intellect increased by 2.".into(),
..Default::default()
},
);
s.tick(10.0); // GetTime = 10
s.run(
r#"
BENILLA_ANCHOR = CreateFrame("Button", "BF9")
BENILLA_ANCHOR:SetPoint("CENTER", 0, 0); BENILLA_ANCHOR:SetSize(10, 10)
BENILLA_TIP = CreateFrame("GameTooltip", "TT9")
"#,
)
.unwrap();
// `secs_left` seconds remaining at GetTime = 10, read back as the tooltip's last line.
let mut line = |secs_left: f64| -> String {
s.set_auras(
"player",
Some(vec![AuraState {
spell_id: 1459,
name: Some("Arcane Intellect".into()),
duration: 86_400.0,
expiration_time: 10.0 + secs_left,
helpful: true,
..Default::default()
}]),
);
s.run(
r#"
BENILLA_TIP:SetOwner(BENILLA_ANCHOR, "ANCHOR_RIGHT")
BENILLA_TIP:SetPlayerBuff(0)
BENILLA_LAST = TT9TextLeft3:GetText()
"#,
)
.unwrap();
s.eval::<String>("return BENILLA_LAST").unwrap()
};
// The headline divergence: anything past an hour was reading in MINUTES.
assert_eq!(line(7_200.0), "2 hours remaining", "was '120 minutes'");
assert_eq!(line(3_600.0), "1 hour remaining", "the hour edge, singular");
assert_eq!(
line(3_599.999),
"60 minutes remaining",
"one ms under the hour stays in minutes — no '1 hour' until it is whole"
);
// The singular half: the shipped pair really is "%d minute" / "%d minutes".
assert_eq!(line(60.0), "1 minute remaining", "was '1 minutes'");
assert_eq!(line(61.0), "2 minutes remaining", "the minute arm ceils");
// The seconds arm truncates where every arm above it ceils.
assert_eq!(line(5.4), "5 seconds remaining", "was '6 seconds'");
assert_eq!(line(1.0), "1 second remaining", "singular at exactly one");
assert_eq!(
line(-0.4),
"0 seconds remaining",
"the lapsing read, plural at zero"
);
// And the top of the ladder, which had no arm at all.
assert_eq!(line(129_600.0), "2 days remaining", "was '2160 minutes'");
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
@@ -29,6 +29,65 @@ fn ui_cost_enabled() -> bool {
*ON.get_or_init(|| std::env::var("WOW_UI_COST").as_deref() == Ok("1"))
}
/// `WOW_UI_GATE=1` — the extract gate's miss reporter ([`report_gate_miss`]). Off by default and
/// read once, the `[ui-cost]` meter's posture.
fn gate_log_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_UI_GATE").as_deref() == Ok("1"))
}
/// One line naming why the extract gate did not skip this frame — the input that differed, and for
/// the render list the first entry that moved. A settled UI is what makes the paint pass free; when
/// it is never settled, this says what is moving.
fn report_gate_miss(
script: &UiScript,
now: &[benilla_ui::script::ExtractedQuad],
prev: &[benilla_ui::script::ExtractedQuad],
dims_eq: bool,
text_ui_eq: bool,
portraits_eq: bool,
) {
if !dims_eq {
eprintln!("[ui-gate] miss: window/scale changed");
return;
}
if !text_ui_eq {
eprintln!("[ui-gate] miss: focused editbox text-UI changed");
return;
}
if !portraits_eq {
eprintln!("[ui-gate] miss: portrait sources changed");
return;
}
if now.len() != prev.len() {
eprintln!(
"[ui-gate] miss: render list {} -> {} entries",
prev.len(),
now.len()
);
return;
}
match now.iter().zip(prev).position(|(a, b)| a != b) {
Some(i) => {
let (a, b) = (&now[i], &prev[i]);
let (was, now_s) = (format!("{b:?}"), format!("{a:?}"));
let owner = match a.target {
benilla_ui::order::ZTarget::Frame(fh) => script.frame_name(fh),
benilla_ui::order::ZTarget::Region(_) => None,
};
eprintln!(
"[ui-gate] miss: entry {i}/{} target={:?} name={owner:?}\n was {}\n now {}",
now.len(),
a.target,
&was[..was.len().min(280)],
&now_s[..now_s.len().min(280)],
);
}
// Nothing in this pass's own inputs moved — the miss came from the capture-mode arm.
None => eprintln!("[ui-gate] miss: capture mode (the gate never skips under a capture)"),
}
}
/// Last frame's extract-gate inputs (decision 0740), held together as one `Local` — the gate
/// compares all four or none, and a Bevy system has a hard param budget this was eating.
#[derive(Default)]
@@ -375,6 +434,19 @@ pub(super) fn drive_script(
}
return;
}
// `WOW_UI_GATE=1` — why the gate MISSED. A skip is worth the whole conversion, so the
// question "what moved?" is where a per-frame cost always ends, and a `Vec` inequality does
// not answer it. Off by default, like the `[ui-cost]` meter beside it.
if gate_log_enabled() {
report_gate_miss(
&script,
&extracted,
&prev.extracted,
prev.dims == Some(dims),
text_ui == prev.text_ui,
booths.images.0 == prev.portraits,
);
}
prev.dims = Some(dims);
prev.text_ui = text_ui.clone();
prev.portraits = booths.images.0.clone();
@@ -436,6 +508,7 @@ pub(super) fn drive_script(
circular,
portrait_unit,
rotation,
desaturated,
} => {
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
@@ -555,6 +628,7 @@ pub(super) fn drive_script(
// The engine's SetRotation is counterclockwise-positive; the quad pass spins
// clockwise-on-screen (`UiQuad::rotation`) — negate to convert.
rotation: -rotation,
desaturated,
..default()
});
}
@@ -155,6 +155,7 @@ pub(super) fn emit(
color: [hc[0], hc[1], hc[2], hc[3] * host.alpha],
additive: false,
circular: false,
desaturated: false,
clip: text_clip,
rotation: 0.0,
mask: None,
@@ -171,10 +172,11 @@ pub(super) fn emit(
// gate inside is geometric and needs no fixed-vs-measured flag. Computed before the shadow
// so the shadow pass draws the same display string (the client's shadow is a second draw of
// the same truncated CGxString).
let ellipsized = if ebox.is_none() && !matches!(host.target, ZTarget::Frame(_)) {
crate::ui_text::ellipsize_to_fit(atlas, draw_text, draw_rect, spec)
} else {
None
let ellipsized = match host.target {
ZTarget::Region(region) if ebox.is_none() => {
crate::ui_text::ellipsize_to_fit(atlas, region, draw_text, draw_rect, spec)
}
_ => None,
};
if let Some(display) = ellipsized.as_deref() {
draw_text = display;
@@ -339,6 +341,7 @@ pub(super) fn emit(
color: [1.0, 1.0, 1.0, host.alpha],
additive: false,
circular: false,
desaturated: false,
clip: text_clip,
rotation: 0.0,
mask: None,
@@ -55,6 +55,11 @@ fn setup() -> UiScript {
// the manifest includes it (it loads well before FriendsFrame.xml in the real order).
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "ScrollTemplates.xml");
// The guild pane's frames inherit the reference's shared UIPanelButtonTemplate /
// UIPanelCloseButton / UIPanelScrollFrameTemplate rather than a private copy (decision 1257),
// so this file's slice needs the shared kit — and the strict `load_xml` above is what would
// otherwise let those frames load art-less and silent.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "FriendsFrame.xml");
s
}
@@ -76,8 +81,17 @@ fn push(s: &mut UiScript, state: SocialState, event: &str) {
s.fire_event(event, Vec::new());
}
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — which is
/// what a character in no guild sees in the reference, and what we always are.
/// The window loads, opens on the Friends tab, and the GUILD tab comes up disabled — the
/// GUILDLESS case of `InGuildCheck`.
///
/// **This assertion changed meaning with decision 1257 and was kept rather than deleted.** It used
/// to pin a hardcoded `PanelTemplates_DisableTab(this, 3)`, because there was no guild arc and the
/// tab was permanently dead. The tab is live now, and this is the other half of its law: with no
/// guild seated in the engine, `IsInGuild()` answers nil, `FriendsFrame_OnShow` runs
/// `InGuildCheck()`, and the tab greys — which is exactly what the reference does for a character
/// in no guild. The IN-guild half is `guild_tests`. Note that this file seats no guild fixture at
/// all: it is asserting against the REAL `script::guild` bindings' empty state, which is the state
/// every character starts a session in.
#[test]
fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
let s = setup();
@@ -91,12 +105,27 @@ fn the_window_opens_on_friends_with_the_guild_tab_disabled() {
.unwrap(),
"Friends List"
);
assert_eq!(
s.eval::<i64>("return IsInGuild() and 1 or 0").unwrap(),
0,
"no guild is seated, which is what makes the next assertion mean something"
);
assert_eq!(
s.eval::<i64>("return FriendsFrameTab3.isDisabled or 0")
.unwrap(),
1,
"the guild tab is disabled, not absent"
);
// …and the tab refuses to be reached by NUMBER either, which is the second of the two locks
// (`ToggleFriendsFrame`'s own `tab == 3 and not IsInGuild()` early-out).
s.run("ToggleFriendsFrame(3)").unwrap();
assert!(
!s.eval::<bool>("return GuildFrame:IsVisible()").unwrap(),
"a guildless character cannot open the guild pane at all"
);
assert!(s
.eval::<bool>("return FriendsListFrame:IsVisible()")
.unwrap());
// The two tab kinds are different templates and must not be swapped: the strip along the
// bottom is the big window tab (20px end slices), the Friends/Ignore pair inside tab 1 is the
// ref's compact TabButtonTemplate (16px). Getting this wrong is invisible to every behaviour
@@ -788,9 +817,13 @@ fn the_window_geometry_matches_the_reference_framexml() {
"FriendsFrameFriendsScrollFrame",
"FriendsFrameIgnoreScrollFrame",
"WhoListScrollFrame",
// No UIPanelCloseButton template ships here, so ours states the 32x32 the ref inherits.
// …and the guild pane's, the fourth of the same kind (decision 1257).
"GuildListScrollFrame",
// The window's own close button predates UIPanelTemplates.xml shipping the reference's
// `UIPanelCloseButton`, so it states inline the 32x32 that template confers. The guild
// pane's two close buttons inherit it and therefore need no entry here.
"FriendsFrameCloseButton",
];
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 61);
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 210);
}
@@ -607,16 +607,16 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
s.resolve();
// The backpack icon + the four slot rings, by name.
let art = |s: &UiScript, owner: &str| -> Vec<(String, Option<[f32; 4]>)> {
let art = |s: &UiScript, owner: &str| -> Vec<(String, bool)> {
s.extract()
.into_iter()
.filter(|eq| s.quad_owner_name(eq.target).as_deref() == Some(owner))
.filter_map(|eq| match &eq.content {
benilla_ui::script::QuadContent::Texture {
path: Some(p),
color,
desaturated,
..
} => Some((p.clone(), *color)),
} => Some((p.clone(), *desaturated)),
_ => None,
})
.collect()
@@ -650,7 +650,8 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
"{owner} must still DRAW under the open menu — greyed is not gone"
);
}
// The backpack icon specifically: still its own art, and tinted to SetDesaturation's grey.
// The backpack icon specifically: still its own art, and carrying SetDesaturation's greyscale
// flag to the renderer (decision 1327 — before it, the grey was the ref's no-shader 0.5 tint).
let toggle = art(&s, "MainMenuBarBackpackButton");
assert!(
toggle
@@ -661,8 +662,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
assert!(
toggle
.iter()
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
&& c.is_some_and(|c| (c[0] - 0.5).abs() < 0.01)),
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && *grey),
"…and it is greyed, not full-bright: {toggle:?}"
);
@@ -672,8 +672,7 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
assert!(
toggle
.iter()
.any(|(p, c)| p == "Interface\\Buttons\\Button-Backpack-Up"
&& c.is_none_or(|c| (c[0] - 1.0).abs() < 0.01)),
.any(|(p, grey)| p == "Interface\\Buttons\\Button-Backpack-Up" && !*grey),
"closing the menu restores full colour: {toggle:?}"
);
assert!(
File diff suppressed because it is too large Load Diff
+9
View File
@@ -738,6 +738,9 @@ mod delete_item_tests;
#[cfg(test)]
mod static_popup_tests;
#[cfg(test)]
mod binder_tests;
#[cfg(test)]
mod death_tests;
@@ -754,6 +757,12 @@ mod framexml_diff;
#[cfg(test)]
mod friends_tests;
/// The four guild windows — the social window's third tab and its three satellites (decision
/// 1257). Its own module rather than more of `friends_tests` because it stands the whole guild
/// engine API in for in Lua before the XML loads, which that file must not.
#[cfg(test)]
mod guild_tests;
#[cfg(test)]
mod quest_tests;
@@ -348,24 +348,21 @@ fn a_disabled_bar_greys_rather_than_hides() {
2,
"the bar is still on screen"
);
// `SetDesaturation` has no shader path in this engine, so the grey is the reference's own
// no-shader fallback: vertex colour 0.5 (UiPanels.xml's transcription).
let tint = quads
// Since decision 1327 `SetDesaturation` takes its SHADER arm — the icon carries the greyscale
// flag to the renderer and keeps its own tint, rather than the reference's no-shader 0.5
// vertex-colour fallback (which is what this asserted while nothing greyed).
let grey = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
desaturated,
..
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*color),
} if p == "Interface\\Icons\\Ability_Druid_Rake" => Some(*desaturated),
_ => None,
})
.expect("Claw's icon");
assert_eq!(
tint.map(|c| [c[0], c[1], c[2]]),
Some([0.5, 0.5, 0.5]),
"a disabled bar greys every icon on it"
);
assert!(grey, "a disabled bar greys every icon on it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -547,3 +547,180 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.eval::<bool>("return SpellBookFrame.bookType == BOOKTYPE_SPELL")
.unwrap());
}
/// The spellbook's **macro-editor fork**, pinned in both directions (report B248).
///
/// The reference's contract, read off the 1.12.1 install's own `SpellBookFrame.lua:271-283` and
/// `MacroFrame.lua:93-97`, is narrow and easy to "fix" into a later expansion's:
///
/// * only a **shift**-click reaches the editor at all — a plain left OR **right** click still
/// casts, exactly as it does with no macro window on screen;
/// * what lands is a whole **`/cast <name>[(<rank>)]` line**, not the bare name (the bare-name
/// insert is 2.x's `ChatEdit_InsertLink`, a different client — and `MACRO_HELP_TEXT_LINE5`,
/// which advertises this feature, says "*Shift* click");
/// * it is **appended with no separator** — the reference's own `GetText()..line` — so two
/// shift-clicks run together on one line;
/// * a **passive** contributes nothing, and neither does a shift-click while the name/icon
/// popup has the body box hidden;
/// * with the editor hidden the shift-click is a **pickup** again.
///
/// DO NOT "FIX" the `/cast` prefix away (the standing rule, decision 1030): B248 reported it as
/// wrong, and the install says it is what 1.12.1 does.
#[test]
fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The macro window's own strings (the app runs the real `GlobalStrings.lua`; `macro_tests`'
// harness is the source of this list).
s.run(
r#"
CREATE_MACROS = "Create Macros"
GENERAL_MACROS = "General Macros"
CHARACTER_SPECIFIC_MACROS = "%s Specific Macros"
ENTER_MACRO_LABEL = "Enter Macro Commands:"
MACROFRAME_CHAR_LIMIT = "%d/255 Characters Used"
MACRO_POPUP_TEXT = "Enter Macro Name (Max 16 Characters):"
MACRO_POPUP_CHOOSE_ICON = "Choose an Icon:"
CHANGE_MACRO_NAME_ICON = "Change Name/Icon"
DELETE = "Delete" NEW = "New" EXIT = "Exit" CANCEL = "Cancel" OKAY = "Okay"
MACROS = "Macros"
TOOLTIP_DEFAULT_COLOR = { r = 1.0, g = 1.0, b = 1.0 }
TOOLTIP_DEFAULT_BACKGROUND_COLOR = { r = 0.09, g = 0.09, b = 0.19 }
"#,
)
.unwrap();
for file in [
"Fonts.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
"ScrollTemplates.xml",
"MicroMenu.xml",
"ActionBar.xml",
"MacroFrame.xml",
"SpellBookFrame.xml",
] {
load_xml(&s, file);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The file's own book plus a third Fire spell that is PASSIVE — book id 3, which the ref's
// column-major grid puts on SpellButton5.
let mut b = book();
b.tabs[0].num_spells = 3;
b.tabs[1].offset = 3;
b.slots.insert(
2,
SpellSlotView {
spell_id: 168,
name: "Frost Warding".into(),
texture: Some("Interface\\Icons\\Spell_Frost_FrostWard".into()),
passive: true,
..Default::default()
},
);
s.set_spellbook(b);
s.run(r#"CreateMacro("Ambush", 1, "")"#).unwrap();
s.run("ShowMacroFrame()").unwrap();
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
let body = |s: &UiScript| -> String {
s.eval::<String>("return BenillaMacroFrameText:GetText()")
.unwrap()
};
assert!(
s.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
.unwrap(),
"the editor's body box is up — the ref's own `MacroFrame_AddMacroLine` gate"
);
assert_eq!(body(&s), "");
// ── B248's reported gesture: a plain RIGHT click still CASTS, and writes nothing ──────────
click(&mut s, "SpellButton1", "RightButton");
assert!(
s.errors().is_empty(),
"right-click errors: {:?}",
s.errors()
);
assert_eq!(s.take_spell_casts(), vec![133], "a right-click casts");
assert_eq!(
body(&s),
"",
"B248: an UNSHIFTED click must never reach the macro editor (ref l.271 tests shift first)"
);
assert!(s.cursor_payload().is_none());
// …and so does a plain left click, the other half of the same arm.
click(&mut s, "SpellButton1", "LeftButton");
assert_eq!(s.take_spell_casts(), vec![133]);
assert_eq!(body(&s), "");
// ── The reference gesture: shift-click APPENDS a whole `/cast` line, and never casts ──────
s.set_modifiers(true, false, false);
click(&mut s, "SpellButton1", "LeftButton");
assert!(
s.errors().is_empty(),
"shift-click errors: {:?}",
s.errors()
);
assert_eq!(
body(&s),
"/cast Fireball(Rank 1)",
"ref l.276: SLASH_CAST1 + name + the rank in parens"
);
assert!(
s.take_spell_casts().is_empty(),
"the macro arm replaces the cast, it does not add to it"
);
assert!(
s.cursor_payload().is_none(),
"with the editor open a shift-click writes instead of picking up"
);
assert!(
s.eval::<bool>("return BenillaMacroFrame.textChanged == 1")
.unwrap(),
"the write goes through the box's own OnTextChanged, so the window is dirty"
);
// A second one runs straight on: the reference appends with NO separator (`GetText()..line`),
// which is why a two-spell macro built this way needs the player to break the line himself.
// DO NOT "fix" this into a newline join.
click(&mut s, "SpellButton3", "RightButton"); // shift outranks the button — ref l.271 vs l.284
assert_eq!(
body(&s),
"/cast Fireball(Rank 1)/cast Fire Blast(Rank 1)",
"ref MacroFrame.lua:95 — appended, unseparated"
);
assert!(s.take_spell_casts().is_empty());
// A PASSIVE writes nothing at all — and does not fall through to a pickup either (ref l.274's
// guard sits INSIDE the macro-frame arm).
let before = body(&s);
click(&mut s, "SpellButton5", "LeftButton");
assert_eq!(body(&s), before, "a passive is not a castable line");
assert!(s.cursor_payload().is_none());
// The name/icon popup hides the body box; the ref's AddMacroLine gates on exactly that.
s.run("BenillaMacroNewButton_OnClick()").unwrap();
assert!(!s
.eval::<bool>("return BenillaMacroFrameText:IsVisible()")
.unwrap());
click(&mut s, "SpellButton1", "LeftButton");
assert!(s.errors().is_empty(), "popup-open errors: {:?}", s.errors());
assert!(s.cursor_payload().is_none(), "still not a pickup");
s.run("BenillaMacroPopupFrame:Hide()").unwrap();
s.run("BenillaMacroFrame_Update()").unwrap();
assert_eq!(body(&s), before, "nothing landed while the popup was up");
// ── Editor hidden: the shift-click is a PICKUP again (ref l.281-282's else) ───────────────
s.run("HideUIPanel(BenillaMacroFrame)").unwrap();
click(&mut s, "SpellButton1", "LeftButton");
s.set_modifiers(false, false, false);
assert!(s.errors().is_empty(), "pickup errors: {:?}", s.errors());
let (kind, spell_id) = s
.eval::<(String, i64)>("local k, _, _, id = GetCursorInfo() return k, id")
.unwrap();
assert_eq!((kind.as_str(), spell_id), ("spell", 133));
assert!(s.take_spell_casts().is_empty());
}
@@ -723,3 +723,255 @@ fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap());
}
/// A profession trainer's list: one skill line, a long recipe name and a short one, each with a rank
/// subtext — the shape of the B253 report (a Leatherworking trainer at "Handstitched Leather Pants").
fn recipe_menu() -> TrainerState {
let mut long = service(
3756,
"Handstitched Leather Pants",
TrainerServiceCategory::Available,
50,
1,
165,
"Leatherworking",
None,
vec![],
);
long.subtext = Some("Rank 1".into());
let mut short = service(
2149,
"Belt",
TrainerServiceCategory::Available,
50,
1,
165,
"Leatherworking",
None,
vec![],
);
short.subtext = Some("Rank 1".into());
TrainerState {
greeting: "Can I teach you how to turn beast hides into armor?".into(),
trainer_type: 2,
groups: Vec::new(),
services: vec![long, short],
}
}
/// **B253 — a long row name drew two lines over the row beneath it.** The row is a FLOW, not two
/// fixed columns: the name carries no width (a FontString given one wraps at it, and a 16 px row
/// cannot grow), and the rank follows the name's right edge rather than sitting at an invented
/// x=188. Both halves are the reference's own row
/// (`ClassTrainerFrameTemplates.xml`'s `<ButtonText>` at width 0 + the per-row
/// `SetPoint("LEFT", <row>Text, "RIGHT", 10, 0)`), read off the player's chain.
///
/// The mutation check is the first assertion: put a `<Size>` back on `$parentName` and the request
/// carries a wrap width, the long name measures two lines, and its height passes the row height.
#[test]
fn a_long_row_name_stays_on_one_line_and_carries_its_rank_along() {
let mut s = trainer_script();
s.set_money(5000);
s.set_trainer(Some(recipe_menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Nadyia".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The host measure: 6 px per character, 14 px per line, wrapped at whatever width was asked for.
// Capture the row names' requests on the way past — what they ASK is the structural fact.
let mut name_wraps: Vec<Option<f32>> = Vec::new();
let mut answer = |s: &mut UiScript, collect: bool| {
let reqs = s.fontstrings_needing_measure();
if collect {
name_wraps.extend(
reqs.iter()
.filter(|r| r.text.contains("Handstitched"))
.map(|r| r.wrap_width),
);
}
let answers: Vec<(u32, f32, f32, u64)> = reqs
.into_iter()
.map(|r| {
let ink = r.text.chars().count() as f32 * 6.0;
match r.wrap_width {
Some(w) => (r.id, ink.min(w), (ink / w).ceil().max(1.0) * 14.0, r.key),
None => (r.id, ink, 14.0, r.key),
}
})
.collect();
s.set_measured_text_unwrapped(&answers);
};
answer(&mut s, true);
s.resolve();
s.tick(0.016);
answer(&mut s, false);
s.resolve();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!name_wraps.is_empty() && name_wraps.iter().all(|w| w.is_none()),
"the row name must ask for NO wrap width — it is a single line whatever the name is; \
got {name_wraps:?}"
);
// Find each name's row by what it painted (the group comparator owns the order, not this test).
let row_of = |s: &mut UiScript, needle: &str| -> i64 {
s.eval::<i64>(&format!(
"for i = 1, 11 do local b = getglobal('BenillaTrainerService' .. i) \
local t = b.name:GetText() if t and strfind(t, '{needle}', 1, 1) then return i end \
end return 0"
))
.unwrap()
};
let long_row = row_of(&mut s, "Handstitched");
let short_row = row_of(&mut s, "Belt");
assert!(long_row > 0 && short_row > 0, "both services painted");
let geom = |s: &mut UiScript, row: i64| -> (f32, f32, f32, f32) {
s.eval::<(f32, f32, f32, f32)>(&format!(
"local b = getglobal('BenillaTrainerService{row}') \
return b.name:GetHeight(), b.name:GetRight(), b.subtext:GetLeft(), b:GetHeight()"
))
.unwrap()
};
let (long_h, long_name_right, long_sub_left, row_h) = geom(&mut s, long_row);
let (short_h, short_name_right, short_sub_left, _) = geom(&mut s, short_row);
assert!(
long_h <= row_h + 0.5,
"the long name is one line inside its own row: name {long_h} px, row {row_h} px"
);
assert!((long_h - short_h).abs() < 0.5, "and so is the short one");
for (name, right, left) in [
("long", long_name_right, long_sub_left),
("short", short_name_right, short_sub_left),
] {
assert!(
(left - right - 10.0).abs() < 0.5,
"the {name} row's rank sits 10 px past its NAME's right edge (the reference's own \
offset), not at a fixed column: name right {right}, subtext left {left}"
);
}
assert!(
long_sub_left > short_sub_left + 50.0,
"so a longer name pushes its rank along instead of running under it: {long_sub_left} vs \
{short_sub_left}"
);
}
/// **B256 — "filter to Available, learn a spell, the filter comes back partly reset."** The window
/// is driven here through the real [`crate::ui_trainer::TrainerOpen`], so the packet→reset decision
/// under test is the app's own and not this test's: the closure below is the trainer feed's three
/// lines (`if open.fresh_list { reset } ; set_trainer ; fire`), and everything else is the shipped
/// Lua.
///
/// The reference cannot produce this bug because it never gets a list packet with the window open —
/// it repaints a purchase from a client-side state re-derivation (`0x4d7d40`, decision 1128 §4.2).
/// benilla re-asks the server instead, so the reference's per-packet mask reset (`0x4d75d9`) was
/// riding in on a packet the reference never sends, and taking the player's choice — and their
/// collapsed groups, which 1128 recorded as "a collapse does not survive a purchase" — with it.
#[test]
fn learning_a_spell_keeps_the_filter_and_the_collapse_a_re_open_still_resets() {
use crate::ui_trainer::TrainerOpen;
const DAZALAR: u64 = 0xabc;
let mut s = trainer_script();
// The reference's own file-scope defaults (1128), then the player's choice below.
s.run(
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 0 \
BenillaTrainerFrame_ApplyFilter()",
)
.unwrap();
s.set_money(5000);
// The trainer feed (`ui_trainer::feed_trainer`), reduced to the part this bug lives in.
fn feed(s: &mut UiScript, open: &mut TrainerOpen, state: TrainerState, event: &str) {
if open.fresh_list {
s.reset_trainer_filter(open.trainer_type);
open.fresh_list = false;
}
s.set_trainer(Some(state));
s.fire_event(event, vec![ScriptValue::Str("Dazalar".into())]);
}
let filter_on = |s: &mut UiScript, kind: &str| {
s.eval::<bool>(&format!(
"return GetTrainerServiceTypeFilter('{kind}') == 1"
))
.unwrap()
};
let rows = |s: &mut UiScript| s.eval::<i64>("return GetNumTrainerServices()").unwrap();
// He opens the trainer and filters the list down to what he can actually learn.
let mut open = TrainerOpen::default();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, menu(), "TRAINER_SHOW");
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(
!filter_on(&mut s, "unavailable"),
"his choice reached the engine"
);
assert_eq!(
rows(&mut s),
4,
"two headers over the two learnable services"
);
// …and folds the first group away while he is at it.
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(rows(&mut s), 3, "the folded group keeps its header only");
// He trains. The app re-asks for the list (`trainer_buy_succeeded`) and marks its own answer as
// the repaint it is; the bought service comes back gray.
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
open.refresh_pending = true;
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
feed(&mut s, &mut open, learned, "TRAINER_UPDATE");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!filter_on(&mut s, "unavailable"),
"learning a spell is a repaint, not a new window: his filter stands"
);
assert_eq!(
rows(&mut s),
2,
"and the list is still his — the folded Arms group is empty of learnables now, so it goes \
with its header, leaving Fury over Thunder Clap"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(
!s.eval::<bool>("return DropDownList1Button2Check:IsVisible() and true or false")
.unwrap(),
"the dropdown agrees with the list, instead of claiming a filter the list ignores"
);
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
// He walks away and comes back: THIS packet opens a window, so the reference's reset lands —
// and the window's own OnShow/TRAINER_SHOW puts the saved globals straight back over it. The
// filter survives (it is saved), the collapse does not (it is engine-side, and the reset clears
// it) — the reference's own split.
open.clear();
open.open(DAZALAR, 0, vec![], "Hello, hunter!".into());
let mut learned = menu();
learned.services[0].category = TrainerServiceCategory::Used;
feed(&mut s, &mut open, learned, "TRAINER_SHOW");
assert!(
!filter_on(&mut s, "unavailable"),
"the saved filter is pushed back over the packet reset"
);
assert!(
filter_on(&mut s, "available"),
"and the states he never turned off are still on"
);
assert_eq!(
rows(&mut s),
2,
"nothing collapsed now — but still filtered"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}