Files
benilla-octo/crates/benilla-app/src/ui_tooltip/tests.rs
T
2f35f63274 The trainer's ability icon shows its spell tooltip on the first hover (#158)
- Fix trainer tooltip on first hover
- The spell-tooltip feed keeps its in-game gate, and a test holds it after the trainer feed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
2026-09-26 14:41:33 +07:00

520 lines
21 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.
//! The spell-view cell tests, against the real 5875 data, and the feed's place in the schedule.
use super::*;
use crate::ui_action::Spells;
/// A view context with no player state, the DBC-only half of the builder.
struct TestCtx {
items: Items,
commands: NetCommands,
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
/// The builder's two lookups, over the shipped `GlobalStrings.lua`: a stub would pass on
/// wording the client never shows.
get: Box<Getter>,
text: Box<Filler>,
/// Empty: every cell here is graded as an untalented character.
spell_mods: crate::spell::SpellModifiers,
}
type Getter = dyn Fn(&str) -> Option<String>;
type Filler = dyn Fn(&str, &[i64]) -> Option<String>;
impl TestCtx {
fn new() -> Self {
let (tx, rx) = crossbeam_channel::unbounded();
let vm = std::rc::Rc::new(benilla_ui::script::UiScript::new().expect("VM"));
crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua");
let (for_get, for_text) = (vm.clone(), vm);
Self {
items: Items::default(),
commands: NetCommands(tx),
_rx: rx,
get: Box::new(move |key| benilla_ui::strings::global(for_get.lua(), key)),
spell_mods: crate::spell::SpellModifiers::default(),
text: Box::new(move |key, args: &[i64]| {
let template = benilla_ui::strings::global(for_text.lua(), key)?;
let args: Vec<_> = args
.iter()
.map(|n| benilla_ui::strings::Arg::D(*n))
.collect();
Some(benilla_ui::strings::fill(&template, &args))
}),
}
}
fn ctx<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
) -> ViewCtx<'a, 'w, 's> {
self.ctx_for(objects, form, sub_classes, None)
}
fn ctx_engaged<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
store: Option<&'a ObjectStore>,
target_reach: f32,
) -> ViewCtx<'a, 'w, 's> {
let mut ctx = self.ctx_for(objects, 0, None, store);
ctx.attack_target_reach = Some(target_reach);
ctx
}
fn ctx_for<'a, 'w, 's>(
&'a mut self,
objects: &'a Objects<'w, 's>,
form: u8,
sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>,
store: Option<&'a ObjectStore>,
) -> ViewCtx<'a, 'w, 's> {
ViewCtx {
home_area: None,
form,
store,
combat_reach: store.map_or(1.5, |s| s.0.unit_combat_reach()),
attack_target_reach: None,
objects,
items: &mut self.items,
commands: &self.commands,
sub_classes,
spell_mods: &self.spell_mods,
get: self.get.as_ref(),
text: self.text.as_ref(),
}
}
}
/// An object index with nothing streamed: no worn item, and every reagent count 0.
fn no_objects() -> crate::ui_items::TestObjects {
crate::ui_items::TestObjects::new()
}
fn empty_player() -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
22u16, 100u32,
)]))
}
/// Fireball rank 1 (133) end to end: description 138, cast index 18 (1500 ms), duration 30.
#[test]
fn fireball_view_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
let v = spell_tooltip_view(133, &spells, &mut t.ctx(&objects, 0, None)).expect("Fireball view");
assert_eq!(v.name, "Fireball");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost.as_deref(), Some("30 Mana"));
assert_eq!(v.range.as_deref(), Some("35 yd range"));
assert_eq!(v.cast_time.as_deref(), Some("1.5 sec cast"));
assert_eq!(
v.cooldown, None,
"Fireball has no recovery in either column"
);
assert_eq!(v.requires_form, None);
assert!(
v.description.starts_with("Hurls a fiery ball that causes"),
"got: {}",
v.description
);
assert!(
v.description.contains(" to ") && v.description.contains("Fire damage"),
"the $s range substituted: {}",
v.description
);
assert!(
!v.description.contains('$'),
"no unsubstituted tokens: {}",
v.description
);
// Charge rank 1 (100): range row 95 = {8, 25}, a cooldown only in the category column
// (15000), and the Stances form line (0x10000 is form 17).
let v = spell_tooltip_view(100, &spells, &mut t.ctx(&objects, 0, None)).expect("Charge view");
assert_eq!(v.name, "Charge");
assert_eq!(v.rank.as_deref(), Some("Rank 1"));
assert_eq!(v.cost, None, "Charge costs nothing (it generates rage)");
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
assert_eq!(v.cooldown.as_deref(), Some("15 sec cooldown"));
assert_eq!(v.requires_form.as_deref(), Some("Requires Battle Stance"));
assert!(!v.form_met, "form 0 (unshifted) does not satisfy the mask");
assert_eq!(
v.description,
"Charge an enemy, generate 9 rage, and stun it for 1 sec. Cannot be used in combat."
);
let v = spell_tooltip_view(100, &spells, &mut t.ctx(&objects, 17, None)).expect("Charge view");
assert!(v.form_met, "form 17 = Battle Stance satisfies the mask");
// A permissive Stances mask (`AttributesEx2` bit 19) prints no line, though its forms have
// names: bit 27 is form 28, bit 31 form 32.
for (id, name, mask) in [
(588u32, "Inner Fire", 0x0800_0000u32),
(8122, "Psychic Scream", 0x0800_0000),
(2061, "Flash Heal", 0x8000_0000),
(5176, "Wrath", 0x4000_0000),
] {
let d = spells.catalog.get(id).expect(name);
assert_eq!(d.stances, mask, "{name} carries the permissive mask");
assert!(
d.form_mask_is_permissive(),
"{name} carries AttributesEx2 b19"
);
let v = spell_tooltip_view(id, &spells, &mut t.ctx(&objects, 0, None)).expect(name);
assert_eq!(v.requires_form, None, "{name} demands no form");
}
}
#[test]
fn cost_and_cast_cells_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
// A level-60 store: max health 4000, base mana 1000 (fields: health 22, max health 28,
// level 34, base mana 162).
let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 3500u32),
(28, 4000),
(34, 60),
(162, 1000),
]));
// Bloodrage (2687): 20% of max health, a flat number through the health fallback; bare
// "Instant" on a non-mana type.
let v = spell_tooltip_view(
2687,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Bloodrage view");
assert_eq!(v.cost.as_deref(), Some("800 Health"), "20% of 4000");
assert_eq!(v.cast_time.as_deref(), Some("Instant"));
// Life Tap (1454): the 5875 data has no cost columns for any rank, so the cell is empty.
let v = spell_tooltip_view(
1454,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Life Tap view");
assert_eq!(v.cost, None, "1.12 Life Tap has no cost cell");
assert_eq!(
v.cast_time.as_deref(),
Some("Instant"),
"health type, not mana"
);
// Health Funnel (755): the `_PER_TIME` form in health, and a channeled cast cell.
let v = spell_tooltip_view(
755,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Health Funnel view");
assert_eq!(v.cost.as_deref(), Some("11 Health, plus 5 per sec"));
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
// Judgement (20271): 6% of base mana resolves to a flat number; a DBC-only view has only
// the flat cost, none here.
let v = spell_tooltip_view(
20271,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Judgement view");
assert_eq!(v.cost.as_deref(), Some("60 Mana"), "6% of base mana 1000");
let v =
spell_tooltip_view(20271, &spells, &mut t.ctx(&objects, 0, None)).expect("Judgement view");
assert_eq!(v.cost, None, "a DBC-only view cannot resolve a pct cost");
// Heroic Strike (78): rage is wire 150 ÷ 10, and "Next melee" is the cast cell.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.cost.as_deref(), Some("15 Rage"));
assert_eq!(v.cast_time.as_deref(), Some("Next melee"));
// Throw (2764) and Auto Shot (75) read "Attack speed" off `Attributes & 0x2` alone; Attack
// (6603), `Effect[0]` ATTACK, omits the line (`0x52eb3c`).
let v = spell_tooltip_view(
2764,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Throw view");
assert_eq!(v.cast_time.as_deref(), Some("Attack speed"));
let v = spell_tooltip_view(75, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Auto Shot view");
assert_eq!(v.cast_time.as_deref(), Some("Attack speed"));
let v = spell_tooltip_view(
6603,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Attack view");
assert_eq!(v.cast_time, None, "ATTACK Effect[0] omits the line");
// Mind Flay (15407): a channeled mana spell.
let v = spell_tooltip_view(
15407,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Mind Flay view");
assert_eq!(v.cost.as_deref(), Some("45 Mana"));
assert_eq!(v.cast_time.as_deref(), Some("Channeled"));
}
/// The range cell (`0x52e9a2`-`0x52ea8c`): the melee row prints a number off the reaches, an
/// authored row its own, and the on-next-swing and self rows nothing.
#[test]
fn range_cell_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
// A default-reach player: `UNIT_FIELD_COMBATREACH` (130) unset reads the descriptor's 1.5.
let store = empty_player();
// Sinister Strike (1752) is on row 2 "Combat Range", the one row with flags bit 0:
// 1.5 + 1.5 + 1.3333334 = 4.333 is under the 5.0 floor.
let d = spells.catalog.get(1752).expect("Sinister Strike 1752");
assert_eq!(d.range_index, 2, "the melee row");
assert!(spells.ranges.get(2).expect("row 2").is_melee());
let v = spell_tooltip_view(
1752,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("5 yd range"));
// A 4.0 reach: 4.0 + 4.0 + 1.3333334 = 9.333, rounded to 9.
let big = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(
130u16,
4.0f32.to_bits(),
)]));
let v = spell_tooltip_view(1752, &spells, &mut t.ctx_for(&objects, 0, None, Some(&big)))
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("9 yd range"));
// The second reach is the auto-attack target's: 1.5 + 4.0 + 1.3333334 = 6.833, rounded to 7.
let v = spell_tooltip_view(
1752,
&spells,
&mut t.ctx_engaged(&objects, Some(&store), 4.0),
)
.expect("Sinister Strike view");
assert_eq!(v.range.as_deref(), Some("7 yd range"));
// An authored row: Fireball's 0-35.
let v = spell_tooltip_view(
133,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Fireball view");
assert_eq!(v.range.as_deref(), Some("35 yd range"));
// An authored pair, `"%d-%d"`: Charge's 8-25, which the reach does not change.
let v = spell_tooltip_view(100, &spells, &mut t.ctx_for(&objects, 0, None, Some(&big)))
.expect("Charge view");
assert_eq!(v.range.as_deref(), Some("8-25 yd range"));
// No cell: Heroic Strike (78)'s on-next-swing attributes skip it before the resolver, and
// Bloodrage (2687)'s self row resolves max 0.
let v = spell_tooltip_view(78, &spells, &mut t.ctx_for(&objects, 0, None, Some(&store)))
.expect("Heroic Strike view");
assert_eq!(v.range, None, "Attributes & 0x404 → no range cell");
let v = spell_tooltip_view(
2687,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Bloodrage view");
assert_eq!(v.range, None, "the self row resolves max 0");
}
/// The required-item, chance-to-X and reagents lines on the real data.
#[test]
fn the_required_item_chance_and_reagent_lines_on_real_data() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let spells = Spells {
catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"),
forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("SpellShapeshiftForm.dbc"),
ranges: benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"),
cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("SpellCastTimes.dbc"),
durations: benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"),
radii: benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"),
};
let subs = benilla_formats::load_item_sub_classes(&mut chain).expect("ItemSubClass.dbc");
let mut t = TestCtx::new();
let mut objs = no_objects();
let objects = objs.get();
let store = empty_player();
// 1. The wand Shoot (5019, class 2, subclass bit 19): "Requires Wands", red with no wand
// worn; the same row gives the cast-fail line its singular "Wand".
assert_eq!(subs.name(2, 19), Some("Wands"), "the verbose plural");
assert_eq!(subs.display_name(2, 19), Some("Wand"), "the singular");
let v = spell_tooltip_view(
5019,
&spells,
&mut t.ctx_for(&objects, 0, Some(&subs), Some(&store)),
)
.expect("Shoot view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Wands"));
assert!(!v.item_met, "nothing worn satisfies class 2 / bit 19 → red");
// A multi-bit mask is named by `ItemSubClassMask.dbc` (`0x52eef0`, `0x6e2380`): Parry's
// 0x2a5f3 is the eleven melee subclasses, one name.
let parry = spells.catalog.get(3127).expect("Parry 3127");
assert!(parry.equipped_item_subclass_mask.count_ones() > 1);
let v = spell_tooltip_view(
3127,
&spells,
&mut t.ctx_for(&objects, 0, Some(&subs), Some(&store)),
)
.expect("Parry view");
assert_eq!(v.requires_item.as_deref(), Some("Requires Melee Weapon"));
// 2. Attack (6603): `Effect[0] == 78` omits the cast line (`0x52eb15`), though
// `Attributes & 0x40` is clear.
let d = spells.catalog.get(6603).expect("Attack 6603");
assert_eq!(d.effects[0], 78, "SPELL_EFFECT_ATTACK");
assert!(!d.passive, "6603 carries Attributes 0x10, not 0x40");
let v = spell_tooltip_view(6603, &spells, &mut t.ctx(&objects, 0, None)).expect("Attack view");
assert_eq!(v.cast_time, None, "the law's Effect[0] gate");
// The chance line `Effect[0]` selects (`0x52f5b1`): ATTACK skips the passive gate. No
// descriptor, no line; the wire's values are already percents.
assert_eq!(v.chance, None, "no player streamed yet");
let rated = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(22u16, 100u32),
(1109u16, 2.62f32.to_bits()), // PLAYER_CRIT_PERCENTAGE
(1107u16, 5.5f32.to_bits()), // PLAYER_DODGE_PERCENTAGE
]));
let v = spell_tooltip_view(
6603,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&rated)),
)
.expect("Attack view");
assert_eq!(v.chance.as_deref(), Some("2.62% chance to crit"));
// Dodge (81) is passive and reads its own field.
let dodge = spells.catalog.get(81).expect("Dodge 81");
assert_eq!(dodge.effects[0], 20, "SPELL_EFFECT_DODGE");
assert!(dodge.passive, "81 carries Attributes 0x40");
let v = spell_tooltip_view(81, &spells, &mut t.ctx_for(&objects, 0, None, Some(&rated)))
.expect("Dodge view");
assert_eq!(v.chance.as_deref(), Some("5.50% chance to dodge"));
// None of the four effects: no line.
let v = spell_tooltip_view(
133,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&rated)),
)
.expect("Fireball view");
assert_eq!(v.chance, None);
// 3. Slow Fall (130): "Reagents: Light Feather", red while unowned; the name comes from the
// item cache, seeded here as the server would.
let d = spells.catalog.get(130).expect("Slow Fall 130");
assert_eq!(d.reagents[0], (17056, 1), "Light Feather ×1");
let v = spell_tooltip_view(
130,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Slow Fall view");
assert_eq!(
v.reagents, None,
"the template hasn't landed: the line waits rather than printing an id"
);
t.items
.insert_template(17056, Some(crate::items::test_template("Light Feather")));
let v = spell_tooltip_view(
130,
&spells,
&mut t.ctx_for(&objects, 0, None, Some(&store)),
)
.expect("Slow Fall view");
assert_eq!(
v.reagents.as_deref(),
Some("Reagents: |cffff2020Light Feather|r"),
"count 1 prints no (N); unowned wraps in the builder's inline red"
);
}
/// Red only when the resolver finds no opener; a key, a known skill, no requirement and no
/// `Lock.dbc` row all read green.
#[test]
fn the_locked_line_greens_when_the_lock_can_be_opened() {
use crate::target::lock::LockOutcome;
// The Scarlet Key in hand at the Armory Door.
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenByKey(7146))),
TooltipTint::LockOpen,
"holding the key must read green"
);
// The same door without the key.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unmet)),
TooltipTint::Red,
"no key still reads red"
);
// A known skill opener: green, where the reference ramps by margin (`locked_line_tint`).
assert_eq!(
locked_line_tint(Some(LockOutcome::OpenBySpell(2575))),
TooltipTint::LockOpen
);
// A lock row that imposes nothing, and a flag-locked object with no row at all.
assert_eq!(
locked_line_tint(Some(LockOutcome::Unlocked)),
TooltipTint::LockOpen
);
assert_eq!(locked_line_tint(None), TooltipTint::LockOpen);
}
/// A trainer list that lands this frame is hoverable in this frame's tick: the trainer feed derives
/// the subjects the spell feed pushes.
#[test]
fn the_spell_feed_runs_after_the_trainer_feed() {
let mut app = crate::game_plugins::schedule_tests::headless_client();
assert!(crate::test_support::runs_before(
&mut app,
crate::ui_trainer::feed_trainer,
feed_spell_tooltips
));
}