Files
benilla-octo/crates/benilla-app/src/ui_script/tot_frame_tests.rs
T
SamandClaude Fable 5 3d10e7415b the target-of-target frame, and talents can be unlearned again
- the target-of-target frame and its option: 1.12's frame with its five display modes, fed one hop off the selected unit's own target field through the same read `/assist` runs, in four feeds (snapshot and transitions, the debuff buttons, the portrait booth, TargetUnit's click arm); ships off, as 1.12 does, on its own pair of saved globals
- talents can be unlearned again: the trainer's `MSG_TALENT_WIPE_CONFIRM` had no handler since the wipe flow was deferred, so the gossip line closed the menu and did nothing — the question is an NPC session (walking away retracts it), the popup shows the cost on the StaticPopup money variant, Accept answers it behind the client's own purse check
- Sprint's sparkles hang in the air behind you along the trail instead of collapsing into your torso: ride-vs-trail is the emitter's own file flag 0x10, not the host class — the finding wow-re returned eighteen days ago and nobody folded; the particle dump names the two flag bits it had been mis-reporting
- `/sleep` takes both movement masks (the strictest posture), correcting the day's sit gate which let you lie down on a lake bottom while `/sit` was refused; a Lua-driven selection (TargetUnit on an attackable token) starts the swing exactly as a click does, through the one select path

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

488 lines
18 KiB
Rust

//! The shipped target-of-target frame, driven end to end (decision 1576) — `UnitFrames.xml`'s
//! `TargetofTargetFrame` over synthetic `"targettarget"` snapshots and the events the app's feed
//! fires for that token.
//!
//! Two things here are not ordinary unit-frame plumbing and are what most of these test:
//!
//! * **The visibility law is six gates deep** — a switch, five display modes, and the four unit
//! tests the reference wraps them in — and a frame that gets any of them wrong is either always
//! there or never there. The mode leg is also where we knowingly leave the reference: its solo
//! arm calls neither `Show` nor `Hide` when you are in a raid, so the frame keeps whatever state
//! it had; ours answers the question the option asks.
//! * **A token going away is silent.** The feed clears `"targettarget"` without an event, by the
//! same convention `"target"` uses, so the frame's own events cannot take it down — that is what
//! `TargetFrame_OnUpdate`'s one-compare reconcile is for, and it has a test.
use benilla_ui::script::{
AuraState, PartyMemberInfo, PartyState, QuadContent, RaidMemberInfo, ScriptValue, UiScript,
UnitState,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error (the unit-frame tests').
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
);
}
/// A unit snapshot with a guid — the identity `UnitIsUnit` compares, which the "your target is
/// you" gate reads.
fn unit(name: &str, guid: u64, health: u32) -> UnitState {
UnitState {
exists: true,
is_connected: true,
name: Some(name.into()),
guid,
health,
max_health: 100,
level: 60,
power_type: 0,
power: 50,
max_power: 100,
..UnitState::default()
}
}
/// The frame's production load prefix, plus a player, a target and a target's target — the state
/// in which everything but the switch itself already allows the frame.
///
/// The tail three are the target-aura tests' set and are here for the same reasons: `BuffFrame`
/// defines `DebuffTypeColor`, which the debuff row's dispel tint indexes, and it will not TICK
/// without `ActionBar`'s `TOOLTIP_UPDATE_TIME` — which this file's reconcile test needs, since it
/// is the only one here that runs the clock.
fn load_tot() -> 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, "Cooldown.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "BuffFrame.xml");
s.set_unit("player", Some(unit("Tri", 0x100, 100)));
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s
}
/// Turn the frame on the way the option row does — the write plus its `applyFunc`.
fn switch_on(s: &mut UiScript) {
s.run(r#"SHOW_TARGET_OF_TARGET = "1" TargetofTarget_Update()"#)
.unwrap();
}
fn shown(s: &mut UiScript) -> bool {
s.eval::<bool>("return TargetofTargetFrame:IsShown() and true or false")
.unwrap()
}
/// Every texture path the UI actually draws this frame (the pet-frame tests' render probe).
fn drawn(s: &mut UiScript) -> Vec<String> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture { path: Some(p), .. } => Some(p),
_ => None,
})
.collect()
}
fn debuff(spell_id: u32, name: &str, debuff_type: Option<&str>) -> AuraState {
AuraState {
spell_id,
name: Some(name.into()),
icon: Some(format!("Interface\\Icons\\Spell_{spell_id}")),
count: 1,
debuff_type: debuff_type.map(str::to_string),
// 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,
until_cancelled: false,
channeled: false,
}
}
/// A party of `n` others; `GetNumPartyMembers` is that list's length.
fn party(n: usize) -> PartyState {
PartyState {
members: (0..n)
.map(|i| PartyMemberInfo {
name: format!("Mate{i}"),
guid: 0x300 + i as u64,
})
.collect(),
..PartyState::default()
}
}
/// A raid of `n` (the roster INCLUDES the player, per `GetRaidRosterInfo`'s array).
fn raid(n: usize) -> PartyState {
PartyState {
raid: (0..n)
.map(|i| RaidMemberInfo {
name: format!("Raider{i}"),
guid: 0x400 + i as u64,
..RaidMemberInfo::default()
})
.collect(),
..PartyState::default()
}
}
/// The switch is the outer gate, and it ships OFF — 1.12's own default (`SHOW_TARGET_OF_TARGET`
/// = `"0"`, ref UIOptionsFrame.lua l.116). Everything else about the state already allows the
/// frame, so this is the switch alone.
#[test]
fn the_frame_ships_off_and_the_switch_is_what_shows_it() {
let mut s = load_tot();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return SHOW_TARGET_OF_TARGET").unwrap(),
"0",
"the shipped default is the reference's: off"
);
assert!(
!shown(&mut s),
"off means hidden with everything else ready"
);
switch_on(&mut s);
assert!(shown(&mut s), "the switch alone brings it up");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four unit gates the reference wraps the switch in (ref TargetofTarget_Update l.504): a
/// target, a target OF it, a target that is not you, and a target that is alive.
#[test]
fn the_four_unit_gates_each_take_the_frame_down() {
let mut s = load_tot();
switch_on(&mut s);
assert!(shown(&mut s));
// No target of target.
s.set_unit("targettarget", None);
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "nothing to show");
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
// No target at all.
s.set_unit("target", None);
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "no target, no target's target");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
// The target is YOU: its target is your own target, and the frame would restate the frame it
// hangs off.
s.set_unit("target", Some(unit("Tri", 0x100, 100)));
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "self-target: nothing to add");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
// A dead target.
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 0)));
s.run("TargetofTarget_Update()").unwrap();
assert!(!shown(&mut s), "a corpse is fighting nobody");
s.set_unit("target", Some(unit("Kobold Miner", 0x200, 80)));
s.run("TargetofTarget_Update()").unwrap();
assert!(shown(&mut s), "and back");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The five display modes, each against solo / party / raid. Mode 3 in a raid is the one that
/// diverges from the reference on purpose: its ladder calls neither Show nor Hide there and the
/// frame keeps its last state (ref l.513-520), which is a leak rather than a rule.
#[test]
fn the_five_modes_answer_solo_party_and_raid() {
let mut s = load_tot();
switch_on(&mut s);
let states = [
("solo", PartyState::default()),
("party", party(2)),
("raid", raid(10)),
];
// mode -> (solo, party, raid)
let expected = [
("1", [false, false, true]), // raid only
("2", [false, true, false]), // party, and not while raiding
("3", [true, false, false]), // solo only
("4", [false, true, true]), // grouped at all
("5", [true, true, true]), // always
];
for (mode, want) in expected {
for (i, (label, state)) in states.iter().enumerate() {
s.set_party(state.clone());
s.run(&format!(
r#"SHOW_TARGET_OF_TARGET_STATE = "{mode}" TargetofTarget_Update()"#
))
.unwrap();
assert_eq!(
shown(&mut s),
want[i],
"mode {mode} while {label}: expected shown={}",
want[i]
);
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Mode 3 in a raid, specifically as a STATE question rather than a fresh evaluation: the frame
/// must come down when the raid forms, not keep whatever it had. This is the reference's leaked
/// branch, and it can only be seen by showing the frame first.
#[test]
fn the_solo_mode_comes_down_when_a_raid_forms() {
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"SHOW_TARGET_OF_TARGET_STATE = "3" TargetofTarget_Update()"#)
.unwrap();
assert!(shown(&mut s), "solo, in solo mode");
s.set_party(raid(10));
s.fire_event("RAID_ROSTER_UPDATE", vec![]);
assert!(!shown(&mut s), "the raid forms and the frame goes away");
}
/// The paint: name, health, and the powerless unit's hidden mana bar (this file's convention on
/// every frame — the reference leaves an empty 0/0 rail drawn).
#[test]
fn the_frame_paints_its_unit() {
let mut s = load_tot();
switch_on(&mut s);
assert_eq!(
s.eval::<String>("return TargetofTargetName:GetText()")
.unwrap(),
"Tri"
);
let (value, max) = s
.eval::<(f64, f64)>(
"local _, m = TargetofTargetHealthBar:GetMinMaxValues() \
return TargetofTargetHealthBar:GetValue(), m",
)
.unwrap();
assert_eq!((value, max), (100.0, 100.0));
assert!(
s.eval::<bool>("return TargetofTargetManaBar:IsShown() and true or false")
.unwrap(),
"a unit with mana shows its rail"
);
let mut powerless = unit("Skeleton", 0x100, 100);
powerless.max_power = 0;
s.set_unit("targettarget", Some(powerless));
s.fire_event(
"UNIT_MAXPOWER",
vec![ScriptValue::Str("targettarget".into())],
);
assert!(
!s.eval::<bool>("return TargetofTargetManaBar:IsShown() and true or false")
.unwrap(),
"no power, no rail"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// DEAD over a dimmed trough — and the reference's connected test, which is what tells a corpse
/// from a linkdead player (both read zero health).
#[test]
fn the_dead_word_needs_a_connected_corpse() {
let mut s = load_tot();
switch_on(&mut s);
assert!(
!s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
"alive: no word"
);
let mut corpse = unit("Tri", 0x100, 0);
corpse.dead = true;
s.set_unit("targettarget", Some(corpse.clone()));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
assert!(
s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
"dead: the word"
);
let mut linkdead = corpse;
linkdead.is_connected = false;
s.set_unit("targettarget", Some(linkdead));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
assert!(
!s.eval::<bool>("return TargetofTargetDeadText:IsShown() and true or false")
.unwrap(),
"disconnected, not dead"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The portrait tint (ref TargetofTargetHealthCheck), and its PLAYERS-ONLY gate: a creature's
/// portrait is never tinted, which is why the check has to run off the bar's own value rather
/// than off the snapshot.
#[test]
fn the_portrait_tints_with_a_players_state_and_never_a_creatures() {
let mut s = load_tot();
switch_on(&mut s);
let mut hurt = unit("Tri", 0x100, 15);
hurt.is_player = true;
s.set_unit("targettarget", Some(hurt));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 0.0, 0.0), "a player under a fifth: red");
let mut healthy = unit("Tri", 0x100, 90);
healthy.is_player = true;
s.set_unit("targettarget", Some(healthy));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 1.0, 1.0), "and white again");
// A creature at the same 15%: the tint must not move (the check returns before touching it).
s.set_unit("targettarget", Some(unit("Kobold", 0x500, 15)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("targettarget".into())]);
let (r, g, b) = s
.eval::<(f64, f64, f64)>("return TargetofTargetPortrait:GetVertexColor()")
.unwrap();
assert_eq!((r, g, b), (1.0, 1.0, 1.0), "creatures carry no reading");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The left click selects — the one leg the target frame's own click does not have, and the whole
/// reason the frame is clickable.
#[test]
fn the_left_click_targets_the_unit() {
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"TargetofTarget_OnClick("LeftButton")"#).unwrap();
assert_eq!(s.take_target_requests(), vec!["targettarget".to_string()]);
// A right click is not a menu here: the reference gives this frame none.
s.run(r#"TargetofTarget_OnClick("RightButton")"#).unwrap();
assert!(s.take_target_requests().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The four debuff buttons, dispel-tinted (ref RefreshBuffs over MAX_PARTY_DEBUFFS).
#[test]
fn the_debuff_row_draws_what_the_unit_carries() {
let mut s = load_tot();
switch_on(&mut s);
s.set_auras(
"targettarget",
Some(vec![
debuff(1000, "Rend", None),
debuff(1001, "Curse of Agony", Some("Curse")),
]),
);
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("targettarget".into())]);
let paths = drawn(&mut s);
assert!(
paths.iter().any(|p| p == "Interface\\Icons\\Spell_1000"),
"the first debuff's icon draws: {paths:?}"
);
assert!(
paths.iter().any(|p| p == "Interface\\Icons\\Spell_1001"),
"and the second"
);
assert!(
!s.eval::<bool>("return TargetofTargetFrameDebuff3:IsShown() and true or false")
.unwrap(),
"the empty slots stay down"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The target's OWN aura rows re-wrap around this frame: 5 to a row instead of 6, and the buffs
/// open under the second debuff row instead of the seventh icon (ref TargetDebuffButton_Update
/// l.310-334). Both directions — the reference only re-runs the rows on the way in.
#[test]
fn the_target_rows_wrap_short_while_the_frame_stands_beside_them() {
let mut s = load_tot();
// A hostile target, so the debuffs lead and the buffs hang off them.
s.set_auras("target", Some(vec![debuff(2000, "Sunder", None)]));
s.fire_event("UNIT_AURA", vec![ScriptValue::Str("target".into())]);
let anchor = |s: &mut UiScript, frame: &str| -> String {
s.eval::<String>(&format!(
"local _, rel = {frame}:GetPoint(1) return rel:GetName()"
))
.unwrap()
};
assert_eq!(
anchor(&mut s, "TargetFrameBuff1"),
"TargetFrameDebuff7",
"hidden: the sixth icon closes the first row"
);
switch_on(&mut s);
assert!(shown(&mut s));
assert_eq!(
anchor(&mut s, "TargetFrameBuff1"),
"TargetFrameDebuff6",
"shown: the row wraps a slot early"
);
assert_eq!(
anchor(&mut s, "TargetFrameDebuff6"),
"TargetFrameDebuff1",
"and the sixth icon starts the second row"
);
// Back off — the reference never re-lays the rows here, so they would stay wrapped short.
s.run(r#"SHOW_TARGET_OF_TARGET = "0" TargetofTarget_Update()"#)
.unwrap();
assert_eq!(
anchor(&mut s, "TargetFrameBuff1"),
"TargetFrameDebuff7",
"and back to six"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reconcile. A token going away is CLEARED, not announced — no UNIT_* event carries it — so
/// the frame's own registrations cannot take it down. `TargetFrame_OnUpdate` is what does, and it
/// runs only while you have a target.
#[test]
fn the_reconcile_takes_the_frame_down_when_the_token_goes_silent() {
let mut s = load_tot();
switch_on(&mut s);
assert!(shown(&mut s));
// Exactly what the feed does: clear the token, fire nothing.
s.set_unit("targettarget", None);
assert!(shown(&mut s), "no event, so nothing has told the frame yet");
s.tick(0.016);
assert!(!shown(&mut s), "the reconcile notices within a frame");
// And the other direction: a token appearing DOES fire, but the reconcile covers it anyway.
s.set_unit("targettarget", Some(unit("Tri", 0x100, 100)));
s.tick(0.016);
assert!(shown(&mut s), "and brings it back");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}