Files
benilla-octo/crates/benilla-app/src/ui_script/skills_frame_tests.rs
T
SamandClaude Fable 5 8ae42cc7ab 71 more key commands, and the quest windows wear the reference's names
- the binding registry goes 130 → 201 of the client's 228 live commands: a coverage gate makes registered ∪ recorded-absent exactly the 1.12 surface, each absence naming the Lua globals whose arrival refutes it — which is how 37 rows whose mechanism had already landed came home (the keyring, the pet book, the raid tab, the six action-bar pages, both vertical multibars) — and then four mechanisms bring 71 more: the five camera views with the reference's default table and its sixteen camera CVars read out of the binary (NextView/PrevView do NOT wrap), self-cast riding UseAction's third argument with autoSelfCast a real CVar at last, the friendly target cone / assist / last-hostile as one cycler with a side (the two sides use different deadness tests on purpose, and assist neither requires an assistable target nor swings), and REPLY2 — reply to the last person you told; the friendly cone also stops borrowing the enemy side's in-combat score bonus, which would have shoved a healer targeting you to the head of every cycle
- the key names close their gaps: NUMPADEQUALS, F13-F24 and BUTTON6+ were keys the binding window listed and the file saved that the dispatcher could never press — named both ways now, with the namer and the codec structurally unable to disagree again; platform= becomes the OS gate it really is (off a Mac build the iTunes-remote category never even exists), and the Mac 1.12 slice settles that its F13 IS print-screen while F14/F15 map to UNKNOWN on purpose and stay unbindable; and the last-enemy stamp gains the four conjuncts it shipped without — you resolve, are not dead, not a ghost, not mounted, and the new target is alive-or-feigning — so TargetLastEnemy can no longer land somewhere the real client's never would
- the quest windows wear the reference's names: ~40 of the quest window's globals were ours (the reference's name usually sitting in the comment beside them), its two six-item reward pools merge into the reference's single ten-item run with the update function ported line-for-line, the four panels and the quest log take REAL scroll frames (the thumb drags now), the gossip window gets its greeting panel, the two mail panes' inert painted-at-rest scrollbars become real ones, the Skills detail pane nests as the reference has it, and nine private constants take their real names; the one that broke a feature detect: our QuestLogCount — a name 1.12 doesn't publish — made pfUI conclude it was running on TBC and die in that branch
- an EditBox is born with its constructor's five regions (the text, three selection quads and the caret), so GetRegions answers the reference's own count and authored regions start at index 6 — and a bare <NormalTexture/> now builds a live blank texture, since the element is what creates the region, not its file=; OpacityFrame lands, its first customer having arrived; and the equip / auto-equip / use bind confirms complete the soulbind arc — three different predicates with no quality leg (borrowing the loot arm's would have silenced the confirm on every white bind-on-equip item), Accept re-issuing the original action with the reference's suppress flag because 1.12 ships no confirm packet, and the soulstone use going through the same gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 20:16:49 +07:00

474 lines
18 KiB
Rust

//! The shipped **Skills tab** (`assets/ui/SkillFrame.xml`) driven end-to-end, engine-only (no
//! Bevy) — the per-window test module the spellbook/trainer/bank files already establish, split
//! out of `character_tests.rs` (which owns the paperdoll + the tab round-trip) so the skills-pane
//! paint law has a home of its own.
//!
//! What it pins is the **proficiency row**: `SkillFrame.lua`'s own `skillMaxRank == 1` gate draws
//! a full GRAY bar with NO rank text, and two different kinds of line land in it — the armor
//! proficiencies, which the server really does report as `1/1`, and the **single-rank** lines
//! (class skills, Dual Wield, racials, the per-mount riding lines), whose `skillMaxRank` the
//! engine overrides to `1` off `SkillRaceClassInfo.flags & 0x400` however high the server's own
//! descriptor is (`benilla-ui`'s `SkillEntry::mono`; wow-re `0x4d3610`'s `4d38b1` branch). A
//! hunter's `Beast Mastery` on vmangos arrives as `300/300` and must still read gray and
//! numberless, exactly as it does in the real client.
use benilla_ui::script::{QuadContent, SkillEntry, SkillsState, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the
/// character/spellbook tests' loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) {
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
);
}
fn skill(
skill_id: u32,
name: &str,
value: u32,
max: u32,
mono: bool,
(category_id, category_name, category_order): (u32, &str, u32),
) -> SkillEntry {
SkillEntry {
skill_id,
name: name.into(),
value,
max,
temp_bonus: 0,
perm_bonus: 0,
min_level: 0,
cost_index: 0,
category_id,
category_name: category_name.into(),
category_order,
description: String::new(),
abandonable: false,
mono,
}
}
/// The Skills page, shown, with one line of each shape — a hunter's real vmangos numbers:
/// `Beast Mastery 300/300` (single-rank, `SkillRaceClassInfo` 0x410), `Defense 12/60` (a normal
/// weapon line), `Cloth 1/1` (an armor proficiency the server itself caps).
fn shown_skills_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// UIPanelTemplates.xml is NOT optional even though this list ran without it for a year: a
// MISSING template is a loader *warning*, not an error, so an under-loaded list passes the
// assert in load_xml and then fails later on geometry that silently never got built.
// SkillDetailScrollFrame inherits UIPanelScrollFrameTemplate from that file.
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"CharacterFrame.xml",
"SkillFrame.xml",
] {
load_xml(&s, f);
}
s.set_skills(SkillsState {
entries: vec![
skill(50, "Beast Mastery", 300, 300, true, (7, "Class Skills", 2)),
skill(95, "Defense", 12, 60, false, (6, "Weapon Skills", 5)),
skill(415, "Cloth", 1, 1, false, (8, "Armor Proficiencies", 6)),
],
});
s.run(r#"ToggleCharacter("SkillFrame")"#).unwrap();
s.resolve();
s
}
/// Row slot `i`'s rank text and bar color — the two things the proficiency gate decides.
fn row(s: &mut UiScript, i: u32) -> (String, [f32; 4]) {
let text = s
.eval::<String>(&format!(
"return SkillRankFrame{i}SkillRank:GetText() or \"\""
))
.unwrap();
let c = s
.eval::<(f32, f32, f32, f32)>(&format!("return SkillRankFrame{i}:GetStatusBarColor()"))
.unwrap();
(text, [c.0, c.1, c.2, c.3])
}
/// Row slot `i`'s **trough** colour — the `$parentBackground` texture behind the fill, which the
/// ref recolours per branch alongside the fill (`SkillFrame.lua:158` normal / `:167` proficiency).
fn row_bg(s: &mut UiScript, i: u32) -> [f32; 4] {
let c = s
.eval::<(f32, f32, f32, f32)>(&format!(
"return SkillRankFrame{i}Background:GetVertexColor()"
))
.unwrap();
[c.0, c.1, c.2, c.3]
}
#[test]
fn a_single_rank_line_paints_gray_with_no_rank_text() {
let mut s = shown_skills_page();
assert!(
s.eval::<bool>("return SkillFrame:IsVisible()").unwrap(),
"the Skills page is up"
);
// Visible rows: 1 header "Class Skills", 2 Beast Mastery, 3 header "Weapon Skills",
// 4 Defense, 5 header "Armor Proficiencies", 6 Cloth — one list slot each.
let (text, color) = row(&mut s, 2);
assert_eq!(
text, "",
"Beast Mastery is single-rank: no rank text, though the server said 300/300"
);
assert_eq!(color, [0.5, 0.5, 0.5, 1.0], "and a gray bar");
assert_eq!(
row_bg(&mut s, 2),
[1.0, 1.0, 1.0, 0.5],
"over the proficiency branch's WHITE trough (ref SkillFrame.lua:167)"
);
// The armor proficiency reaches the same branch by the server's own 1/1.
let (text, color) = row(&mut s, 6);
assert_eq!(text, "", "Cloth is 1/1: no rank text");
assert_eq!(color, [0.5, 0.5, 0.5, 1.0], "gray too");
assert_eq!(row_bg(&mut s, 6), [1.0, 1.0, 1.0, 0.5], "white trough too");
// The control: a normal weapon line still reads its numbers, in blue.
let (text, color) = row(&mut s, 4);
assert_eq!(text, "12/60", "Defense keeps its rank text");
assert_eq!(color, [0.0, 0.0, 1.0, 0.5], "and the blue fill");
assert_eq!(
row_bg(&mut s, 4),
[0.0, 0.0, 0.75, 0.5],
"over the normal branch's DARK BLUE trough (ref SkillFrame.lua:158)"
);
// And what those troughs actually DRAW at. The template declares the texture `<Color 1,1,1,0.2>`
// — a real texel — and the vertex colour MULTIPLIES it, alpha included (the composition law,
// `benilla-ui` `script::tests::regions`): the proficiency's white reaches the screen at
// `0.2 x 0.5 = 0.1`, not 0.5. Every solid-colour quad this page emits is one of these troughs.
let solids: Vec<[f32; 4]> = s
.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: None,
color: Some(c),
..
} => Some(*c),
_ => None,
})
.collect();
assert!(
solids.contains(&[1.0, 1.0, 1.0, 0.1]),
"a proficiency trough draws white at 0.1; got {solids:?}"
);
assert!(
solids.contains(&[0.0, 0.0, 0.75, 0.1]),
"a normal row's trough draws dark blue at 0.1; got {solids:?}"
);
assert!(
!solids.iter().any(|c| c[3] == 0.5),
"nothing draws at the raw vertex alpha — that was the replace bug; got {solids:?}"
);
// Selecting the single-rank row paints the detail pane the same way (the shared PaintBar).
s.run("SetSelectedSkill(2) BenillaSkillFrame_Update()")
.unwrap();
assert_eq!(
s.eval::<String>("return SkillDetailStatusBarSkillRank:GetText() or \"\"")
.unwrap(),
"",
"the detail pane's own bar is a proficiency too"
);
assert_eq!(
s.eval::<(f32, f32, f32, f32)>("return SkillDetailStatusBarBackground:GetVertexColor()")
.unwrap(),
(1.0, 1.0, 1.0, 0.5),
"including its trough (the shared PaintBar; ref SkillFrame.lua:376)"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// **The director's reference screenshot, reproduced row for row** (decision 1091). A real
/// level-60 tauren hunter's `PLAYER_SKILL_INFO` block — read straight out of the live vmangos
/// `character_skills` rows the A/B was taken on — fed through the app's own display predicate
/// ([`crate::ui_char::skills_row`]) and the engine's grouping, against the REAL shipped DBCs.
///
/// What the reference client shows for exactly this block is the expectation below: four headers,
/// fourteen rows — and **no** `Dual Wield`, `Tauren Racial` or `GENERIC (DND)`, and no `Secondary
/// Skills` header, though the server sends all three lines at 300/300 like any other. Skips
/// without client data.
#[test]
fn a_real_hunters_block_lists_exactly_what_the_reference_client_lists() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let catalog = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines");
// (skill line, value, max) — Twohunter's rows, verbatim. Tauren (race 6) hunter (class 3), 60.
const BLOCK: &[(u16, u16, u16)] = &[
(44, 300, 300), // Axes
(46, 300, 300), // Guns
(50, 300, 300), // Beast Mastery — single-rank
(51, 300, 300), // Survival — single-rank
(95, 300, 300), // Defense
(109, 300, 300), // Language: Orcish
(115, 300, 300), // Language: Taurahe
(118, 300, 300), // Dual Wield — HIDDEN
(124, 300, 300), // Tauren Racial — HIDDEN
(162, 300, 300), // Unarmed
(163, 300, 300), // Marksmanship — single-rank
(173, 300, 300), // Daggers
(183, 300, 300), // GENERIC (DND) — HIDDEN
(226, 300, 300), // Crossbows
(413, 1, 1), // Mail
(414, 1, 1), // Leather
(415, 1, 1), // Cloth
];
let entries: Vec<SkillEntry> = BLOCK
.iter()
.filter_map(|&(skill_id, value, max)| {
let slot = benilla_protocol::messages::PlayerSkillSlot {
skill_id,
step: 0,
value,
max,
temp_bonus: 0,
perm_bonus: 0,
};
crate::ui_char::skills_row(&slot, &catalog, 6, 3, 60)
})
.collect();
let mut s = UiScript::new().unwrap();
s.set_skills(SkillsState { entries });
let n = s.eval::<i64>("return GetNumSkillLines()").unwrap();
let rows: Vec<(String, bool)> = (1..=n)
.map(|i| {
s.eval::<(String, Option<i64>)>(&format!(
"local n,h = GetSkillLineInfo({i}) return n,h"
))
.map(|(name, header)| (name, header.is_some()))
.unwrap()
})
.collect();
let expected: Vec<(&str, bool)> = vec![
("Class Skills", true),
("Beast Mastery", false),
("Marksmanship", false),
("Survival", false),
("Weapon Skills", true),
("Axes", false),
("Crossbows", false),
("Daggers", false),
("Defense", false),
("Guns", false),
("Unarmed", false),
("Armor Proficiencies", true),
("Cloth", false),
("Leather", false),
("Mail", false),
("Languages", true),
("Language: Orcish", false),
("Language: Taurahe", false),
];
let got: Vec<(&str, bool)> = rows.iter().map(|(n, h)| (n.as_str(), *h)).collect();
assert_eq!(got, expected, "the pane, row for row");
// The three the server sends and the client never lists.
for hidden in ["Dual Wield", "Tauren Racial", "GENERIC (DND)"] {
assert!(
!got.iter().any(|(n, _)| *n == hidden),
"{hidden} must not appear"
);
}
// And the class lines still read as proficiencies despite their 300/300 descriptor.
assert_eq!(
s.eval::<i64>("return (select(7, GetSkillLineInfo(2)))")
.unwrap(),
1,
"Beast Mastery's skillMaxRank"
);
}
/// The page's own **CLOSE** button (decision 1496). `SkillFrameCancelButton` is live in the
/// reference — the XML comment that swallows its `SkillFrameAcceptButton` neighbour closes one
/// line above it (ref `SkillFrame.xml` l.337/339) — and the director's screenshot of an empty
/// bottom-right seat is what that misread cost. Pins the button's existence, the ref's own seat
/// (80x22 centred on the page's TOPLEFT + (305,-422)), and that it closes the window.
#[test]
fn the_pages_close_button_sits_where_the_reference_seats_it_and_closes_the_window() {
let mut s = shown_skills_page();
assert!(
s.eval::<bool>("return SkillFrameCancelButton ~= nil")
.unwrap(),
"the ref's SkillFrameCancelButton is built"
);
assert!(
s.eval::<bool>("return SkillFrameCancelButton:IsVisible()")
.unwrap(),
"and shown — nothing in the ref's SkillFrame.lua ever hides it"
);
// The ref's geometry, read page-relative so the assertion is the ref's own numbers.
let (page_top, page_left) = s
.eval::<(f64, f64)>("return SkillFrame:GetTop(), SkillFrame:GetLeft()")
.unwrap();
let (top, bottom, left, right) = s
.eval::<(f64, f64, f64, f64)>(
"local b = SkillFrameCancelButton \
return b:GetTop(), b:GetBottom(), b:GetLeft(), b:GetRight()",
)
.unwrap();
assert_eq!(
(
(left + right) / 2.0 - page_left,
(top + bottom) / 2.0 - page_top,
right - left,
top - bottom,
),
(305.0, -422.0, 80.0, 22.0),
"CENTER of the page's TOPLEFT at (305,-422), 80x22 (ref l.339-348)"
);
// Its label is the CLOSE global string's seat, in the panel-button gold.
let label = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == "CLOSE" => Some(*color),
_ => None,
})
.expect("the CLOSE label draws");
assert_eq!(
label,
Some([1.0, 0.82, 0.0, 1.0]),
"GameFontNormal — the UIPanelButtonTemplate face's own normal font"
);
// And it does what the ref's OnClick does: page down, window down.
s.run("SkillFrameCancelButton:Click()").unwrap();
s.resolve();
assert!(
!s.eval::<bool>("return SkillFrame:IsVisible()").unwrap(),
"the page hides"
);
assert!(
!s.eval::<bool>("return CharacterFrame:IsVisible()").unwrap(),
"and the window with it (ref l.352-355)"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The **ALL** fold's face and seat (decision 1496). Both halves of the director's report: the
/// label is the row font — `GameFontHighlight`, WHITE at 12 — not `GameFontNormalSmall`'s yellow
/// 10, and the button rides 3px BELOW the tab cap's centre (the ref's own `(-3,-3)` off the left
/// cap), not 3px above it, which is where the offset copied from `TrainerFrame.xml` put it.
#[test]
fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
let s = shown_skills_page();
let label = s
.extract()
.iter()
.find_map(|q| match &q.content {
QuadContent::Text {
text: Some(t),
color,
font_height,
..
} if t == "ALL" => Some((*color, *font_height)),
_ => None,
})
.expect("the ALL label draws");
assert_eq!(
label,
(Some([1.0, 1.0, 1.0, 1.0]), Some(12.0)),
"GameFontHighlight — the SAME face every SkillTypeLabel row wears"
);
let (tab_top, tab_bottom) = s
.eval::<(f64, f64)>(
"return SkillFrameExpandTabLeft:GetTop(), SkillFrameExpandTabLeft:GetBottom()",
)
.unwrap();
let (btn_top, btn_bottom, btn_left) = s
.eval::<(f64, f64, f64)>(
"local b = SkillFrameCollapseAllButton \
return b:GetTop(), b:GetBottom(), b:GetLeft()",
)
.unwrap();
assert_eq!(
(tab_top + tab_bottom) / 2.0 - (btn_top + btn_bottom) / 2.0,
3.0,
"the fold sits 3px BELOW the cap's centre (ref's -3); +3 above was the bug"
);
let cap_right = s
.eval::<f64>("return SkillFrameExpandTabLeft:GetRight()")
.unwrap();
assert_eq!(
btn_left - cap_right,
-3.0,
"and 3px back over the cap (ref's -3 on x)"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}
/// The tab's **fit law** (ref `SkillFrameExpandButtonFrame`'s OnLoad, l.312-316:
/// `SetWidth(GetTextWidth()+45)`). benilla seats the VM's font engine at the frame boundary, so
/// the XML-load call reads 0 — the guard leaves the declared width standing, and the first Update
/// with a measurer fits the tab. Both states are pinned here because only the second one is the
/// reference's, and only the first is what a cold load sees.
#[test]
fn the_expand_tab_fits_its_label_once_a_measure_answers() {
let mut s = shown_skills_page();
assert_eq!(
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
.unwrap(),
54.0,
"unmeasured, the declared width stands — a 0 measure must not squash the tab to 45"
);
s.set_text_measurer(Box::new(super::FixedWidthFont(7.0)));
s.run("BenillaSkillFrame_Update()").unwrap();
s.resolve();
assert_eq!(
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
.unwrap(),
7.0 * 3.0 + 45.0,
"then the ref's own law: the label's width + 45"
);
// The middle slab is the span between the two caps, so the fit reaches the art for free.
let (mid_l, mid_r, cap_r_l) = s
.eval::<(f64, f64, f64)>(
"return SkillFrameExpandTabMiddle:GetLeft(), SkillFrameExpandTabMiddle:GetRight(), \
SkillFrameExpandTabRight:GetLeft()",
)
.unwrap();
assert_eq!(mid_r, cap_r_l, "the middle stretches to the right cap");
assert_eq!(
mid_r - mid_l,
66.0 - 16.0,
"and carries the whole span minus the two caps"
);
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
}