Files
benilla-octo/crates/benilla-app/src/ui_script/trainer_tests.rs
T
SamandClaude Fable 5 3d10e7415b the target-of-target frame, and talents can be unlearned again
- the target-of-target frame and its option: 1.12's frame with its five display modes, fed one hop off the selected unit's own target field through the same read `/assist` runs, in four feeds (snapshot and transitions, the debuff buttons, the portrait booth, TargetUnit's click arm); ships off, as 1.12 does, on its own pair of saved globals
- talents can be unlearned again: the trainer's `MSG_TALENT_WIPE_CONFIRM` had no handler since the wipe flow was deferred, so the gossip line closed the menu and did nothing — the question is an NPC session (walking away retracts it), the popup shows the cost on the StaticPopup money variant, Accept answers it behind the client's own purse check
- Sprint's sparkles hang in the air behind you along the trail instead of collapsing into your torso: ride-vs-trail is the emitter's own file flag 0x10, not the host class — the finding wow-re returned eighteen days ago and nobody folded; the particle dump names the two flag bits it had been mis-reporting
- `/sleep` takes both movement masks (the strictest posture), correcting the day's sit gate which let you lie down on a lake bottom while `/sit` was refused; a Lua-driven selection (TargetUnit on an attackable token) starts the swing exactly as a click does, through the one select path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:40:16 +07:00

979 lines
39 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,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error and returning the
/// frame count it materialized.
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
}
/// 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, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_* (the kit's MenuBackdrop), app order
load_xml(&s, "UIDropDownMenu.xml"); // the filter dropdown's kit
load_xml(&s, "ScrollTemplates.xml"); // the faux-scroll bar kit
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set/_Clear/_SetColor live here
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 BenillaTrainerService1Name:GetText()")
.unwrap(),
"Arms"
);
assert_eq!(
s.eval::<String>("return BenillaTrainerService2Name: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()")
.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()")
.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()")
.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 BenillaTrainerService2Name: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 BenillaTrainerService2Name: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 BenillaTrainerService1Name: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 BenillaTrainerService2Name: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: the list slides one row so row 1 now shows Service 01 (WoW convention: negative = down).
s.mouse_wheel(x, y, -1.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>(row1).unwrap(),
" Service 01",
"wheel-over-row scrolled the list down one row"
);
// Spin down again: row 1 advances to Service 02 — the scroll bar really moves the offset.
s.mouse_wheel(x, y, -1.0);
assert_eq!(s.eval::<String>(row1).unwrap(), " Service 02");
// Spin back up: Service 01 returns to the top.
s.mouse_wheel(x, y, 1.0);
assert_eq!(s.eval::<String>(row1).unwrap(), " Service 01");
}
/// The selected service row's text renders white (HIGHLIGHT), legible against its colour glow — a
/// deliberate divergence from the ref (which whitens only the subtext, keeping the name state-coloured).
/// Selecting the unavailable Cleave (red when unselected) whitens its name; selecting away restores it.
#[test]
fn selected_service_row_text_is_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());
// 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"
);
}
/// 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 — the direction half the sound test
/// above never checked. They were inverted in the shared kit (ScrollTemplates.xml): at the top the
/// up arrow correctly greys out, but the *enabled* down arrow called `Step(bar, -1)`, decrementing
/// a value already clamped at its minimum — so both arrows were dead in every faux-scroll window
/// (the wheel and the thumb drag masked it, and the sound test passed because OnClick still fired).
#[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();
};
assert_eq!(offset(&mut s), 0, "opens at the top");
click(&mut s, "Down");
assert_eq!(
offset(&mut s),
1,
"the down arrow advances the list one row"
);
click(&mut s, "Down");
assert_eq!(offset(&mut s), 2);
click(&mut s, "Up");
assert_eq!(offset(&mut s), 1, "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 `$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());
}