Files
benilla-octo/crates/benilla-app/tests/character_stats.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

217 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Drives the REAL `assets/ui/CharacterFrame.xml` stat rows through the engine — the first test
//! that executes the paper doll's own `PaperDollFrame_Set*` Lua at all.
//!
//! That gap is why this file exists. The character sheet's whole buff-decomposition surface — the
//! green number, the red number, the `(base +x -y)` tooltip — sat broken from the day it shipped
//! (B165, B251) because the four `POSSTAT`/`NEGSTAT`/`RESISTANCEBUFFMODS*` descriptor arrays were
//! decoded as f32 when the wire carries INT, so every real buff arrived as a rounded `0` and every
//! row took its "nothing is modifying this" leg. Both gates were green throughout: the bindings'
//! own unit tests fed the snapshot directly, and nothing ever ran the Lua that colours the row.
//!
//! So the assertions here are deliberately at the *rendered string*: what `CharacterStatFrameNStatText`
//! and `MagicResTextN` actually carry after a repaint, colour escapes included. Three shapes per
//! family — untouched (plain), buffed (green), debuffed (red) — plus the tooltip's base arithmetic,
//! which is the guard for `UnitStat`'s first return being the RAW field rather than a pre-subtracted
//! one (decision 1397: the ref Lua subtracts `posBuff`/`negBuff` itself, so a pre-subtracted first
//! return deducts the buff twice — a defect the f32 bug was hiding).
use benilla_ui::script::{UiScript, UnitCombatStats, UnitState};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The character window's load prefix, in `assets/ui/benilla.toc` order. The .toc's own entry says
/// it "depends only on Fonts/UiPanels/GameTooltip"; `ItemButtonTemplate.xml` joins them because the
/// doll's equipment slots are `SetItemButton*` buttons.
const FILES: [&str; 6] = [
"Fonts.xml",
"ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"CharacterFrame.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 two colour escapes the sheet writes, as `Fonts.xml` defines them — asserted against the
/// rendered string rather than re-derived, so a palette change fails here loudly instead of
/// silently agreeing with itself.
const GREEN: &str = "|cff20ff20";
const RED: &str = "|cffff2020";
/// A level-60 body carrying one of each case: strength heavily geared (+105), agility cursed
/// (−12), stamina untouched, and the same three shapes across the resistance schools. The negative
/// halves are the wire words an **x86**-hosted server sends; an arm64 host saturates a debuff to a
/// flat `0` on the way out (decision 1397), which is exactly why the red leg has to be exercised
/// here and cannot be exercised against the local deploy.
fn stats() -> UnitCombatStats {
UnitCombatStats {
stats: [225, 68, 178, 34, 51],
stat_pos: [105, 0, 0, 4, 0],
stat_neg: [0, -12, 0, 0, 0],
// schools: [0] armor, then holy/fire/nature/frost/shadow/arcane.
resistances: [2965, 0, 65, 20, 0, 0, 0],
resistance_pos: [0, 0, 65, 0, 0, 0, 0],
resistance_neg: [0, 0, 0, -10, 0, 0, 0],
..Default::default()
}
}
fn player() -> UnitState {
UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
race: Some("Human".into()),
class: Some("Warrior".into()),
..Default::default()
}
}
/// Repaint the sheet the way an in-world `UNIT_STATS` does and read a row's rendered text back.
fn painted(script: &mut UiScript) {
script
.run("PaperDollFrame_SetStats() PaperDollFrame_SetResistances() PaperDollFrame_SetArmor()")
.expect("the paper doll's stat rows repaint");
}
fn text_of(script: &mut UiScript, region: &str) -> String {
script
.eval::<String>(&format!(r#"return getglobal("{region}"):GetText()"#))
.unwrap_or_else(|e| panic!("reading {region}: {e}"))
}
fn tooltip_of(script: &mut UiScript, frame: &str) -> String {
script
.eval::<String>(&format!(r#"return getglobal("{frame}").tooltip"#))
.unwrap_or_else(|e| panic!("reading {frame}.tooltip: {e}"))
}
fn seated() -> UiScript {
let mut script = UiScript::new().expect("a UI VM");
load_ui(&script);
script.set_unit("player", Some(player()));
script.set_player_combat_stats(Some(stats()));
script
}
#[test]
fn a_geared_stat_renders_green_and_its_tooltip_names_the_unbuffed_base() {
let mut s = seated();
painted(&mut s);
// Strength: 225 total off a 120 base, +105 from gear. Green number, and the tooltip's
// parenthesised base is the ONE subtraction the ref Lua performs (225 − 105 − 0 = 120).
let text = text_of(&mut s, "CharacterStatFrame1StatText");
assert!(
text.starts_with(GREEN) && text.contains("225"),
"a gear-boosted strength must render green, got {text:?}"
);
let tip = tooltip_of(&mut s, "CharacterStatFrame1");
assert!(
tip.contains("Strength 225") && tip.contains("(120") && tip.contains("+105"),
"the tooltip must read the total, the unbuffed base and the delta, got {tip:?}"
);
assert!(
!tip.contains("(15"),
"base 15 = 225 − 105 − 105: the buff was deducted twice, so UnitStat's first return is \
pre-subtracted. It must be the raw UNIT_FIELD_STAT — {tip:?}"
);
}
#[test]
fn a_debuffed_stat_renders_red_and_an_untouched_one_stays_plain() {
let mut s = seated();
painted(&mut s);
let agility = text_of(&mut s, "CharacterStatFrame2StatText");
assert!(
agility.starts_with(RED) && agility.contains("68"),
"a cursed agility must render red, got {agility:?}"
);
// Stamina has neither half — the ref's `posBuff == 0 and negBuff == 0` leg: a bare number with
// no escape at all. This is the assertion the shipped bug would have PASSED, and it is here so
// a future over-correction that colours everything is caught.
let stamina = text_of(&mut s, "CharacterStatFrame3StatText");
assert_eq!(
stamina, "178",
"an unmodified stat carries no colour escape at all"
);
// Red wins over green when both halves are in play — the ref colours on `negBuff < 0` first.
s.set_player_combat_stats(Some(UnitCombatStats {
stat_pos: [105, 30, 0, 4, 0],
..stats()
}));
painted(&mut s);
let mixed = text_of(&mut s, "CharacterStatFrame2StatText");
assert!(
mixed.starts_with(RED),
"a stat with both a buff and a debuff renders RED, got {mixed:?}"
);
}
#[test]
fn resistance_rows_colour_by_which_half_is_bigger() {
let mut s = seated();
painted(&mut s);
// The five magic rows are the sheet's own school order (BENILLA_MAGICRES_IDS); resolve each
// row's school from the frame rather than assuming, so a row-order change cannot silently
// re-point these assertions.
let row_of = |s: &mut UiScript, school: i64| -> usize {
s.eval::<i64>(&format!(
"for i = 1, NUM_RESISTANCE_TYPES do \
if BENILLA_MAGICRES_IDS[i] == {school} then return i end \
end return 0"
))
.expect("the school→row map") as usize
};
let fire = row_of(&mut s, 2);
let nature = row_of(&mut s, 3);
assert!(fire > 0 && nature > 0, "fire and nature both have rows");
let fire_text = text_of(&mut s, &format!("MagicResText{fire}"));
assert!(
fire_text.starts_with(GREEN) && fire_text.contains("65"),
"a +65 fire resistance renders green, got {fire_text:?}"
);
let nature_text = text_of(&mut s, &format!("MagicResText{nature}"));
assert!(
nature_text.starts_with(RED),
"a nature school whose debuff outweighs its buff renders red, got {nature_text:?}"
);
}
/// Armor is the one row of this family the local server never decomposes — item armor lands in
/// `UNIT_FIELD_RESISTANCES[0]` itself, not in the buff split — so its plain-number leg is the live
/// shape and worth pinning as such.
#[test]
fn armor_with_no_buff_split_renders_plain() {
let mut s = seated();
painted(&mut s);
assert_eq!(text_of(&mut s, "CharacterArmorFrameStatText"), "2965");
}