Files
benilla-octo/crates/benilla-ui/src/script/trainer/tests.rs
T
SamandClaude Fable 5.1 16abf643b1 the world gets its own Lua state, and the trainer follows the spell
- entering the world builds its own Lua state instead of adopting the character screen's, so a login one-shot fired into the frameless glue VM is spent again against the interface (2226)
- after training, the trainer's detail pane moves on: the selection is the service's spell id, re-found by scan, and a filtered row is hidden, not removed (2231)
- a spell's miss or resist floats in spell gold, and a travelling spell's word waits for the projectile; only a white hit's miss is white (2229)
- a press that lands on a nameplate is the plate's: no freelook, no camera turn, the plate's own click slot (2233)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 16:58:29 +07:00

742 lines
28 KiB
Rust

//! The trainer tree's tests — the per-`trainerType` ordering laws (decisions 0247/1124) pinned
//! against wow-re's own emulated runs of the real finalizer, plus the filter/collapse/intent
//! surface. Split out of `mod.rs` when the four comparators pushed it past the file budget.
use super::*;
use crate::script::UiScript;
fn svc(
spell_id: u32,
name: &str,
cat: TrainerServiceCategory,
group_key: u32,
group_name: &str,
) -> TrainerService {
TrainerService {
spell_id,
// The fixture's default subject: the wire spell, no hop (the "no learn wrapper"
// fallback); the tooltip tests set the arm they mean explicitly.
tooltip: TrainerTooltip::Spell {
spell_id,
alt_caster: false,
},
name: Some(name.into()),
subtext: Some("Rank 1".into()),
texture: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
description: format!("Teaches {name}."),
cost: 100,
prof_first_rank: false,
category: cat,
level_req: 10,
skill_req: None,
ability_reqs: vec![],
is_trade_skill: false,
group_key,
group_name: group_name.into(),
}
}
/// A two-line warrior trainer (type 0). Groups sort by name (Arms < Fury); within Arms, the level
/// keys order Heroic Strike (level 1) before Cleave (level 20). The default-expanded tree is thus:
/// `[H:Arms, Heroic Strike(avail), Cleave(used), H:Fury, Bloodrage(unavail)]` — a 5-row list whose
/// service rows sit at indices 2, 3, 5.
fn trainer() -> TrainerState {
let mut hs = svc(
78,
"Heroic Strike",
TrainerServiceCategory::Available,
26,
"Arms",
);
hs.level_req = 1;
let mut cl = svc(284, "Cleave", TrainerServiceCategory::Used, 26, "Arms");
cl.level_req = 20;
let br = svc(
285,
"Bloodrage",
TrainerServiceCategory::Unavailable,
256,
"Fury",
);
TrainerState {
greeting: "What would you like to learn?".into(),
trainer_type: 0,
groups: Vec::new(),
services: vec![hs, cl, br],
}
}
/// Every visible row as `(name, serviceType)` — the shape the ported XML actually renders.
fn visible(s: &mut UiScript) -> Vec<(String, String)> {
let n = s.eval::<i64>("return GetNumTrainerServices()").unwrap();
(1..=n)
.map(|i| {
s.eval::<(String, String)>(&format!(
"local n,_,t = GetTrainerServiceInfo({i}) return n,t"
))
.unwrap()
})
.collect()
}
#[test]
fn tree_interleaves_headers_and_ordered_services() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert!(s
.eval::<bool>("return GetTrainerServiceInfo(1) == nil")
.unwrap());
s.set_trainer(Some(trainer()));
// 2 headers + 3 services = 5 visible rows.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Row 1 is the "Arms" header: name, nil subtext, "header", isExpanded=1.
let (hn, hsub, ht, hexp) = s
.eval::<(String, Option<String>, String, Option<i64>)>(
"local n,s,t,e = GetTrainerServiceInfo(1) return n,s,t,e",
)
.unwrap();
assert_eq!((hn.as_str(), ht.as_str()), ("Arms", "header"));
assert_eq!((hsub, hexp), (None, Some(1)));
// Rows 2..5: services (name/state) then the Fury header then its service — the sorted tree.
let info = |s: &mut UiScript, i: i64| {
s.eval::<(String, String)>(&format!(
"local n,_,t = GetTrainerServiceInfo({i}) return n,t"
))
.unwrap()
};
assert_eq!(
info(&mut s, 2),
("Heroic Strike".into(), "available".into())
);
assert_eq!(info(&mut s, 3), ("Cleave".into(), "used".into()));
assert_eq!(info(&mut s, 4), ("Fury".into(), "header".into()));
assert_eq!(info(&mut s, 5), ("Bloodrage".into(), "unavailable".into()));
}
#[test]
fn service_getters_read_the_row_at_a_visible_index() {
let mut s = UiScript::new().unwrap();
let mut t = trainer();
// Heroic Strike (services[0], the row at index 2) carries a full gate set.
t.services[0].skill_req = Some(TrainerSkillReq {
name: "Blacksmithing".into(),
rank: 100,
met: true,
});
t.services[0].ability_reqs = vec![TrainerAbilityReq {
name: "Apprentice".into(),
met: false,
}];
s.set_trainer(Some(t));
// Cost/level/desc/reqs all resolve at the SERVICE index 2 (not the header at 1).
assert_eq!(
s.eval::<(i64, i64, i64)>("return GetTrainerServiceCost(2)")
.unwrap(),
(100, 0, 0)
);
assert_eq!(
s.eval::<i64>("return GetTrainerServiceLevelReq(2)")
.unwrap(),
1
);
assert_eq!(
s.eval::<String>("return GetTrainerServiceDescription(2)")
.unwrap(),
"Teaches Heroic Strike."
);
let (skill, rank, has) = s
.eval::<(String, i64, i64)>("return GetTrainerServiceSkillReq(2)")
.unwrap();
assert_eq!((skill.as_str(), rank, has), ("Blacksmithing", 100, 1));
assert!(s
.eval::<bool>(
"local n,h = GetTrainerServiceAbilityReq(2,1) return n=='Apprentice' and h==nil"
)
.unwrap());
assert!(s
.eval::<bool>("local l,p = IsTrainerServiceLearnSpell(2) return l==1 and p==nil")
.unwrap());
// A HEADER row (index 1) has no service data — the getters no-op to defaults.
assert_eq!(
s.eval::<i64>("return GetTrainerServiceLevelReq(1)")
.unwrap(),
0
);
assert!(s
.eval::<bool>("return GetTrainerServiceIcon(1) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetTrainerServiceSkillReq(1) == nil")
.unwrap());
}
/// The state filter takes a group's **header with it** once it hides the group's last service — the
/// finalizer's `[+0x1c]` hide has no header-row exemption (`0x4d8528`/`0x4d8535`, decision 1124).
/// This asserted the opposite until 1124: benilla rendered bare headers over empty groups, and an
/// all-boxes-off filter left a window full of headings and nothing else.
#[test]
fn state_filter_takes_a_groups_header_with_its_last_service() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Hide "used": Cleave drops (5 → 4), and BOTH headers stay — Arms still has Heroic Strike.
s.run("SetTrainerServiceTypeFilter('used', 0)").unwrap();
assert_eq!(
visible(&mut s)
.into_iter()
.map(|(_, t)| t)
.collect::<Vec<_>>(),
["header", "available", "header", "unavailable"]
);
// Now hide "available" too: Arms loses its last row, so the ARMS HEADER GOES WITH IT — only
// Fury's header and its unavailable service remain.
s.run("SetTrainerServiceTypeFilter('available', 0)")
.unwrap();
assert_eq!(
visible(&mut s),
[
("Fury".to_string(), "header".to_string()),
("Bloodrage".to_string(), "unavailable".to_string()),
]
);
// Every box off renders NOTHING — not a list of bare headings.
s.run("SetTrainerServiceTypeFilter('unavailable', 0)")
.unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
// …but the *rows* are still there behind it, and the getters still serve them (2231). This line
// asserted `GetTrainerServiceInfo(1) == nil` until the accessor gate was carved: the single one
// every service getter shares, `0x4d89b0`, bounds against the TOTAL `ds:0xb73a10`, and the
// visible count `ds:0xb73a18` has exactly five references image-wide — the finalizer seeding and
// decrementing it, the buy-ALL loop, and `GetNumTrainerServices`. No getter reads it. An empty
// window is empty because the Lua stops iterating, not because the rows stopped existing.
assert_eq!(
s.eval::<String>("return (GetTrainerServiceInfo(1))")
.unwrap(),
"Arms",
"row 1 is still the Arms header, now in the hidden tail"
);
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0,
"and nothing is selected, which is a different question from what row 1 holds"
);
}
/// Collapse is the *asymmetric* case, and deliberately so: it hides a group's services but keeps the
/// header — a different field (`hdr[+0x20]`) tested three instructions after the filter's, with a
/// header-row exemption the filter's lacks (`0x4d853d`). Without it a collapsed group could never be
/// re-expanded, having no header left to click.
#[test]
fn collapse_keeps_the_header_the_filter_would_remove() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms — both its services hide
assert_eq!(
visible(&mut s),
[
("Arms".to_string(), "header".to_string()),
("Fury".to_string(), "header".to_string()),
("Bloodrage".to_string(), "unavailable".to_string()),
]
);
// Collapse-all keeps every header; the filter's empty case keeps none.
s.run("CollapseTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
}
#[test]
fn collapse_by_header_index_and_collapse_all() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
// Collapse the Arms group by its header's display index (1): its two services vanish, the
// header stays and now reports isExpanded=nil. 5 → 3 (H:Arms, H:Fury, Bloodrage).
s.run("CollapseTrainerSkillLine(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
assert!(s
.eval::<bool>("local _,_,t,e = GetTrainerServiceInfo(1) return t=='header' and e==nil")
.unwrap());
assert_eq!(
s.eval::<String>("local n = GetTrainerServiceInfo(2) return n")
.unwrap(),
"Fury",
"Arms' services are folded; Fury's header is now row 2"
);
// Expand it back by the same header index.
s.run("ExpandTrainerSkillLine(1)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
// Collapse-all (id 0): both groups fold → just the two headers.
s.run("CollapseTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 2);
// Expand-all (id 0): back to the full tree.
s.run("ExpandTrainerSkillLine(0)").unwrap();
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
#[test]
fn collapse_survives_a_content_update_and_resets_on_close() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("CollapseTrainerSkillLine(1)").unwrap(); // fold Arms
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 3);
// A re-list (same skill lines) keeps the fold — a buy re-lists and the tree shouldn't jump open.
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetNumTrainerServices()").unwrap(),
3,
"Arms stays collapsed across a content update"
);
// Close → the collapse set resets; a re-open is fully expanded.
s.set_trainer(None);
s.set_trainer(Some(trainer()));
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
/// A buy queues the row's **spell id**, and only a green row can be bought — the single-row path
/// `0x4d89d0` resolves the index through the shared total-bounded gate and then refuses on the state
/// byte (`0x4d89e5 cmp byte ptr [esi+0x30], bl; jne`), so red and gray rows are silently dropped on
/// the floor rather than sent to the server (2231). This used to queue whatever the index resolved
/// to, which is how a `used` row could still put a `CMSG_TRAINER_BUY_SPELL` on the wire.
#[test]
fn buy_queues_an_available_services_spell_id_and_refuses_every_other_row() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
// Row 2 is Heroic Strike (spell 78, available). Buying it queues its spell id, not its index.
s.run("BuyTrainerService(2)").unwrap();
assert_eq!(s.take_trainer_buys(), vec![78]);
assert!(s.take_trainer_buys().is_empty(), "drained");
// Buying a HEADER row (index 1) queues nothing.
s.run("BuyTrainerService(1)").unwrap();
assert!(s.take_trainer_buys().is_empty(), "a header is not buyable");
// Nor does a gray row (Cleave, already known) or a red one (Bloodrage, gated).
s.run("BuyTrainerService(3)").unwrap();
s.run("BuyTrainerService(5)").unwrap();
assert!(
s.take_trainer_buys().is_empty(),
"only state byte 0 — available — reaches the wire"
);
}
#[test]
fn selection_and_close_intents() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
s.run("SelectTrainerService(2)").unwrap();
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
2
);
s.run("SelectTrainerService(9)").unwrap(); // OOB (past the 5 rows) clears
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
assert!(!s.take_trainer_close());
s.run("CloseTrainer()").unwrap();
assert!(s.take_trainer_close());
assert!(!s.take_trainer_close(), "drained");
}
/// The two whole-trainer predicates fork on the wire `trainer_type` — `IsTradeskillTrainer` on
/// `== 2` (`0x4d8ea0`), `IsTalentTrainer` on `== 1` (`0x4d8ed0`) — so at most one is ever true, and
/// at a class trainer neither is.
#[test]
fn tradeskill_and_talent_flags() {
let mut s = UiScript::new().unwrap();
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == nil")
.unwrap());
// A class trainer: neither predicate.
s.set_trainer(Some(trainer()));
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == nil and IsTalentTrainer() == nil")
.unwrap());
// A tradeskill trainer — one "Recipes" group, so its one service is the row at index 2.
let mut recipe = svc(
2743,
"Copper Chain Pants",
TrainerServiceCategory::Unavailable,
2,
"Recipes",
);
recipe.is_trade_skill = true;
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 2,
groups: Vec::new(),
services: vec![recipe],
}));
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == 1 and IsTalentTrainer() == nil")
.unwrap());
assert!(s
.eval::<bool>("return IsTrainerServiceTradeSkill(2) == 1")
.unwrap());
// A mount trainer — the "talent" predicate, which used to be a hardcoded nil.
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 1,
groups: Vec::new(),
services: vec![svc(
33,
"Riding",
TrainerServiceCategory::Available,
762,
"Riding",
)],
}));
assert!(s
.eval::<bool>("return IsTalentTrainer() == 1 and IsTradeskillTrainer() == nil")
.unwrap());
}
#[test]
fn unresolved_skill_line_is_dropped() {
let mut s = UiScript::new().unwrap();
let mut t = trainer();
// A service whose skill line didn't resolve (0) is dropped from the tree entirely.
t.services.push(svc(
999,
"Orphan Spell",
TrainerServiceCategory::Available,
0,
"",
));
s.set_trainer(Some(t));
// Still 5 rows — the orphan contributes neither a header nor a service row.
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 5);
}
/// **The selection is the service, never the row it sits on** — and a hidden service keeps a row.
///
/// `SelectTrainerService` stores the record's spell id (`ds:0xb73a0c`, `0x4d74f0`) and
/// `GetTrainerSelectionIndex` finds it again by scanning the **whole** array (`0x4d7520`, bounded by
/// the total `ds:0xb73a10`, not the visible `ds:0xb73a18`). Filtering and collapsing only clear a
/// record's visible flag `[+0x34]` and sort it into a tail, so the answer for an off-screen
/// selection is an index *past* `GetNumTrainerServices()` — never a clamp, and never a live row
/// number belonging to some other service. benilla stored the row number and clamped it, which is
/// the director's "the info and img below don't update" report: see [`super::selected_row`].
#[test]
fn the_selection_follows_its_service_and_lands_in_the_tail_when_it_is_hidden() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
let sel = |s: &mut UiScript| s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap();
let shown = |s: &mut UiScript| s.eval::<i64>("return GetNumTrainerServices()").unwrap();
let at = |s: &mut UiScript, i: i64| {
s.eval::<String>(&format!("return (GetTrainerServiceInfo({i})) or ''"))
.unwrap()
};
// Bloodrage sits at row 5 of `[H:Arms, Heroic Strike, Cleave, H:Fury, Bloodrage]`.
s.run("SelectTrainerService(5)").unwrap();
assert_eq!(sel(&mut s), 5);
assert_eq!(at(&mut s, 5), "Bloodrage");
// Hide the already-known state: Cleave leaves and everything under it slides up one. The row
// number 5 now names nothing on screen; the selection is still Bloodrage, at row 4.
s.run("SetTrainerServiceTypeFilter('used', 0)").unwrap();
assert_eq!(at(&mut s, 4), "Bloodrage");
assert_eq!(
sel(&mut s),
4,
"it followed its service, it did not stay put"
);
// Fold Bloodrage's group away. It is off screen but not gone: its record goes to the tail, so
// the answer is an index past the visible count — which is what sends the stock window down its
// "the selection is not on screen" path instead of silently repainting someone else's row.
s.run("CollapseTrainerSkillLine(3)").unwrap();
let folded = sel(&mut s);
assert!(
folded > shown(&mut s),
"a folded-away selection reads past the visible count, not as row {folded} of {}",
shown(&mut s)
);
s.run("ExpandTrainerSkillLine(3)").unwrap();
assert_eq!(sel(&mut s), 4, "and back on screen when the group unfolds");
// He learns it. The re-list brings it back gray and the filter hides it — the tail again, never
// the row number of whatever moved up into its place.
let mut learned = trainer();
learned.services[2].category = TrainerServiceCategory::Used;
s.set_trainer(Some(learned));
assert!(
sel(&mut s) > shown(&mut s),
"the learned spell is off screen, not at some live row"
);
// A service that has left the trainer's list altogether is the one case that reads 0.
let mut shorter = trainer();
shorter.services.remove(2);
s.set_trainer(Some(shorter));
assert_eq!(sel(&mut s), 0);
// A header row is not a selection, and neither is a row past the end.
s.set_trainer(Some(trainer()));
s.run("SelectTrainerService(1)").unwrap();
assert_eq!(sel(&mut s), 0, "row 1 is the Arms header");
}
/// A new `SMSG_TRAINER_LIST` resets the selection with the rest of the builder's state — `0x4d7560`
/// selects record 0 after the sort (`0x4d7b40 xor ecx,ecx` → `0x4d7b42 call 0x4d74f0`), which is
/// always a group header and so reads back as 1. Clearing is the same answer to the only question
/// the stock window asks (`GetTrainerSelectionIndex() > 1`, which 0 and 1 both fail); what matters
/// is that a re-opened trainer cannot inherit the last visit's selection.
#[test]
fn a_new_list_packet_clears_the_selection() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("SelectTrainerService(5)").unwrap();
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
5
);
s.reset_trainer_list_state(0);
s.set_trainer(Some(trainer()));
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0,
"the same spell is still in the list, and it is still not selected"
);
}
#[test]
fn clearing_empties_and_resets_selection() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
s.run("SelectTrainerService(2)").unwrap();
s.set_trainer(None);
assert_eq!(s.eval::<i64>("return GetNumTrainerServices()").unwrap(), 0);
assert_eq!(
s.eval::<i64>("return GetTrainerSelectionIndex()").unwrap(),
0
);
}
/// **The tradeskill order, pinned against wow-re's emulated run of the real finalizer** (decision
/// 1124). The fixture is creature 957 "Dane Lindgren" — the blacksmithing trainer in the director's
/// report — with his 19 real `npc_trainer` rows: 18 recipes on skill line 164 with ascending
/// `reqSkillValue`, plus the profession-learn service 2020 ("Apprentice Blacksmith", `reqLevel 5`,
/// no skill gate). wow-re fed exactly this set through the builder `0x4d7560` + finalizer `0x4d8410`
/// with `0xb73a08 = 2` and the real `qsort`/collator, and got the 21 rows asserted below.
///
/// Two things this pins that benilla had wrong, and one it never had:
/// - the learn row is **first among services**, under its own `Development Skills` header — placed
/// by the `SKILL_STEP` group partition, *not* by any level key;
/// - `reqLevel` is **inert** at type 2 (the class comparator's first key, which used to sink 2020 to
/// the bottom of the list);
/// - recipes ascend by `reqSkillValue`, ties broken by name.
#[test]
fn tradeskill_trainer_matches_the_emulated_reference_order() {
// (wire spell, name, reqSkillValue) — the recipes, in wire order (ascending here, but the
// fixture is shuffled below to prove the order is the comparator's and not the input's).
let recipes: &[(u32, &str, u32)] = &[
(2743, "Copper Chain Pants", 1),
(2754, "Copper Mace", 15),
(2755, "Copper Axe", 20),
(3340, "Copper Chain Boots", 20),
(2756, "Copper Shortsword", 25),
(3341, "Rough Grinding Stone", 25),
(9984, "Copper Claymore", 30),
(8881, "Copper Dagger", 30),
(2744, "Copper Battle Axe", 35),
(3299, "Copper Chain Belt", 35),
(3342, "Runed Copper Gauntlets", 40),
(3343, "Runed Copper Pants", 45),
(2746, "Coarse Sharpening Stone", 65),
(7409, "Coarse Weightstone", 65),
(3118, "Heavy Copper Maul", 65),
(3300, "Runed Copper Belt", 70),
(2747, "Thick War Axe", 70),
(3344, "Coarse Grinding Stone", 75),
];
let mut services: Vec<TrainerService> = recipes
.iter()
.map(|&(id, name, req)| {
// Group key 2 = TRADESKILL_SERVICE_LEARN: no SKILL_STEP effect on the wire spell.
let mut s = svc(id, name, TrainerServiceCategory::Unavailable, 2, "Recipes");
s.subtext = None;
s.level_req = 0;
s.skill_req = Some(TrainerSkillReq {
name: "Blacksmithing".into(),
rank: req,
met: false,
});
s
})
.collect();
services.reverse(); // wire order is not display order — and must not be
// Group key 1 = TRADESKILL_SERVICE_STEP: spell 2020 carries Effect 44 SKILL_STEP.
let mut learn = svc(
2020,
"Apprentice Blacksmith",
TrainerServiceCategory::Available,
1,
"Development Skills",
);
learn.subtext = None;
learn.level_req = 5; // the key that used to decide everything, and decides nothing here
learn.prof_first_rank = true;
services.insert(services.len() / 2, learn);
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(TrainerState {
greeting: "Care to learn how to turn the ore that you find into weapons?".into(),
trainer_type: 2,
groups: Vec::new(),
services,
}));
let rows = visible(&mut s);
let expected: Vec<(&str, &str)> = vec![
("Development Skills", "header"),
("Apprentice Blacksmith", "available"),
("Recipes", "header"),
("Copper Chain Pants", "unavailable"),
("Copper Mace", "unavailable"),
("Copper Axe", "unavailable"),
("Copper Chain Boots", "unavailable"),
("Copper Shortsword", "unavailable"),
("Rough Grinding Stone", "unavailable"),
("Copper Claymore", "unavailable"),
("Copper Dagger", "unavailable"),
("Copper Battle Axe", "unavailable"),
("Copper Chain Belt", "unavailable"),
("Runed Copper Gauntlets", "unavailable"),
("Runed Copper Pants", "unavailable"),
("Coarse Sharpening Stone", "unavailable"),
("Coarse Weightstone", "unavailable"),
("Heavy Copper Maul", "unavailable"),
("Runed Copper Belt", "unavailable"),
("Thick War Axe", "unavailable"),
("Coarse Grinding Stone", "unavailable"),
];
let got: Vec<(&str, &str)> = rows.iter().map(|(n, t)| (n.as_str(), t.as_str())).collect();
assert_eq!(got, expected);
}
/// **The mount (type 1, the client's "talent") order, pinned against wow-re's emulated run**
/// (decision 1124): the already-known services fold into the `-1` "My Talents" group, which the
/// header comparator puts **first**, ahead of the name-ordered skill-line headers; within a group
/// the state byte sorts available → unavailable → used, then the name.
#[test]
fn mount_trainer_folds_known_services_into_my_talents() {
let known = |id: u32, name: &str| {
svc(
id,
name,
TrainerServiceCategory::Used,
TRAINER_GROUP_KNOWN,
"My Talents",
)
};
let services = vec![
svc(
33,
"Riding",
TrainerServiceCategory::Available,
762,
"Riding",
),
known(6648, "Tiger Riding"),
svc(
824,
"Horse Riding",
TrainerServiceCategory::Unavailable,
148,
"Horse Riding",
),
svc(
8394,
"Ram Riding",
TrainerServiceCategory::Available,
152,
"Ram Riding",
),
];
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(TrainerState {
greeting: "Ready to ride?".into(),
trainer_type: 1,
groups: Vec::new(),
services,
}));
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(
got,
[
"My Talents",
"Tiger Riding",
"Horse Riding",
"Horse Riding",
"Ram Riding",
"Ram Riding",
"Riding",
"Riding",
],
"the -1 group leads; every other header is name-ordered"
);
// And the type predicates fork on the same dword the sort does.
assert!(s.eval::<bool>("return IsTalentTrainer() == 1").unwrap());
assert!(s
.eval::<bool>("return IsTradeskillTrainer() == nil")
.unwrap());
}
/// The **state byte is a sort key at type 1 and only there** — the same three services, ordered by
/// state (available → unavailable → used) inside one group, where the class cascade would order
/// them by level and the tradeskill one by skill value.
#[test]
fn talent_order_sorts_on_state_within_a_group() {
let one = |id: u32, name: &str, cat: TrainerServiceCategory| svc(id, name, cat, 762, "Riding");
let services = vec![
one(3, "Cee", TrainerServiceCategory::Available),
one(1, "Aye", TrainerServiceCategory::Used),
one(2, "Bee", TrainerServiceCategory::Unavailable),
];
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 1,
groups: Vec::new(),
services: services.clone(),
}));
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(got, ["Riding", "Cee", "Bee", "Aye"]);
// The same three at a CLASS trainer sort by name instead (equal levels, no skill gates) — the
// proof that the comparator really is selected by the type and not shared.
s.set_trainer(Some(TrainerState {
greeting: String::new(),
trainer_type: 0,
groups: Vec::new(),
services,
}));
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(got, ["Riding", "Aye", "Bee", "Cee"]);
}