Files
benilla-octo/crates/benilla-app/tests/talent_frame.rs
T
SamandClaude Fable 5 ab235bad6e 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

343 lines
14 KiB
Rust

//! Drives the REAL `assets/ui/TalentFrame.xml` through the engine — the first test that
//! executes the transcribed talent Lua at all (before this, the window's machinery only ever
//! ran inside a live client session; both of the director's day-one reports — no prereq branch
//! lines, no tooltip on first hover — slipped through that gap).
//!
//! The harness loads the same file chain the app does (`ui_script/mod.rs`'s list, cut to the
//! talent window's dependency prefix), pushes a synthetic two-talent page shaped like the
//! warrior's Improved Rend → Deep Wounds column, opens the window, and asserts at three
//! depths: the Lua-visible state (`TALENT_BRANCH_ARRAY`), the logical frame state (`IsShown`),
//! and the extract the renderer actually draws (branch/arrow quads with their atlas coords).
use benilla_ui::script::{
QuadContent, SpellTooltipView, TalentPrereqView, TalentTabView, TalentUiState, TalentView,
UiScript,
};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The talent window's load prefix — the app's own order (`assets/ui/benilla.toc`), members only.
/// `ItemButtonTemplate.xml` is the `SetItemButton*` family the talent buttons grey through (the
/// reference's own verb; see TalentFrame.xml's header) — it sits at .toc line 32, above every
/// other entry here bar `Fonts.xml`.
const FILES: [&str; 7] = [
"Fonts.xml",
"ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
"TalentFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
// The app's provider shape: backslash paths, dir-relative, basename fallback.
let provider = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
for file in FILES {
let text = std::fs::read_to_string(dir.join(file)).unwrap_or_else(|e| {
panic!("reading {file}: {e}");
});
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| {
panic!("parsing {file}: {e}");
});
let report = benilla_ui::loader::load(script, &doc, &provider);
assert!(
report.errors.is_empty(),
"{file} loaded with errors: {:#?}",
report.errors
);
}
}
/// The Improved Rend → Deep Wounds shape: a rank-3 prereq two tiers straight up the same
/// column, the empty cell between them carrying the vertical branch (the classic vanilla look).
/// `points_spent` decides the line's color: ≥10 unlocks tier 3 (yellow, `1`); fewer leaves it
/// locked (gray, `-1`) — the reference draws the branch EITHER way.
fn fixture(points_spent: u32) -> TalentUiState {
let rend = TalentView {
name: "Improved Rend".into(),
texture: Some("Interface\\Icons\\Ability_Gouge".into()),
tier: 1,
column: 3,
rank: 3,
max_rank: 3,
exceptional: false,
meets_prereq: true,
prereqs: Vec::new(),
display_spell: 201,
next_spell: 0,
req_lines: Vec::new(),
learnable: false,
};
let deep_wounds = TalentView {
name: "Deep Wounds".into(),
texture: Some("Interface\\Icons\\Ability_BackStab".into()),
tier: 3,
column: 3,
rank: 0,
max_rank: 3,
exceptional: false,
meets_prereq: true,
prereqs: vec![TalentPrereqView {
tier: 1,
column: 3,
learnable: true,
}],
display_spell: 301,
next_spell: 0,
req_lines: Vec::new(),
learnable: true,
};
TalentUiState {
tabs: vec![TalentTabView {
name: "Arms".into(),
background: "WarriorArms".into(),
points_spent,
}],
talents: vec![vec![rend, deep_wounds]],
points: (2, 0),
}
}
fn view(name: &str, desc: &str) -> SpellTooltipView {
SpellTooltipView {
name: name.into(),
description: desc.into(),
..Default::default()
}
}
fn open_window(points_spent: u32) -> UiScript {
let mut script = UiScript::new().expect("engine");
script.set_screen_size(1024.0, 768.0);
load_ui(&script);
script.set_talents(fixture(points_spent));
script.run("ToggleTalentFrame()").expect("toggle");
script
}
#[test]
fn branch_lines_draw_for_a_same_column_prereq() {
let mut script = open_window(10);
// Depth 1 — the Lua machinery: Update ran, the branch array carries the vertical chain
// (tier 1's down-edge, the empty tier-2 cell's up+down, the button's own top arrow).
script
.run(
r#"
assert(BenillaTalentFrame:IsVisible(), "window visible")
local a = TALENT_BRANCH_ARRAY
assert(a[1][3].down == 1, "tier1 down, got " .. tostring(a[1][3].down))
assert(a[2][3].up == 1, "tier2 up, got " .. tostring(a[2][3].up))
assert(a[2][3].down == 1, "tier2 down, got " .. tostring(a[2][3].down))
assert(a[3][3].topArrow == 1, "tier3 topArrow, got " .. tostring(a[3][3].topArrow))
"#,
)
.expect("branch array");
// Depth 2 — the pooled textures took the work: at least one branch + one arrow shown.
script
.run(
r#"
assert(BenillaTalentFrameBranch1:IsShown(), "branch 1 shown")
assert(BenillaTalentFrameArrow1:IsShown(), "arrow 1 shown")
"#,
)
.expect("pool state");
// Depth 3 — the renderer sees them: branch/arrow quads in the extract, cropped into the
// atlas (a full-sheet [0,1] crop means SetTexCoord never landed).
script.resolve();
let quads = script.extract();
let branch = quads
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
tex_coords,
..
} if p.contains("UI-TalentBranches") => Some(*tex_coords),
_ => None,
})
.next();
let Some(tex_coords) = branch else {
panic!("no UI-TalentBranches quad in the extract");
};
assert!(
tex_coords.is_some_and(|c| c.edges() != [0.0, 1.0, 0.0, 1.0]),
"branch quad must be atlas-cropped, got {tex_coords:?}"
);
assert!(
quads.iter().any(|q| matches!(
&q.content,
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-TalentArrows")
)),
"no UI-TalentArrows quad in the extract"
);
}
#[test]
fn tooltip_is_complete_on_the_first_hover() {
let mut script = open_window(10);
// The app's feed owes the store every talent spell view up front (the spellbook's own
// arrival-driven contract) — with the views in place, the FIRST OnEnter must render the
// full tooltip: name line, description, the green hint.
script.set_spell_tooltip(201, view("Improved Rend", "Bleed harder."));
script.set_spell_tooltip(301, view("Deep Wounds", "Bleed on crit."));
script
.run(
r#"
BenillaTalentButton_OnEnter(BenillaTalentFrameTalent2)
assert(GameTooltip:IsShown(), "tooltip shown on first hover")
assert(GameTooltipTextLeft1:GetText() == "Deep Wounds",
"name line, got " .. tostring(GameTooltipTextLeft1:GetText()))
"#,
)
.expect("first hover");
}
#[test]
fn tooltip_miss_still_shows_the_rank_line() {
let mut script = open_window(10);
// The store is EMPTY (no views pushed): the ask-once fallback must still show a box with
// the talent head — never a blank hover — and record the ask for the app's resolver.
script
.run(
r#"
BenillaTalentButton_OnEnter(BenillaTalentFrameTalent2)
assert(GameTooltip:IsShown(), "tooltip shown on a store miss")
assert(GameTooltipTextLeft1:GetText() == "Rank 0/3",
"rank fallback, got " .. tostring(GameTooltipTextLeft1:GetText()))
"#,
)
.expect("miss hover");
let asks = script.take_spell_tooltip_asks();
assert!(
asks.contains(&301),
"the miss records the ask, got {asks:?}"
);
}
#[test]
fn a_locked_tier_still_draws_the_branch_gray() {
// The director's own day-one state: 2 points spent, tier 3 locked — the reference draws
// the chain anyway, gray (the `-1` keys of the texcoord tables; a typo'd negative key
// would error the draw loop and hide every line while the grid stays perfect).
let script = open_window(2);
script
.run(
r#"
local a = TALENT_BRANCH_ARRAY
assert(a[1][3].down == -1, "tier1 down gray, got " .. tostring(a[1][3].down))
assert(a[3][3].topArrow == -1, "tier3 topArrow gray, got " .. tostring(a[3][3].topArrow))
assert(BenillaTalentFrameBranch1:IsShown(), "gray branch shown")
assert(BenillaTalentFrameArrow1:IsShown(), "gray arrow shown")
"#,
)
.expect("gray branch");
}
/// **B162 — an unavailable talent goes GREYSCALE, not merely dim.**
///
/// The reporter's own A/B: 1.12.1 at 0 talent points draws every unlearned icon in black and
/// white; benilla drew them in full colour. The Lua was never wrong — it asked for the grey-out
/// and the ask reached the region — but the engine had no `Texture:SetDesaturated`, so
/// `SetItemButtonDesaturated`'s no-shader arm was the only one available and "greyed" meant a
/// `SetVertexColor(0.65)` brightness multiply. On colourful art that reads as a slightly dimmer
/// colourful icon, which is exactly what was reported.
///
/// So the assertion is on the DESATURATION FLAG reaching the renderer, not on the tint: the tint
/// was always there and was never the thing that was missing. The fixture is the reporter's state
/// — points spent, none left — and Deep Wounds' tier is locked besides.
///
/// The control is the same extract's learned talent: full colour, no flag. A regression that
/// greys the whole tree passes the first assertion and fails this one.
#[test]
fn an_unavailable_talent_reaches_the_renderer_desaturated() {
let mut script = open_window(2);
script.resolve();
let icon = |quads: &[benilla_ui::script::ExtractedQuad], leaf: &str| -> (bool, [f32; 4]) {
quads
.iter()
.find_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
desaturated,
..
} if p.ends_with(leaf) => Some((*desaturated, color.unwrap_or([1.0; 4]))),
_ => None,
})
.unwrap_or_else(|| panic!("no {leaf} icon quad in the extract"))
};
let quads = script.extract();
// Deep Wounds: tier 3 with 2 points spent — locked, and the player has 2 points to spend, so
// this is the tier gate alone, not the no-points force-desaturate.
let (grey, tint) = icon(&quads, "Ability_BackStab");
assert!(
grey,
"an unavailable talent's icon must carry the greyscale flag — the whole of B162"
);
// The reference's own `(1, 0.65, 0.65, 0.65)` still SETS that tint, and on shader-capable
// hardware it has no effect on colour — the desaturated fragment discards the vertex RGB
// entirely (wow-re `texture-desaturate-law.md` §6.2; decision 1330 corrected 1327 here). It is
// pinned anyway because the value must keep reaching the quad: it is what the no-shader arm
// would have drawn with, and its ALPHA is read on both paths.
assert!(
(tint[0] - 0.65).abs() < 1e-3,
"the ref's 0.65 still lands on the quad (inert on RGB, live on alpha), got {tint:?}"
);
// The control: Improved Rend is learned to max, so it is available — full colour, no flag.
let (grey, tint) = icon(&quads, "Ability_Gouge");
assert!(!grey, "a learned talent must NOT be greyed");
assert!(
(tint[0] - 1.0).abs() < 1e-3,
"…and draws at full colour, got {tint:?}"
);
}
#[test]
fn a_wheel_spin_over_the_grid_scrolls_the_tree() {
// The wheel's whole chain, driven from the pointer entry point the app feeds: hit-test on
// a talent button, bubble to the ScrollFrame's OnMouseWheel, step the Slider, whose
// OnValueChanged pans the frame. This is the chain the 2051f4f8 rename broke silently —
// the handler called a helper by a name that no longer existed, and only a real spin (not
// a load) executes it.
let mut script = open_window(10);
// Rects must be resolved before the wheel's hit-test can land on the grid.
script.resolve();
let (x, y): (f32, f32) = script
.eval("return BenillaTalentFrameTalent1:GetCenter()")
.expect("talent 1 center");
// One notch DOWN (WoW convention: -1) — the handler steps the bar one valueStep forward.
script.mouse_wheel(x, y, -1.0);
script
.run(
r#"
local scroll = BenillaTalentFrameScroll:GetVerticalScroll()
assert(scroll == BENILLA_TALENT_SCROLL_STEP,
"one notch down pans one step, got " .. tostring(scroll))
assert(BenillaTalentFrameScrollBar:GetValue() == scroll,
"the bar follows the frame, got " .. tostring(BenillaTalentFrameScrollBar:GetValue()))
"#,
)
.expect("wheel down");
// And the notch back up re-seats the top.
script.mouse_wheel(x, y, 1.0);
script
.run(
r#"
assert(BenillaTalentFrameScroll:GetVerticalScroll() == 0,
"a notch up rewinds to the top, got " .. tostring(BenillaTalentFrameScroll:GetVerticalScroll()))
"#,
)
.expect("wheel up");
assert!(script.errors().is_empty(), "{:?}", script.errors());
}