Files
benilla-octo/crates/benilla-app/src/ui_script/pet_frame_tests.rs
T
SamandClaude Fable 5 a401e5c43d the dressing room opens, and the pet gets its paper doll
- ctrl-click an item to try it on: the dressing room dresses your double, and shift-click posts the item link to chat
- the pet tab lands in the character window: paper doll stats, family and diet, the right-click menu, and dropping food on the pet feeds it
- water-only spells refuse on dry land: aquatic form gates to swimming, client-side like the real one
- the macro icon picker's white squares get their art back, and option rows explain themselves on hover

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

532 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 shipped pet frame, driven end to end (decision 0990) — `UnitFrames.xml`'s
//! `BenillaPetFrame` over synthetic `"pet"` snapshots and the events the app's feed fires.
//!
//! The frame's whole job is to be right about **which unit an event names**, and that is what most
//! of these test: `UNIT_PET` names the OWNER (`arg1 == "player"`, wow-re §9), every other `UNIT_*`
//! names the pet itself, and a frame that mixes the two repaints off the player's health.
use benilla_ui::script::{AuraState, QuadContent, ScriptValue, UiScript, UnitState};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error (the unit-frame tests'
/// loader).
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
);
}
/// The pet frame's production load prefix — it is parented to `BenillaPlayerFrame`, so the whole
/// unit-frame file comes with it, and the tooltip/dropdown kit its hover and its neighbours need.
fn load_pet_frame() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "UnitFrames.xml");
s
}
/// A pet snapshot. `max_power == 0` is the powerless pet (a skeleton) — the leg that swaps the art.
fn pet(name: &str, health: u32, power: u32, max_power: u32, power_type: u8) -> UnitState {
UnitState {
exists: true,
name: Some(name.into()),
health,
max_health: 100,
level: 60,
power_type,
power,
max_power,
..UnitState::default()
}
}
/// Every texture path the UI actually **draws** this frame, and its vertex tint.
///
/// The engine ships `SetTexture`/`SetVertexColor` and no getters (the reference's `GetTexture`
/// exists but nothing in the shipped XML reads it), so these assertions go through the render path
/// the unit-frame tests already use — which is the honest question anyway: not what the region was
/// told, but what comes out the other end.
fn drawn(s: &mut UiScript) -> Vec<(String, Option<[f32; 4]>)> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} => Some((p, color)),
_ => None,
})
.collect()
}
/// Is exactly this texture drawn? Exact, not `contains` — `UI-SmallTargetingFrame` is a prefix of
/// `UI-SmallTargetingFrame-NoMana`, and the whole point of the art-swap test is telling them apart.
fn draws(s: &mut UiScript, path: &str) -> bool {
drawn(s).iter().any(|(p, _)| p == path)
}
fn debuff(spell_id: u32, name: &str, count: u8) -> AuraState {
AuraState {
spell_id,
name: Some(name.into()),
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
count,
debuff_type: None,
// No unit but yourself carries a duration on the 1.12 wire (decision 0257 B6).
duration: 0.0,
expiration_time: 0.0,
helpful: false,
cancelable: false,
}
}
/// Summon → the frame appears with the pet's name, health and power; dismiss → it hides again.
/// `UNIT_PET` is the only wire either edge has, which is why it carries them both.
#[test]
fn the_pet_frame_appears_on_a_summon_and_leaves_on_a_dismiss() {
let mut s = load_pet_frame();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap(),
"no pet, no frame"
);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
let ok: bool = s
.eval(
r#"
local hb, mb = BenillaPetFrameHealthBar, BenillaPetFrameManaBar
local _, hmax = hb:GetMinMaxValues()
local _, mmax = mb:GetMinMaxValues()
return BenillaPetFrame:IsVisible()
and BenillaPetFrameTextureFrameName:GetText() == "Grimjaw"
and hb:GetValue() == 72 and hmax == 100
and mb:GetValue() == 45 and mmax == 80 and mb:IsVisible()
"#,
)
.unwrap();
assert!(ok, "the summoned pet's name, health and power all draw");
// The dismiss: the token clears and UNIT_PET fires, exactly as `feed_pet_unit` does it.
s.set_unit("pet", None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap());
}
/// **The two argument gates.** `UNIT_PET` names the OWNER, so `arg1 == "pet"` on it is not our
/// event; every other `UNIT_*` names the pet, so `arg1 == "player"` on those is not ours either.
/// A frame that has these backwards paints the player's health onto the pet's bar — which is why
/// this drives each event with the WRONG token and asserts nothing moved.
#[test]
fn the_frame_answers_only_the_events_that_name_its_own_unit() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(s
.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap());
// A health change on the PLAYER must not touch the pet's bar.
s.set_unit("pet", Some(pet("Grimjaw", 5, 45, 80, 0)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<f64>("return BenillaPetFrameHealthBar:GetValue()")
.unwrap(),
72.0,
"a UNIT_HEALTH for \"player\" is not the pet's event"
);
// …and the same event naming the pet does.
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("pet".into())]);
assert_eq!(
s.eval::<f64>("return BenillaPetFrameHealthBar:GetValue()")
.unwrap(),
5.0
);
// UNIT_PET is the mirror image: it names the owner, so a "pet" arg1 is not ours.
s.set_unit("pet", None);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("pet".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap(),
"UNIT_PET names the OWNER — a \"pet\" arg1 is somebody else's event"
);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrame:IsVisible()")
.unwrap());
}
/// The reference's art swap (`PetFrame.lua`'s Update): a pet with no power wears the `-NoMana`
/// plate, which has no mana rail to leave empty — and the bar hides with it. A hunter's FOCUS pet
/// takes the plain plate and the focus colour, which is the case the mana-coloured default would
/// get wrong.
#[test]
fn a_powerless_pet_wears_the_no_mana_plate() {
let mut s = load_pet_frame();
const PLAIN: &str = "Interface\\TargetingFrame\\UI-SmallTargetingFrame";
const NO_MANA: &str = "Interface\\TargetingFrame\\UI-SmallTargetingFrame-NoMana";
// Focus (power type 2) — a hunter's pet: plain art, orange bar.
s.set_unit("pet", Some(pet("Boar", 100, 60, 100, 2)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
let (visible, r, g, b) = s
.eval::<(bool, f64, f64, f64)>(
"local r, g, b = BenillaPetFrameManaBar:GetStatusBarColor() \
return BenillaPetFrameManaBar:IsVisible(), r, g, b",
)
.unwrap();
assert!(visible);
assert_eq!((r, g, b), (1.0, 0.5, 0.25), "FOCUS, not the mana default");
assert!(draws(&mut s, PLAIN), "the plate with a mana rail");
assert!(!draws(&mut s, NO_MANA));
// A skeleton: no power at all.
s.set_unit("pet", Some(pet("Skeleton", 100, 0, 0, 0)));
s.fire_event("UNIT_MAXPOWER", vec![ScriptValue::Str("pet".into())]);
assert!(
!s.eval::<bool>("return BenillaPetFrameManaBar:IsVisible()")
.unwrap(),
"no power bar on the plate that has no rail for it"
);
assert!(draws(&mut s, NO_MANA), "…and the plate swaps with it");
assert!(!draws(&mut s, PLAIN));
}
/// `PET_ATTACK_START`/`PET_ATTACK_STOP` show and hide the flashing attack overlay, and neither
/// repaints the rest of the frame (they are the ref's own early returns).
#[test]
fn the_attack_overlay_follows_its_own_two_events() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrameAttackMode:IsVisible()")
.unwrap());
s.fire_event("PET_ATTACK_START", vec![]);
assert!(s
.eval::<bool>("return BenillaPetFrameAttackMode:IsVisible()")
.unwrap());
// The ref's OnUpdate ramps the overlay's tint alpha down from 1 on the opening (sign −1) leg.
// Two ticks so the ramp has visibly moved, and the range guard catches the classic transcription
// slip: the ref's constants are 0..255 and the API takes 0..1.
s.tick(0.1);
s.tick(0.1);
let tint = drawn(&mut s)
.into_iter()
.find(|(p, _)| p.contains("UI-Player-AttackStatus"))
.and_then(|(_, c)| c)
.expect("the attack overlay draws while shown");
assert!(
(0.0..=1.0).contains(&tint[3]),
"the pulse stays inside the alpha range (got {})",
tint[3]
);
assert!(tint[3] < 1.0, "…and it has actually ramped off full");
s.fire_event("PET_ATTACK_STOP", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameAttackMode:IsVisible()")
.unwrap());
}
/// The four debuff buttons fill from `UnitDebuff("pet", i)` in order, show a stack count only above
/// 1, and the unused ones hide. `UNIT_AURA("pet")` is the wire.
#[test]
fn the_debuff_row_fills_from_the_pets_own_auras() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
s.set_auras(
"pet",
Some(vec![debuff(1000, "Rend", 1), debuff(1001, "Sunder", 3)]),
);
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]);
let ok: bool = s
.eval(
r#"
return BenillaPetFrameDebuff1:IsVisible()
and BenillaPetFrameDebuff2:IsVisible()
and not BenillaPetFrameDebuff3:IsVisible()
and not BenillaPetFrameDebuff4:IsVisible()
and not BenillaPetFrameDebuff1Count:IsVisible()
and BenillaPetFrameDebuff2Count:IsVisible()
and BenillaPetFrameDebuff2Count:GetText() == "3"
"#,
)
.unwrap();
assert!(ok, "two debuffs draw, a stack count shows only above 1");
// …and the icons are the pet's own auras, in the pet's own order.
assert!(draws(&mut s, "Interface\\Icons\\Spell_1000"));
assert!(draws(&mut s, "Interface\\Icons\\Spell_1001"));
// They clear when the auras do.
s.set_auras("pet", Some(vec![]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("pet".into())]);
assert!(!s
.eval::<bool>("return BenillaPetFrameDebuff1:IsVisible()")
.unwrap());
}
/// Left-clicking the frame targets the pet — the ref's `PetFrame_OnClick` bottom leg, and the one
/// the app answers in `target::click::target_unit_requests`' `"pet"` arm.
#[test]
fn left_clicking_the_pet_frame_targets_it() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
s.run("BenillaPetFrame_OnClick(\"LeftButton\")").unwrap();
assert_eq!(s.take_target_requests(), vec!["pet".to_string()]);
// The right button is the deferred PET menu — it must do nothing at all, not target.
s.run("BenillaPetFrame_OnClick(\"RightButton\")").unwrap();
assert!(s.take_target_requests().is_empty());
}
/// **All three legs of `PetFrame_OnClick` survive the click** — B208's "dropping food from the bag
/// onto the pet doesn't feed" (decision 1055).
///
/// The handler transcribed the reference's three legs correctly from the day it shipped, but two of
/// the globals it calls — `DropItemOnUnit` and `SpellTargetUnit` — were never registered, so the
/// middle leg called a nil value and the whole handler errored out. Nothing fed, and nothing said
/// why. The bottom-leg test above could not catch it: it only ever exercises the `else`.
///
/// So this walks the legs the reference's own order picks between them (`SpellIsTargeting()` first,
/// then `CursorHasItem()`, then plain target — `PetFrame.lua:114-129`), and asserts each *runs*.
/// The feed itself is the app's half, gated and sent in `ui_action::drop_item`; what the VM owes is
/// a queued token and no error.
#[test]
fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
let mut s = load_pet_frame();
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 0)));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
// The three globals the handler reaches for must all exist. Named individually so a failure
// says which one went missing rather than just "attempt to call a nil value".
for global in [
"SpellIsTargeting",
"CursorHasItem",
"DropItemOnUnit",
"SpellTargetUnit",
] {
assert!(
s.eval::<bool>(&format!("return _G[\"{global}\"] ~= nil"))
.unwrap_or(false),
"{global} is not registered — the pet frame's click calls it"
);
}
// The middle leg, for real: put food in the backpack, pick it up the way a player does, then
// click the pet. Before the fix this click errored instead of queueing anything.
s.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::from([(
1,
benilla_ui::script::ContainerSlot {
item_id: 2287, // Haunch of Meat — pet food
count: 1,
..Default::default()
},
)]),
}),
);
s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]);
s.run("C_Container.PickupContainerItem(0, 1)").unwrap();
assert!(
s.eval::<bool>("return CursorHasItem()").unwrap(),
"the food is on the cursor"
);
s.run("BenillaPetFrame_OnClick(\"LeftButton\")")
.expect("the cursor-holds-an-item leg must not error");
assert_eq!(
s.take_drop_item_on_unit(),
vec!["pet".to_string()],
"a held item + a pet click queues the drop"
);
// …and it is the DROP leg, not the target leg — the reference's if/elseif is exclusive.
assert!(s.take_target_requests().is_empty());
}
/// The GlobalStrings the happiness tooltip resolves by key. Loaded from the MPQ in production; the
/// test declares the ones it reads, the pet-bar tests' convention. (That the real keys exist in the
/// shipped file is asserted separately, by name, in `ui_pet_stats`.)
fn declare_happiness_strings(s: &UiScript) {
s.run(
"PET_HAPPINESS1 = 'Unhappy' PET_HAPPINESS2 = 'Content' PET_HAPPINESS3 = 'Happy' \
PET_DAMAGE_PERCENTAGE = 'Damage: %d%%' \
LOSING_LOYALTY = 'Losing Loyalty' GAINING_LOYALTY = 'Gaining Loyalty'",
)
.unwrap();
}
fn stats(
hunter: bool,
happiness: Option<u32>,
damage: f32,
rate: f32,
) -> benilla_ui::script::PetStats {
benilla_ui::script::PetStats {
hunter_pet: hunter,
happiness,
damage_percentage: damage,
loyalty_rate: rate,
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
training_points: (170, 130),
experience: (4200, 8000),
// The unit frame draws neither the family word (the paper doll's level line) nor the diet
// (its tooltip) — decision 1062. Left at their defaults on purpose: the happiness icon
// these tests drive must not start depending on them.
..benilla_ui::script::PetStats::default()
}
}
/// The happiness icon end to end through the real XML (decision 1005): shown for a hunter pet,
/// re-cut per bucket, hidden for anything that is not one.
///
/// The tooltip key is the assertion rather than the texcoords because it is what proves **which
/// branch ran** — `PET_HAPPINESS2` can only come from the `happiness == 2` arm.
#[test]
fn the_happiness_icon_shows_per_bucket_and_hides_for_a_non_hunter_pet() {
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
for (bucket, tip, rate, loyalty_line) in [
(3u32, "Happy", 20.0f32, Some("Gaining Loyalty")),
(2, "Content", 0.0, None),
(1, "Unhappy", -10.0, Some("Losing Loyalty")),
] {
s.set_pet_stats(true, stats(true, Some(bucket), 125.0, rate));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap(),
"bucket {bucket} must show the icon"
);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
.unwrap(),
tip,
"bucket {bucket} took the wrong texcoord branch"
);
// The loyalty line is chosen by the SIGN of the rate — and is absent at exactly zero.
let line = s
.eval::<Option<String>>("return BenillaPetFrameHappiness.tooltipLoyalty")
.unwrap();
assert_eq!(
line.as_deref(),
loyalty_line,
"bucket {bucket} loyalty line"
);
}
// A warlock's imp: `HasPetUI`'s second return is nil, so the icon hides however happy the
// first return looks.
s.set_pet_stats(true, stats(false, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap());
}
/// **Bucket 0 keeps the icon up.** The reference hides on `not happiness`, and `0` is truthy in
/// Lua — so a client that folded bucket 0 into nil would hide a frame the reference shows. This is
/// the trap wow-re calls out by name, tested where it would actually bite: in the frame.
#[test]
fn happiness_bucket_zero_keeps_the_icon_showing() {
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
s.set_pet_stats(true, stats(true, Some(0), 100.0, 0.0));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert!(
s.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap(),
"bucket 0 is a number, not the hide case"
);
// …whereas a genuine nil — the gate failure — does hide it.
s.set_pet_stats(true, stats(true, None, 100.0, 0.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert!(!s
.eval::<bool>("return BenillaPetFrameHappiness:IsVisible()")
.unwrap());
}
/// `UNIT_HAPPINESS` repaints the icon **alone** — the reference's own arm returns before the
/// frame-wide update, so a happiness tick must not be a full repaint.
#[test]
fn unit_happiness_repaints_only_the_icon() {
let mut s = load_pet_frame();
declare_happiness_strings(&s);
s.set_unit("pet", Some(pet("Grimjaw", 72, 45, 80, 2)));
s.set_pet_stats(true, stats(true, Some(1), 75.0, -10.0));
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
.unwrap(),
"Unhappy"
);
// Feed it up a bucket and fire ONLY the happiness event.
s.set_pet_stats(true, stats(true, Some(3), 125.0, 20.0));
s.fire_event("UNIT_HAPPINESS", vec![]);
assert_eq!(
s.eval::<String>("return BenillaPetFrameHappiness.tooltip")
.unwrap(),
"Happy",
"UNIT_HAPPINESS must re-cut the icon on its own"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The layering law (decision 0884), the pet frame's copy of the player/target/party tests: the
/// frame art must draw OVER the bar fills, which only the frame LEVEL can hold — the draw layer is
/// bucket-wide. A "simplification" back to declaration order puts the bars on top as pasted slabs.
#[test]
fn the_pet_art_paints_over_the_bars() {
let s = load_pet_frame();
let level: (i64, i64, i64) = s
.eval(
"return BenillaPetFrameTextureFrame:GetFrameLevel(), \
BenillaPetFrameHealthBar:GetFrameLevel(), \
BenillaPetFrameManaBar:GetFrameLevel()",
)
.unwrap();
let (art, health, mana) = level;
assert_eq!(health, art - 1, "the health bar sits under the art");
assert_eq!(mana, art - 1, "and so does the mana bar");
}