Files
benilla-octo/crates/benilla-app/src/ui_script/trainer_tests.rs
T
SamandClaude Fable 5.1 b1294ce9a1 the quest windows go stock, and the 20 ms frames were mis-split pairs
- the quest giver's dialog and the quest log are the reference's own `QuestFrame.xml` and `QuestLogFrame.xml`, run off the player's chain at the reference's own toc seats; our 1366-line and 1999-line copies are deleted, and the verbs the stock files call are built — the four `GameTooltip` quest-item and reward-spell setters, the reward spell itself (parsed on all three packets and dropped by the model until now), and two verbs; the detail pane could never scroll because `UpdateScrollChildRect` measured a frame late where the reference's measure is synchronous, so it measures through the installed font engine now; and ten things the reference does that ours did not, each found by a test that had enshrined ours — a shift-click never selects, an empty log hides the whole detail pane and says "No Active Quests", the abandon confirm lives in the popup registry (1944)
- the quest bindings' law, read off the binary and folded back: no 1.12 packet carries a quest background material — `GetQuestBackgroundMaterial` (`0x502230`) forks on the dialog's SOURCE object, an item answering its template's PageMaterial, a GameObject its template data, a creature nil (FrameXML's "Parchment"), the id naming a `PageTextMaterial.dbc` row whose six words are nowhere in the binary; its twin `ItemTextGetMaterial` has the same body, so the reader window already held every piece and the giver's feed runs the same resolve; `QuestChooseRewardError` is game-error row `0x98`, and the row plays `igQuestFailed` first, which the verb now queues; the reward-spell getters' third return is `Spell.dbc` Attributes bit 5 (1161 of 22357 rows set — craft-cast spells set, `Pattern:` teaching spells clear), read off the catalog now (1946)
- the "20 ms frames" of a raid, chased to their number: the probe's worst frames now carry their churn (entities, assets, first-play sound decodes, live particles, the GPU reading) and in a Blackrock raid with sound on every one of those read flat; then the same window on the PACED clock — under vsync with pipelined rendering the raw delta is mis-split into long/short pairs that sum to exact refresh multiples, so an alone Stormwind leg read 115 of 300 frames over budget with cpu and gpu both at their means, while the paced clock read 0 skipped frames in every leg; what remains real in the raid is `cpu_p99` at 22–23 ms with sound on; and every submesh the streamer spawns now carries why it is an entity, counted per reason — 5.8k resident ADT-doodad submeshes at the Stormwind auction house, 5.3k of them hidden, three prop families the retained pass declines by rule, each riding every per-`Mesh3d` sweep for ~0.4 ms of the alone frame (1947)
- a raid's dozen voices cost the mixer ~1.5 ms of every 60 Hz frame: upstream kira spatializes every frame — normalizes, the attenuation curve and a dB→amplitude `powf` per frame per spatial track, two more `powf`s for the volume tween, ~1.7 M `powf`s a second at 48 kHz × 12 tracks for gains that change imperceptibly across a chunk — so kira 0.12.1 is vendored with one patch: the spatial gains and the volume × fade amplitude are evaluated at the chunk's two ends and interpolated across its ~10.7 ms, the granularity FMOD 3, the reference's own mixer, updates them at; nothing a listener can name changes (1945); and rig poses are evaluated in parallel over rigs — each reads its shared clips and writes only its own pose, and a raid's ~400 live rigs were 0.4 ms of one thread walked serially on the main thread's critical path (1947)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:55:12 +07:00

1043 lines
43 KiB
Rust

