Files
benilla-octo/crates/benilla-app/tests/character_stats.rs
T
Sam SungandClaude Opus 5.5 f98c1a5396 Comments in benilla-app, the glue screens, harness, settings and the rest: one line of fact each, no history, no record numbers (#365)
Part of #41: the last of five `benilla-app` pieces. This one is everything the others did not cover: the addon harness, the settings registry, the glue screens (login, realm list, character select and create), key bindings, the minimap, nameplates, the loading screen, transports, the performance instruments, the smaller modules, the integration tests, and the UI asset files (`OptionsFrame.xml`, `benilla.toc` and the rest). Their comments are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 27,351 comment lines become 11,010.

Fixes #33. Fixes #103.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - The weapon trail's emissive is 0 (`0x70ca30`): the trail is a type-5 render record, so its ambient-only light term is exact.
  - The camera-style dropdown writes "1", "2", "0" (`UIOptionsFrame.lua:525,536,547`); several register-site addresses in the settings table are corrected.
  - The graphics settings are held until `RestartGx`, as in 1.12; `Fonts.xml`'s early load no longer carries a false reason.
  - vmangos sends a player-name invalidation after a rename (`CharacterHandler.cpp:839`), and the cinematic camera's announce packet has an empty handler there (`MiscHandler.cpp:919`).
  - Stock FrameXML and GlueXML line citations are checked against the stock 1.12 files, and vmangos line citations against the pinned revision.
- **Deviation labels.** "Deviation:" now marks only a choice with its recorded reason. Declining to copy a reference defect is stated plainly, and so is an unbuilt behaviour or an untraced one.
- **Verification.** Every file strips to identical code, apart from test literals in `login/`, `death/` and `ui_script/death_tests.rs` that now use placeholder account and character names, and `cargo fmt --check` is clean. Six second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference.

A second commit corrects one comment in `benilla-ui` (`unit_is_grey`: the reference's exclusion is players only). A third lowers `ui_script_wall`'s ceiling from 45 to 40: five gameplay files named `UiScript` only in comments the rewrite removed, and the ratchet asks for the number to follow.

Behaviour differences the rewrite surfaced are filed as their own issues (#339 to #353 among them). String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 00:27:56 +07:00

295 lines
11 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 stock `PaperDollFrame.xml` stat rows through the engine and asserts the rendered
//! strings, colour escapes included: untouched (plain), buffed (green) and debuffed (red), plus the
//! tooltip's base, which the stock Lua computes by subtracting both buff halves from `UnitStat`'s
//! raw first return.
mod common;
use benilla_ui::script::{UiScript, UnitCombatStats, UnitState};
/// The paper doll's load prefix, in `assets/ui/benilla.toc` order. `CharacterFrame.xml` is left
/// out: the rows are repainted directly and the window never opens. Every row formats a global
/// string: `SPELL_STAT<n>_NAME` (`PaperDollFrame.lua:140`), `RESISTANCE<n>_NAME` and
/// `RESISTANCE_TOOLTIP_SUBTEXT` (`:187`/`:226`), `ARMOR` and `ARMOR_TOOLTIP` (`:244`/`:249`).
const FILES: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\Fonts.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
// `Model_OnLoad` (`UIParent.lua:1421`), which the model pane's `<OnLoad>` calls at load.
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml", // the dialog engine
"Interface\\FrameXML\\GameTooltip.xml",
// Each slot button's OnLoad calls `CooldownFrame_SetTimer` (`PaperDollFrame.lua:692`).
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\PaperDollFrame.xml",
];
fn load_ui(script: &UiScript) {
for file in FILES {
common::load_ui(script, file);
}
}
/// The sheet's two colour escapes, as `Fonts.xml` defines them.
const GREEN: &str = "|cff20ff20";
const RED: &str = "|cffff2020";
/// A level-60 body with a geared (+105), a cursed (−12) and an untouched stat, and the same three
/// shapes across the resistances. The negative halves are what an x86-hosted server sends; an
/// arm64 host saturates a debuff to 0.
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 as an in-world `UNIT_STATS` does.
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_field(script: &mut UiScript, frame: &str, field: &str) -> String {
script
.eval::<String>(&format!(r#"return getglobal("{frame}").{field}"#))
.unwrap_or_else(|e| panic!("reading {frame}.{field}: {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 _data = benilla_formats::wow_data_or_skip!();
let mut s = seated();
painted(&mut s);
// Strength 225 = 120 base + 105 gear; the stock Lua subtracts the buff once for the base.
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 _data = benilla_formats::wow_data_or_skip!();
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 stock `posBuff == 0 and negBuff == 0` leg, no escape.
let stamina = text_of(&mut s, "CharacterStatFrame3StatText");
assert_eq!(
stamina, "178",
"an unmodified stat carries no colour escape at all"
);
// Red wins over green: the stock Lua tests `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 _data = benilla_formats::wow_data_or_skip!();
let mut s = seated();
painted(&mut s);
// A row's school is its frame's `id=`, as `PaperDollFrame_SetResistances` reads it:
// `MagicResFrame1` is 6 (arcane), then 2..5 (`PaperDollFrame.xml:654-738`).
let row_of = |s: &mut UiScript, school: i64| -> usize {
s.eval::<i64>(&format!(
"for i = 1, NUM_RESISTANCE_TYPES do \
if getglobal(\"MagicResFrame\"..i):GetID() == {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:?}"
);
}
/// `PaperDollFrame_SetDamage` unpacks `UnitDamage`'s seven values by position and divides by the
/// seventh. The row shows the raw range floored and ceiled; the tooltip range has the multiplier
/// divided out and the flat bonuses subtracted (`PaperDollFrame.lua:295-299`).
#[test]
fn the_melee_block_unpacks_seven_values_and_does_the_references_arithmetic() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = seated();
// Every one of the seven slots is distinct and non-zero, so a misplaced slot cannot pass.
s.set_player_combat_stats(Some(UnitCombatStats {
main_attack_time_ms: 2600,
min_damage: 40.5,
max_damage: 61.5,
physical_bonus_pos: 12,
physical_bonus_neg: -3,
damage_percent: 1.1,
attack_power: 780,
attack_power_pos: 30,
attack_power_neg: -10,
..stats()
}));
s.run("PaperDollFrame_SetDamage() PaperDollFrame_SetAttackPower()")
.expect("the melee rows repaint");
// The row: the raw range, green because the bonuses are net positive.
assert_eq!(
text_of(&mut s, "CharacterDamageFrameStatText"),
format!("{GREEN}40 - 62|r")
);
// The tooltip: the base range, then each modifier in the stock order and colours.
assert_eq!(
tooltip_of_field(&mut s, "CharacterDamageFrame", "damage"),
format!("27 - 47{GREEN} +12|r{RED} -3|r{GREEN} x110%|r")
);
let speed: f64 = s
.eval("return CharacterDamageFrame.attackSpeed")
.expect("the hover's attack speed");
assert!((speed - 2.6).abs() < 1e-6, "got {speed}");
// dps = fullDamage / speed, fullDamage = (base + pos + neg) * percent = 51.0.
let dps: f64 = s.eval("return CharacterDamageFrame.dps").expect("the dps");
assert!((dps - 51.0 / 2.6).abs() < 0.01, "got {dps}");
// No offhand weapon: the offhand speed is nil, which the hover branches on.
assert!(s
.eval::<Option<f64>>("return CharacterDamageFrame.offhandAttackSpeed")
.unwrap()
.is_none());
// `PaperDollFormatStat` colours red if anything is negative, so a mixed pair is red.
assert_eq!(
text_of(&mut s, "CharacterAttackPowerFrameStatText"),
format!("{RED}800|r")
);
let ap_tip = tooltip_of(&mut s, "CharacterAttackPowerFrame");
assert!(
ap_tip.contains("800") && ap_tip.contains("(780") && ap_tip.contains("+30"),
"the AP tooltip names the effective, the base and the buff, got {ap_tip:?}"
);
// No bonuses and no multiplier: `totalBonus == 0`, no colour escape.
s.set_player_combat_stats(Some(UnitCombatStats {
main_attack_time_ms: 2600,
min_damage: 40.5,
max_damage: 61.5,
damage_percent: 1.0,
..stats()
}));
s.run("PaperDollFrame_SetDamage()").unwrap();
assert_eq!(text_of(&mut s, "CharacterDamageFrameStatText"), "40 - 62");
}
/// With the ranged slot empty, all three ranged rows read `NOT_APPLICABLE`, carried between them
/// by the `PaperDollFrame.noRanged` latch (`PaperDollFrame.lua:436-441`).
#[test]
fn the_ranged_rows_fall_back_to_not_applicable_with_an_empty_ranged_slot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = seated();
s.run(
"PaperDollFrame_SetRangedAttack() PaperDollFrame_SetRangedDamage() PaperDollFrame_SetRangedAttackPower()",
)
.expect("the ranged rows repaint");
let na: String = s
.eval("return NOT_APPLICABLE")
.expect("the ref's own string");
for row in [
"CharacterRangedAttackFrameStatText",
"CharacterRangedDamageFrameStatText",
"CharacterRangedAttackPowerFrameStatText",
] {
assert_eq!(text_of(&mut s, row), na, "{row}");
}
// The guard `CharacterRangedDamageFrame_OnEnter` opens with.
assert!(s
.eval::<Option<String>>("return CharacterRangedDamageFrame.damage")
.unwrap()
.is_none());
}
/// Item armor lands in `UNIT_FIELD_RESISTANCES[0]` itself, not in the buff split.
#[test]
fn armor_with_no_buff_split_renders_plain() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = seated();
painted(&mut s);
assert_eq!(text_of(&mut s, "CharacterArmorFrameStatText"), "2965");
}