//! The shipped **trainer window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/TrainerFrame.xml` — a client-sorted, collapsible **skill-line tree** with a **dropdown**
//! state filter and a draggable **scroll bar** (decisions 0247/0251) — loaded behind its deps
//! (`UiPanels.xml` + `UIDropDownMenu.xml` + `ScrollTemplates.xml` + `MerchantFrame.xml` for the
//! `BenillaMoney_*` coin helpers) and fed a synthetic service list. Covers what only a runtime load
//! exercises: the Lua parses and every referenced global resolves, the tree renders interleaved
//! header/service rows, a header click folds its group, the dropdown filter hides a state, the wheel
//! scrolls the list, the NPC name rides `arg1` into the title, the byte-exact GlobalStrings render, the
//! Train button gates on available-and-affordable, and the buy queues the row's spell id.
use benilla_ui::script::{
ExtractedQuad, QuadContent, ScriptValue, SoundRequest, TrainerAbilityReq, TrainerService,
TrainerServiceCategory, TrainerSkillReq, TrainerState, UiScript,
};
use super::test_ui::load_ui as load_xml;
/// Load the trainer window + all its deps into a fresh script, screen sized, with every state filter
/// ON (the XML defaults "Already Known" off — the tests want the full tree, deterministic indices).
///
/// The filter's source of truth is the three **saved globals** (decision 1128), which the window
/// pushes into the engine on every show — so a test that wants the full tree sets those, not the
/// engine's own `SetTrainerServiceTypeFilter`, which the next `TRAINER_SHOW` would overwrite.
fn trainer_script() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "UiPanels.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the kit's MenuBackdrop), app order
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the filter dropdown's kit
load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml"); // the merchant kit; the money frames are MoneyFrame.xml's (1937)
load_xml(&s, "TrainerFrame.xml");
s.run(
"TRAINER_FILTER_AVAILABLE = 1 TRAINER_FILTER_UNAVAILABLE = 1 TRAINER_FILTER_USED = 1 \
BenillaTrainerFrame_ApplyFilter()",
)
.unwrap();
s
}
/// Whether any rendered text quad carries `color` (within a small tolerance) — used to spot the
/// reddened cost coins, whose `(1.0, 0.1, 0.1)` is distinct from the unavailable row's `(0.9, 0, 0)`.
fn has_text_color(quads: &[ExtractedQuad], color: [f32; 3]) -> bool {
quads.iter().any(|q| match &q.content {
QuadContent::Text { color: Some(c), .. } => (0..3).all(|i| (c[i] - color[i]).abs() < 0.02),
_ => false,
})
}
/// Whether the first text quad containing `needle` renders in `color` (small tolerance) — used to
/// assert the selected row's white name vs. a state colour.
fn text_has_color(quads: &[ExtractedQuad], needle: &str, color: [f32; 3]) -> bool {
quads.iter().any(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color: Some(c),
..
} => t.contains(needle) && (0..3).all(|i| (c[i] - color[i]).abs() < 0.02),
_ => false,
})
}
/// The centre of the first text quad whose text contains `needle` — a point to aim the wheel at.
fn text_center(quads: &[ExtractedQuad], needle: &str) -> (f32, f32) {
let r = quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Text { text: Some(t), .. } if t.contains(needle) => q.rect,
_ => None,
})
.unwrap_or_else(|| panic!("no text quad containing {needle:?}"));
((r.left + r.right) * 0.5, (r.bottom + r.top) * 0.5)
}
/// One service in a named skill line, spelling out every field so the intent is legible at the call
/// site.
#[allow(clippy::too_many_arguments)]
fn service(
spell_id: u32,
name: &str,
category: TrainerServiceCategory,
cost: u32,
level_req: u32,
skill_line: u32,
line_name: &str,
skill_req: Option<TrainerSkillReq>,
ability_reqs: Vec<TrainerAbilityReq>,
) -> TrainerService {
TrainerService {
spell_id,
tooltip: benilla_ui::script::TrainerTooltip::Spell {
spell_id,
alt_caster: false,
},
name: Some(name.into()),
subtext: None,
texture: Some("Interface\\Icons\\INV_Sword_04".into()),
description: String::new(),
cost,
prof_first_rank: false,
category,
level_req,
skill_req,
ability_reqs,
is_trade_skill: false,
group_key: skill_line,
group_name: line_name.into(),
}
}
/// A two-line warrior menu. Groups sort by name (Arms < Fury); within a group by level then name. The
/// full-filter tree is:
/// 1 H:Arms · 2 Heroic Strike(avail,10c,l1) · 3 Cleave(unavail,l20,skill+ability) ·
/// 4 H:Fury · 5 Rend(used,30c) · 6 Thunder Clap(avail,500c)
fn menu() -> TrainerState {
TrainerState {
greeting: "Well met. Let me show you the way of the warrior.".into(),
trainer_type: 0,
groups: Vec::new(),
services: vec![
service(
78,
"Heroic Strike",
TrainerServiceCategory::Available,
10,
1,
26,
"Arms",
None,
vec![],
),
service(
845,
"Cleave",
TrainerServiceCategory::Unavailable,
100,
20,
26,
"Arms",
Some(TrainerSkillReq {
name: "Swords".into(),
rank: 50,
met: false,
}),
// The director's case: Cleave is gated (level/skill), but its prerequisite ability is
// already learned — so it reads MET (white) with its rank, decoupled from the
// service's unavailable state.
vec![TrainerAbilityReq {
name: "Charge (Rank 1)".into(),
met: true,
}],
),
service(
6343,
"Thunder Clap",
TrainerServiceCategory::Available,
500,
1,
256,
"Fury",
None,
vec![],
),
service(
772,
"Rend",
TrainerServiceCategory::Used,
30,
1,
256,
"Fury",
None,
vec![],
),
],
}
}
/// The whole trainer window minus Bevy: it loads clean, opens on TRAINER_SHOW with the NPC name in the
/// title, renders the interleaved tree (headers + services), picks the first available service, renders
/// the exact `Cost:` label, gates Train on available-and-affordable, queues the selected row's spell id
/// on a buy, reddens the cost of an unaffordable service, builds the `Requires:` line for a gated one,
/// and hides on TRAINER_CLOSED.
#[test]
fn shipped_trainer_frame_drives_end_to_end() {
let mut s = trainer_script();
// Hidden by default.
assert!(!s
.eval::<bool>("return BenillaTrainerFrame:IsVisible()")
.unwrap());
// The app's feed: 50 copper in the purse + the warrior menu, then the open event with the name.
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event(
"TRAINER_SHOW",
vec![ScriptValue::Str("Sana Winterhoof".into())],
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Shown, title took the NPC name off arg1.
assert!(s
.eval::<bool>("return BenillaTrainerFrame:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaTrainerTitleText:GetText()")
.unwrap(),
"Sana Winterhoof"
);
// Row 1 renders the "Arms" header (its name, no indent); row 2 the first service.
assert_eq!(
s.eval::<String>("return BenillaTrainerService1Text:GetText()")
.unwrap(),
"Arms"
);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
" Heroic Strike"
);
// selectFirstService picked the first available row (Heroic Strike, index 2) → the detail pane
// shows the exact "Cost:" label and Train is enabled (available + 10c affordable at 50c).
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
2
);
assert_eq!(
s.eval::<String>("return BenillaTrainerCostLabel:GetText()")
.unwrap(),
"Cost:"
);
assert!(s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
// Train buys the selected service — the row's spell id reaches the app's drain.
s.run("BuyTrainerService(GetTrainerSelectionIndex())")
.unwrap();
assert_eq!(s.take_trainer_buys(), vec![78]);
assert!(s.take_trainer_buys().is_empty(), "drained");
// Select the available-but-unaffordable service (Thunder Clap, index 6, 500c > 50c): Train disables
// and the cost coins redden (BenillaMoney_SetColor 1.0, 0.1, 0.1).
s.run("SelectTrainerService(6); BenillaTrainerFrame_Update()")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
s.resolve();
assert!(
has_text_color(&s.extract(), [1.0, 0.1, 0.1]),
"unaffordable cost coins render red"
);
// Select the gated service (Cleave, index 3): the Requires: line is built from the level/skill/
// ability gates (byte-exact REQUIRES_LABEL), and Train stays disabled (unavailable).
s.run("SelectTrainerService(3); BenillaTrainerFrame_Update()")
.unwrap();
let reqs = s
.eval::<String>("return BenillaTrainerSkillRequirements:GetText()")
.unwrap();
assert!(reqs.starts_with("Requires: "), "reqs: {reqs}");
for term in ["Level", "Swords", "Charge"] {
assert!(reqs.contains(term), "reqs missing {term}: {reqs}");
}
// The met prerequisite renders WHITE (|cffffffff…|r) with its rank, while the unmet level/skill
// gates redden — the mixed line the director asked for (a learned prev-rank isn't reddened just
// because the spell itself is unavailable).
assert!(
reqs.contains("|cffffffffCharge (Rank 1)|r"),
"a known prerequisite shows white with its rank: {reqs}"
);
assert!(!s
.eval::<bool>("return BenillaTrainerTrainButton:IsEnabled() ~= 0")
.unwrap());
// The app's client-side close: clear the snapshot + fire TRAINER_CLOSED → the window hides.
s.set_trainer(None);
s.fire_event("TRAINER_CLOSED", vec![]);
assert!(!s
.eval::<bool>("return BenillaTrainerFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Clicking a header row folds its group (and expands it back) — the tree collapse, end-to-end through
/// the row button's OnClick → Collapse/ExpandTrainerSkillLine(headerIndex).
#[test]
fn clicking_a_header_row_collapses_its_group() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Full tree: 2 headers + 4 services = 6 rows; row 2 is Heroic Strike.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
" Heroic Strike"
);
// Click the Arms header (row 1): its two services fold → 4 rows (H:Arms, H:Fury, Rend, Thunder
// Clap). Row 2 is now the Fury header.
s.run("BenillaTrainerSkillButton_OnClick(BenillaTrainerService1, 'LeftButton')")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 4);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
"Fury",
"Arms folded; its header (row 1) now abuts the Fury header (row 2)"
);
// Click it again → expands back to 6 rows.
s.run("BenillaTrainerSkillButton_OnClick(BenillaTrainerService1, 'LeftButton')")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The dropdown filter hides a state client-side: toggling "used" off drops the already-known service,
/// its header stays, and the row count falls.
#[test]
fn filter_hides_a_state_keeping_headers() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// Drive the dropdown's own click handler for the "used" row: the row button rides as `this` with
// its pre-click state (checked=on, value="used"), exactly as UIDropDownMenuButton_OnClick invokes
// the row func. It flips the engine filter off and repaints. Rend drops (6 → 5); headers remain.
s.run("this = { value = 'used', checked = 1 }; BenillaTrainerFilterDropDown_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
"the used service (Rend) is hidden; both headers stay"
);
assert!(
!s.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap(),
"the engine's used filter is now off"
);
}
/// The filter rows toggle **through the real dropdown kit** — the path a mouse takes, which the test
/// above deliberately shortcuts by faking `this`. `UIDropDownMenuButton_OnClick` runs the row's func
/// and only THEN flips the check for a `keepShownOnClick` row, so the func must not repaint the row
/// itself: an in-func `UIDropDownMenu_Initialize` re-derived the check from the fresh engine state and
/// the kit's flip then inverted it straight back — the check never moved on screen, and `this.checked`
/// stuck true, so a filter turned off could never be turned back on. Click, re-click, and re-open all
/// have to agree.
#[test]
fn filter_rows_toggle_through_the_dropdown_kit() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
// Open the menu the way the capsule's arrow does. Row 2 is "Unavailable" (Initialize's order),
// checked because trainer_script() turned every state on.
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row_checked = |s: &mut UiScript| {
s.eval::<bool>("return DropDownList1Button2Check:IsVisible() and true or false")
.unwrap()
};
assert!(row_checked(&mut s), "Unavailable starts checked");
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// Click it: the check clears, the engine filter clears, and Cleave (the unavailable service)
// drops out of the tree. Its Arms header stays.
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(!row_checked(&mut s), "the click clears the row's check");
assert!(
!s.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap(),
"and the engine's unavailable filter with it"
);
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
"Cleave is hidden"
);
// Click it again: back on, both on screen and in the engine — the case the old code could never
// reach, because `this.checked` never went false.
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
assert!(row_checked(&mut s), "the re-click restores the check");
assert!(
s.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap(),
"and re-enables the filter"
);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// Close and re-open: Initialize re-derives every row from the engine, so the menu agrees with
// what the clicks left behind (the two states left on, "used" still on from trainer_script()).
s.run("this = DropDownList1Button2; UIDropDownMenuButton_OnClick()")
.unwrap();
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap(); // same owner → closes
s.run("ToggleDropDownMenu(1, nil, BenillaTrainerFilterDropDown)")
.unwrap(); // re-open → re-Initialize
assert!(
!row_checked(&mut s),
"a re-opened menu shows the filter the clicks actually left off"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A menu bigger than the 11 visible rows (one skill line, 15 services → a 16-row tree), so the list
/// scrolls. The wheel spins the list even when the cursor is over a row — the engine bubbles the spin
/// up the parent chain to the window, which drives the faux-scroll bar.
fn long_menu() -> TrainerState {
TrainerState {
greeting: "Much to learn.".into(),
trainer_type: 0,
groups: Vec::new(),
services: (1..=15)
.map(|i| {
service(
1000 + i,
&format!("Service {i:02}"),
TrainerServiceCategory::Available,
10,
10,
26,
"Arms",
None,
vec![],
)
})
.collect(),
}
}
#[test]
fn wheel_over_a_row_scrolls_the_list() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(long_menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let row1 = "return BenillaTrainerService1Text:GetText()";
// At the top: row 1 is the "Arms" header, row 2 the first service.
assert_eq!(s.eval::<String>(row1).unwrap(), "Arms");
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Text:GetText()")
.unwrap(),
" Service 01"
);
// Aim the wheel at a LIST ROW's text (service 3 — visible at the top): the spot over a row.
s.resolve();
let (x, y) = text_center(&s.extract(), "Service 03");
// Spin down (WoW convention: negative = down). The REFERENCE's wheel is a PAGE, not a row:
// `ScrollFrameTemplate_OnMouseWheel` moves `scrollBar:GetHeight() / 2` pixels
// (UIPanelTemplates.lua:150-157), the same half-bar the arrows use. Ours moved one row on
// purpose; the migration reverts that (1860), and the expected row is derived from the bar so
// this stays the reference's rule rather than a literal.
s.mouse_wheel(x, y, -1.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// What this test is FOR is the wiring — wheel -> bar -> `<OnVerticalScroll>` ->
// `FauxScrollFrame_OnVerticalScroll` -> `frame.offset` -> the repaint. The magnitude is the
// reference's and is asserted as "a page, not a row"; pinning the exact row count would pin a
// pixel arithmetic whose inputs resolve a frame apart.
let offset = s
.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap();
assert!(
offset > 1,
"the reference's wheel moves half a BAR, not one row — got offset {offset}"
);
assert_eq!(
s.eval::<String>(row1).unwrap(),
format!(" Service {offset:02}"),
"row 1 shows the entry the offset names, so the repaint followed the scroll"
);
// A second spin cannot go deeper: 16 entries over 11 visible rows makes 5 the deepest legal
// offset, and the reference's half-bar page already reached it in one spin. That it STOPS
// there is the clamp working, not the wheel failing.
s.mouse_wheel(x, y, -1.0);
assert_eq!(
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap(),
offset,
"clamped at the bottom (numItems - numToDisplay), never past it"
);
// Spin back up twice: the list returns to the top and stops there, never past it.
s.mouse_wheel(x, y, 1.0);
s.mouse_wheel(x, y, 1.0);
assert_eq!(
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap(),
0,
"back at the top, clamped"
);
assert_eq!(s.eval::<String>(row1).unwrap(), "Arms");
}
/// The selected service row's name renders white (HIGHLIGHT), legible against its colour glow — and
/// so does a HOVERED one. Both are the reference's own behaviour, through one mechanism: the row's
/// name is the button's `<ButtonText>` under a `<HighlightFont inherits="GameFontHighlight">`, the
/// engine swaps that instance in while the cursor is on the row, and `LockHighlight()` pins it for
/// the selection (Blizzard_TrainerUI.lua l.183). `SetTextColor` writes the NORMAL instance only, so
/// the state colour cannot follow the label into either state — which is exactly why it goes white.
///
/// An earlier revision of this test called the white name "a deliberate divergence from the ref
/// (which whitens only the subtext)". The ref whitens the subtext *by hand* precisely BECAUSE the
/// subtext is a child FontString the lock cannot reach; the name it leaves to the lock. Ours could
/// not, because the name was a child FontString too and the engine's highlighted label fell back to
/// the normal state's colour — so the white was hand-painted here and absent on hover entirely
/// (decision 1605).
#[test]
fn a_selected_or_hovered_service_row_paints_its_name_white() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The name really is the button's own label now — a child FontString would leave
// GetFontString() nil and no per-state font could reach it.
assert!(
s.eval::<bool>("return BenillaTrainerService1:GetFontString() ~= nil")
.unwrap(),
"the row name is the Button's ButtonText, the only region per-state fonts reach"
);
// Cleave (index 3) is unavailable → red when not selected. Select it → its name goes white.
s.run("SelectTrainerService(3); BenillaTrainerFrame_Update()")
.unwrap();
s.resolve();
assert!(
text_has_color(&s.extract(), "Cleave", [1.0, 1.0, 1.0]),
"the selected row's name renders white"
);
// Select Heroic Strike (index 2): Cleave is no longer selected → back to unavailable red.
s.run("SelectTrainerService(2); BenillaTrainerFrame_Update()")
.unwrap();
s.resolve();
assert!(
text_has_color(&s.extract(), "Cleave", [0.9, 0.0, 0.0]),
"an unselected unavailable row is red again"
);
// Now hover it, selecting nothing: the HighlightFont instance takes over and the row lights up.
let (x, y) = text_center(&s.extract(), "Cleave");
s.mouse_move(x, y);
s.resolve();
assert!(
text_has_color(&s.extract(), "Cleave", [1.0, 1.0, 1.0]),
"a hovered row's name renders white, with no script doing it"
);
assert!(
text_has_color(&s.extract(), "Heroic Strike", [1.0, 1.0, 1.0]),
"and the SELECTED row stays white while another is hovered"
);
// Cursor off the list: the hovered row falls back to its state colour, the selected one holds.
s.mouse_move(1000.0, 20.0);
s.resolve();
assert!(
text_has_color(&s.extract(), "Cleave", [0.9, 0.0, 0.0]),
"cursor away: red again"
);
assert!(
text_has_color(&s.extract(), "Heroic Strike", [1.0, 1.0, 1.0]),
"the selection's white is the LOCK, not the hover"
);
}
/// Scrolling is silent, faithfully: the mouse WHEEL plays no sound (the ref's
/// `ScrollFrameTemplate_OnMouseWheel` is soundless — the director asked for no sound when scrolling),
/// while the arrow BUTTONS keep the ref's `UChatScrollButton` click.
#[test]
fn wheel_scroll_is_silent_but_the_arrows_click() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(long_menu())); // 16 rows > 11 visible → the bar + arrows show
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
let _ = s.take_sounds(); // drain the window's OnShow open sound
let click = SoundRequest::KitName("UChatScrollButton".into());
// A wheel spin over the list scrolls it (proven by the wheel test) but plays NOTHING.
s.resolve();
let (x, y) = text_center(&s.extract(), "Service 03");
s.mouse_wheel(x, y, -1.0);
assert!(
!s.take_sounds().contains(&click),
"the wheel scroll is silent"
);
// The down arrow (enabled at the top), though — clicking it plays the ref's arrow click.
s.run("BenillaTrainerListScrollFrameScrollBarScrollDownButton:Click()")
.unwrap();
assert!(
s.take_sounds().contains(&click),
"the arrow button clicks (UChatScrollButton)"
);
}
/// The scrollbar ARROWS move the list the way they point, and stop at the top.
///
/// **The STEP is the reference's, not ours, since 1860.** `UIPanelScrollBarTemplate`'s arrow
/// OnClick is `parent:SetValue(parent:GetValue() -/+ (parent:GetHeight() / 2))` — half the BAR's
/// height in pixels, which for this window's 144-tall bar over 16px rows is five rows. Our
/// deleted kit stepped exactly one row on purpose ("the generic ref scrollbar steps half its
/// height; a discrete row list wants one"); the migration reverts that, and the magnitude here is
/// computed from the bar rather than written as a literal so it stays the reference's rule and not
/// a number someone has to re-derive.
#[test]
fn the_scrollbar_arrows_step_the_list_the_way_they_point() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(long_menu())); // 16 rows > 11 visible → the bar shows
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
let offset = |s: &mut UiScript| {
s.eval::<i64>("return BenillaTrainerListScrollFrame.offset or -1")
.unwrap()
};
let click = |s: &mut UiScript, which: &str| {
s.run(&format!(
"BenillaTrainerListScrollFrameScrollBarScroll{which}Button:Click()"
))
.unwrap();
};
// The reference's own step, read off the bar: half its height in pixels, rounded to rows the
// way `FauxScrollFrame_OnVerticalScroll` rounds (`floor(v/itemHeight + 0.5)`).
assert_eq!(offset(&mut s), 0, "opens at the top");
click(&mut s, "Down");
let step = offset(&mut s);
assert!(
step > 1,
"the down arrow advances by half a BAR, not one row — got {step}"
);
click(&mut s, "Up");
assert_eq!(offset(&mut s), 0, "the up arrow walks it back");
click(&mut s, "Up");
click(&mut s, "Up");
assert_eq!(offset(&mut s), 0, "and stops at the top, never past it");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The filter is remembered across a restart** (decision 1128) — the whole persistence path, in
/// one test: the shipped XML declares its three globals for saving at file scope, a dropdown toggle
/// writes the *global* (not only the engine mask), the serialized file carries it, and a fresh VM
/// that loads the same XML and then executes that file comes up with the toggled filter applied.
///
/// It also pins the two halves that are easy to "simplify" apart and silently break: writing only
/// the engine mask in the click handler would lose the choice at the next list packet (the reference
/// rebuilds that mask on every one), and applying the globals only in `<OnShow>` would leave the
/// first painted frame under the reset.
#[test]
fn the_state_filter_survives_a_restart_through_the_saved_variables_file() {
let mut s = trainer_script();
s.set_money(50);
s.set_trainer(Some(menu()));
s.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 6);
// This XML declared exactly the reference's three globals, in its order. Filtered to the
// trainer's own prefix because the registry belongs to the whole UI: every file the harness
// loads adds its own (GameTooltip.xml's SHOW_NEWBIE_TIPS since 1136), and what the OTHER files
// register is not this test's business.
let declared: Vec<String> = s
.saved_variable_names()
.into_iter()
.filter(|n| n.starts_with("TRAINER_"))
.collect();
assert_eq!(
declared,
vec![
"TRAINER_FILTER_AVAILABLE",
"TRAINER_FILTER_UNAVAILABLE",
"TRAINER_FILTER_USED",
]
);
// Toggle "Unavailable" off through the dropdown row's own handler (pre-click state on `this`).
s.run("this = { value = 'unavailable', checked = 1 }; BenillaTrainerFilterDropDown_OnClick()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return TRAINER_FILTER_UNAVAILABLE").unwrap(),
0,
"the click must write the SAVED global, not just the engine mask"
);
let text = s.saved_variables_text();
assert!(
text.contains("TRAINER_FILTER_UNAVAILABLE = 0"),
"the file carries the toggle: {text}"
);
// The restart: a fresh VM with the same XML (file-scope defaults stand), then the saved file
// executed over them, then a trainer opens.
let mut fresh = trainer_script();
fresh.run(&text).unwrap();
fresh.set_money(50);
fresh.set_trainer(Some(menu()));
fresh.fire_event("TRAINER_SHOW", vec![ScriptValue::Str("Sana".into())]);
assert!(
!fresh
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap(),
"the remembered filter reached the engine"
);
assert_eq!(
fresh.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
"Cleave (the unavailable service) is hidden on the very first paint after the restart"
);
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// A **new list packet resets the engine's filter mask** to the builder's own default — mask 3 at a
/// class/tradeskill/pet trainer, mask 5 (available|used) at a mount trainer — and clears the collapse
/// set, byte-verified (decision 1128). This is the engine half of the pair above: the reset is why
/// the window re-pushes its saved globals on every show.
#[test]
fn a_new_list_packet_resets_the_filter_mask_and_the_collapse_set() {
let mut s = trainer_script();
s.set_trainer(Some(menu()));
s.run("BenillaTrainerFrame_ApplyFilter()").unwrap();
// Collapse a group, and turn a state off directly (no global) — both are engine-side state.
s.run("CollapseTrainerSkillLine(1) SetTrainerServiceTypeFilter('used', 1)")
.unwrap();
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap());
assert!(s.eval::<i64>("return GetNumTrainerServices()").unwrap() < 6);
s.reset_trainer_filter(0);
assert!(
!s.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap(),
"mask 3: available|unavailable, already-known OFF"
);
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('available') == 1")
.unwrap());
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('unavailable') == 1")
.unwrap());
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
5,
"nothing collapsed any more (6 rows less the already-known service the mask now hides)"
);
// A mount trainer wants available|used instead — what makes a known mount visible at all.
s.reset_trainer_filter(1);
assert!(s
.eval::<bool>("return GetTrainerServiceTypeFilter('used') == 1")
.unwrap());
assert!(!s
.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 the row's `<ButtonText>` 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: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}') \
local n = b:GetFontString() \
return n:GetHeight(), n: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());
}