Files
benilla-octo/crates/benilla-app/src/ui_script/unit_frame_tests.rs
T
SamandClaude Fable 5 5de1f3e7b8 feign death plays dead everywhere, and the attack keys go exclusive
- feign death plays dead for real: the body collapses and stays down, both bars read 0 and DEAD, the nameplate drops, the combat hum goes quiet
- melee and Auto Shot are exclusive now: starting one stops the other, even mid-swing
- another client's addon chatter no longer prints as party chat
- right-click presses look pressed, rage and happiness read on their real scale, and the login boxes blink their caret

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

1287 lines
47 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.
use benilla_ui::script::{QuadContent, ScriptValue, SoundRequest, UiScript, UnitState};
/// Load one shipped `assets/ui/<file>` (the panel tests' loader, duplicated to stay
/// self-contained), panicking on any loader error.
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 unit frames' production load prefix (ui_script/mod.rs order): fonts + UIParent +
/// tooltip, then the dropdown kit + unit popups the frames' DropDown children initialize into.
fn load_unit_frames(s: &UiScript) {
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");
}
/// Load the real `assets/ui/UnitFrames.xml` (the shipped default UI) into a bare engine and
/// drive it with synthetic snapshots — the whole slice-1 chain minus Bevy: template expansion,
/// StatusBar fill, the Era event set, the target frame's hide/show lifecycle, and the async
/// name arriving via UNIT_NAME_UPDATE.
#[test]
fn shipped_unit_frames_drive_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// No units yet: both frames hid themselves on their OnLoad's first update.
let hidden: bool = s
.eval("return not BenillaPlayerFrame:IsVisible() and not BenillaTargetFrame:IsVisible()")
.unwrap();
assert!(hidden, "frames hide while their units don't exist");
// The player appears (name still unresolved), at 72/100 health, 45/80 mana.
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: None,
health: 72,
max_health: 100,
level: 12,
power_type: 0,
power: 45,
max_power: 80,
dead: false,
reaction: 0,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert!(s
.eval::<bool>("return BenillaPlayerFrame:IsVisible()")
.unwrap());
let ok: bool = s
.eval(
r#"
local hb, pb = BenillaPlayerFrameHealthBar, BenillaPlayerFramePowerBar
local _, hmax = hb:GetMinMaxValues()
local _, pmax = pb:GetMinMaxValues()
local r, g, b = pb:GetStatusBarColor()
return hb:GetValue() == 72 and hmax == 100
and pb:GetValue() == 45 and pmax == 80 and pb:IsVisible()
and b == 1 and r == 0 -- mana blue
and BenillaPlayerFrameTextureFrameNameText:GetText() == "Unknown"
"#,
)
.unwrap();
assert!(ok, "player frame painted from the snapshot");
// The name-query answer lands: UNIT_NAME_UPDATE repaints the name.
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Benilla".into()),
health: 72,
max_health: 100,
level: 12,
power_type: 0,
power: 45,
max_power: 80,
dead: false,
reaction: 0,
..UnitState::default()
}),
);
s.fire_event("UNIT_NAME_UPDATE", vec![ScriptValue::Str("player".into())]);
assert_eq!(
s.eval::<String>("return BenillaPlayerFrameTextureFrameNameText:GetText()")
.unwrap(),
"Benilla"
);
// A powerless wolf gets targeted: frame shows, power bar hides, health fills 30/50.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Young Wolf".into()),
health: 30,
max_health: 50,
level: 3,
power_type: 0,
power: 0,
max_power: 0,
dead: false,
reaction: 4, // neutral
..UnitState::default()
}),
);
s.take_sounds(); // drain anything earlier; the target select/deselect pair is under test below
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let ok: bool = s
.eval(
r#"
return BenillaTargetFrame:IsVisible()
and not BenillaTargetFramePowerBar:IsVisible()
and BenillaTargetFrameTextureFrameNameText:GetText() == "Young Wolf"
and BenillaTargetFrameTextureFrameLevelText:GetText() == "3"
and BenillaTargetFrameTextureFrameDeadText:GetText() == "" -- living target: no DEAD text
"#,
)
.unwrap();
assert!(
ok,
"target frame painted; powerless unit hides its power bar"
);
// The select sound rides the frame's OnShow (ref TargetFrame_OnShow): a neutral (4) wolf is
// neither UnitIsEnemy (≤2) nor UnitIsFriend (≥5) → the neutral kit.
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCreatureNeutralSelect".into())],
"neutral target select kit"
);
// Neutral reaction (4) tints the name plate yellow — UnitReactionColor[4] = (1,1,0), the
// faithful TargetFrame_CheckFaction path (the plate was untinted before). Assert on the extracted
// quad's vertex color (same style as the bar-fill check below).
s.resolve();
let plate = s
.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Texture {
path: Some(p),
color: Some(c),
..
} if p.contains("LevelBackground") => Some(c),
_ => None,
})
.expect("target name-plate quad present");
assert!(
(plate[0] - 1.0).abs() < 1e-6 && (plate[1] - 1.0).abs() < 1e-6 && plate[2].abs() < 1e-6,
"neutral name plate is yellow, got {plate:?}"
);
// The target's health-bar fill quad is 60% of the bar width (30/50 of the real 119px bar,
// ref-TargetFrame.xml l.253-255).
s.resolve();
let quads = s.extract();
let bar_rect = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-StatusBar"))
})
.filter_map(|q| q.rect)
.find(|r| (r.width() - 119.0 * 0.6).abs() < 0.01)
.expect("target health fill at 60% of 119px");
assert!((bar_rect.width() - 71.4).abs() < 0.01);
// Deselect: the target frame hides again, playing the lost-target kit (ref TargetFrame_OnHide).
s.set_unit("target", None);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(!s
.eval::<bool>("return BenillaTargetFrame:IsVisible()")
.unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName(
"INTERFACESOUND_LOSTTARGETUNIT".into()
)],
"deselect plays the lost-target kit"
);
// A hostile (reaction 2) target takes the UnitIsEnemy branch — the aggro kit.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Kobold Vermin".into()),
health: 40,
max_health: 40,
level: 1,
reaction: 2,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(s
.eval::<bool>("return UnitIsEnemy(\"target\", \"player\") == 1")
.unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCreatureAggroSelect".into())],
"hostile target select kit"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The hit indicator's drawn height via the engine extract: the `SetTextHeight` override on the
/// Text quad whose text matches (the two-regime split, decision 0582 — GetFont keeps reporting
/// the font object's own 30, like the real API).
fn extracted_text_height(s: &mut UiScript, text: &str) -> Option<f32> {
s.resolve();
s.extract().into_iter().find_map(|q| match q.content {
QuadContent::Text {
text: Some(t),
text_height,
..
} if t == text => Some(text_height),
_ => None,
})?
}
/// The portrait hit indicator (decision 0576): `UNIT_COMBAT` over `"player"` drives the
/// transcribed CombatFeedback — a physical wound paints the amount white at the base height 30,
/// a spell crit paints yellow at ×1.5, a full absorb paints the word at ×0.75 — and the fade
/// envelope (0.2 s in, 0.7 s hold, 0.3 s out) ends in a Hide. A `"target"`-token event never
/// touches it (only the player frame registers UNIT_COMBAT in 1.12).
#[test]
fn unit_combat_drives_the_player_hit_indicator() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
health: 72,
max_health: 100,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
let ev = |unit: &str, action: &str, flags: &str, amount: i64, school: i64| {
vec![
ScriptValue::Str(unit.into()),
ScriptValue::Str(action.into()),
ScriptValue::Str(flags.into()),
ScriptValue::Int(amount),
ScriptValue::Int(school),
]
};
// A physical wound: the amount, white, base height.
s.fire_event("UNIT_COMBAT", ev("player", "WOUND", "", 17, 0));
let ok: bool = s
.eval(
r#"
local ind = BenillaPlayerFrameTextureFrameHitIndicator
return ind:IsShown() ~= nil and tostring(ind:GetText()) == "17"
"#,
)
.unwrap();
assert!(ok, "physical wound paints the amount ({:?})", s.errors());
assert_eq!(
extracted_text_height(&mut s, "17"),
Some(30.0),
"base height 30 (the SetTextHeight regime)"
);
// A spell crit: ×1.5 height (the CRITICAL arm), and the type>0 yellow.
s.fire_event("UNIT_COMBAT", ev("player", "WOUND", "CRITICAL", 64, 4));
let ok: bool = s
.eval("return tostring(BenillaPlayerFrameTextureFrameHitIndicator:GetText()) == \"64\"")
.unwrap();
assert!(ok, "spell crit paints ({:?})", s.errors());
assert_eq!(
extracted_text_height(&mut s, "64"),
Some(45.0),
"×1.5 crit height, UNCAPPED past 32 (decision 0582's regime split)"
);
// A full absorb: the word at ×0.75.
s.fire_event("UNIT_COMBAT", ev("player", "WOUND", "ABSORB", 0, 0));
let ok: bool = s
.eval("return BenillaPlayerFrameTextureFrameHitIndicator:GetText() == \"Absorb\"")
.unwrap();
assert!(ok, "full absorb paints the word ({:?})", s.errors());
assert_eq!(
extracted_text_height(&mut s, "Absorb"),
Some(22.5),
"the word at ×0.75"
);
// The envelope: mid-hold the text is fully opaque; past 1.2 s it hides.
s.tick(0.5); // 0.2 fade-in + into the hold
let ok: bool = s
.eval(
r#"
local ind = BenillaPlayerFrameTextureFrameHitIndicator
return ind:IsShown() ~= nil and ind:GetAlpha() == 1.0
"#,
)
.unwrap();
assert!(ok, "mid-hold: opaque ({:?})", s.errors());
s.tick(0.8); // past fade-in + hold + fade-out (1.2 s total)
assert!(
s.eval::<bool>("return BenillaPlayerFrameTextureFrameHitIndicator:IsShown() == nil")
.unwrap(),
"the envelope ends in a Hide ({:?})",
s.errors()
);
// A target-token event leaves the (hidden) indicator alone.
s.fire_event("UNIT_COMBAT", ev("target", "WOUND", "", 99, 0));
assert!(
s.eval::<bool>("return BenillaPlayerFrameTextureFrameHitIndicator:IsShown() == nil")
.unwrap(),
"a target event never touches the player indicator"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Left-clicking the player unit frame targets yourself — the faithful self-target path (ref
/// `PlayerFrame_OnClick` → `TargetUnit("player")`). Drives the real hit-test path (a press + release
/// on the frame's centre fires its `OnClick`) against the shipped `UnitFrames.xml`, and asserts the
/// `TargetUnit` request the app drains and commits. A right-click opens the SELF unit popup
/// (decision 0434 phase 5) — nothing solo (only CANCEL survives the gates, the ref shows no
/// menu), the full leader set once a party is pushed.
#[test]
fn left_clicking_the_player_frame_targets_self() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The SELF-menu strings the popup rows bake at UnitPopup.xml load (no GlobalStrings in a
// bare engine — production reads the real file).
s.run(
r#"
LOOT_METHOD = "Loot Method"
LOOT_THRESHOLD = "Loot Threshold"
LOOT_GROUP_LOOT = "Group Loot"
PARTY_LEAVE = "Leave Party"
RAID_TARGET_ICON = "Raid Target Icon"
CANCEL = "Cancel"
ITEM_QUALITY2_DESC = "Uncommon"
"#,
)
.unwrap();
load_unit_frames(&s);
// The player must exist for the frame to be shown and mouse-hittable.
s.set_unit(
"player",
Some(UnitState {
exists: true,
health: 72,
max_health: 100,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.resolve();
assert!(
s.take_target_requests().is_empty(),
"no request before any click"
);
// Click the frame's centre through the real hit-test path (press + release on the same frame).
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaPlayerFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
assert!(
s.take_target_requests().is_empty(),
"right-click queues no target"
);
// Solo, every SELF row is gated off (only CANCEL survives) — the ref opens no menu. 1.12 has
// no PvP row here and neither do we (decision 0652 took 0646's added row back out).
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
.unwrap(),
"no SELF menu while solo"
);
s.mouse_button(cx as f32, cy as f32, "LeftButton", true);
s.mouse_button(cx as f32, cy as f32, "LeftButton", false);
assert_eq!(
s.take_target_requests(),
vec!["player"],
"left-click queues a self-target"
);
// Grouped and leading: the same right-click opens the SELF popup — title (our name),
// Loot Method + Loot Threshold (nested), Leave Party, Raid Target Icon (nested), Cancel.
s.set_party(benilla_ui::script::PartyState {
members: vec![benilla_ui::script::PartyMemberInfo {
name: "Alice".into(),
guid: 0xA11CE,
}],
leader_index: 0, // we lead
raid_members: 0,
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
});
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the SELF menu opens for a party leader"
);
assert_eq!(
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
6,
"title + Loot Method + Loot Threshold + Leave Party + Raid Target Icon + Cancel"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button4:GetText()")
.unwrap(),
"Leave Party"
);
// The nested rows carry the expand arrow (the level-2 gate for a leader).
assert!(
s.eval::<bool>("return DropDownList1Button2ExpandArrow:IsVisible()")
.unwrap(),
"Loot Method is nested for the leader"
);
// Clicking Leave Party through the real hit path fires UnitPopup_OnClick → LeaveParty()
// and closes the list (not keepShownOnClick).
s.resolve();
let (rx, ry) = s
.eval::<(f64, f64)>("return DropDownList1Button4:GetCenter()")
.unwrap();
s.mouse_button(rx as f32, ry as f32, "LeftButton", true);
s.mouse_button(rx as f32, ry as f32, "LeftButton", false);
assert_eq!(
s.take_party_requests(),
vec![benilla_ui::script::PartyRequest::Leave],
"Leave Party queues the leave intent"
);
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
.unwrap(),
"the click closes the list"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The nested level-2 list end-to-end (the 6a suspect path): a leader right-clicks the player
/// frame, hovers Raid Target Icon — a `hasArrow` row's OnEnter is what opens `DropDownList2` —
/// and clicks Skull through the real hit path. The mark intent must queue against the menu's
/// unit. The level-1 click was pinned above; this pins the level the marks actually live on.
#[test]
fn raid_mark_clicks_through_the_nested_level() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
LOOT_METHOD = "Loot Method"
LOOT_THRESHOLD = "Loot Threshold"
LOOT_GROUP_LOOT = "Group Loot"
PARTY_LEAVE = "Leave Party"
RAID_TARGET_ICON = "Raid Target Icon"
CANCEL = "Cancel"
ITEM_QUALITY2_DESC = "Uncommon"
RAID_TARGET_1 = "Star"; RAID_TARGET_2 = "Circle"; RAID_TARGET_3 = "Diamond";
RAID_TARGET_4 = "Triangle"; RAID_TARGET_5 = "Moon"; RAID_TARGET_6 = "Square";
RAID_TARGET_7 = "Cross"; RAID_TARGET_8 = "Skull"; NONE = "None"
"#,
)
.unwrap();
load_unit_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
health: 72,
max_health: 100,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.set_party(benilla_ui::script::PartyState {
members: vec![benilla_ui::script::PartyMemberInfo {
name: "Alice".into(),
guid: 0xA11CE,
}],
leader_index: 0, // we lead — the mark rows are leader-gated
raid_members: 0,
loot_method: "group".into(),
master_looter: None,
loot_threshold: 2,
});
s.resolve();
let (cx, cy) = s
.eval::<(f64, f64)>("return BenillaPlayerFrame:GetCenter()")
.unwrap();
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the SELF menu opens"
);
s.resolve();
// Hover the nested row through the real pointer path.
assert_eq!(
s.eval::<String>("return DropDownList1Button5:GetText()")
.unwrap(),
"Raid Target Icon"
);
let (rx, ry) = s
.eval::<(f64, f64)>("return DropDownList1Button5:GetCenter()")
.unwrap();
s.mouse_move(rx as f32, ry as f32);
assert!(
s.eval::<bool>("return DropDownList2:IsVisible()").unwrap(),
"hovering the nested row opens level 2"
);
assert_eq!(
s.eval::<i64>("return DropDownList2.numButtons").unwrap(),
9,
"the eight marks + None"
);
s.resolve();
// Click Skull (row 8) through the real hit path.
assert_eq!(
s.eval::<String>("return DropDownList2Button8:GetText()")
.unwrap(),
"Skull"
);
let (sx, sy) = s
.eval::<(f64, f64)>("return DropDownList2Button8:GetCenter()")
.unwrap();
s.mouse_button(sx as f32, sy as f32, "LeftButton", true);
s.mouse_button(sx as f32, sy as f32, "LeftButton", false);
assert_eq!(
s.take_party_requests(),
vec![benilla_ui::script::PartyRequest::SetRaidTarget {
unit: "player".into(),
index: 8
}],
"Skull queues the mark intent for the menu's unit"
);
// Ref law: the click hides only the row's OWN list (UIDropDownMenuButton_OnClick's
// `this:GetParent():Hide()`; DropDownList1's OnHide closes level 2, never the reverse).
// Level 1 lingers and dies by the 2s show-timer once the pointer leaves the chain.
assert!(
s.eval::<bool>("return not DropDownList2:IsVisible() and DropDownList1:IsVisible()")
.unwrap(),
"the click closes its own level; level 1 lingers (ref)"
);
// Two ticks: the ref's OnUpdate hides only on the frame AFTER the timer crosses zero.
s.mouse_move(5.0, 5.0);
s.tick(2.1);
s.tick(0.1);
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
.unwrap(),
"level 1 times out 2s after the pointer leaves the chain"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The level slot's transcribed CheckLevel law (ref-TargetFrame.lua l.119-142) end to end over
/// the REAL `GetDifficultyColor` (ref-QuestLogFrame.lua l.14-20 + l.584-599, loaded from the
/// shipped QuestLogFrame.xml — its ref home) and `UnitLevel`'s −1 return: an attackable target
/// difficulty-colors its number; a hostile 10+ levels up (UnitLevel −1) or a corpse swaps the
/// number for the HighLevelTexture skull; the green→grey boundary rides the real
/// GetQuestGreenRange binding.
#[test]
fn shipped_target_frame_runs_the_level_law() {
use benilla_ui::script::PlayerReqState;
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
// GetDifficultyColor's own load chain (the quest log window, its ref home).
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "QuestLogFrame.xml");
// The player at level 3, both feeds (the snapshot UnitLevel("player") reads; the req state
// the −1 gate and GetQuestGreenRange read) — the app keeps the two in step.
s.set_player_req_state(PlayerReqState {
level: 3,
..Default::default()
});
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 3,
is_player: true,
health: 50,
max_health: 50,
..UnitState::default()
}),
);
// An attackable boar 5 up (the screenshot's case at the director's level): the number shows,
// impossible-red via the real table; no skull.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Elder Mottled Boar".into()),
health: 40,
max_health: 40,
level: 8,
reaction: 4,
can_attack: true,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let ok: bool = s
.eval(
r#"
local lvl = getglobal("BenillaTargetFrameTextureFrameLevelText")
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
local c = GetDifficultyColor(8)
return lvl:IsShown() ~= nil and skull:IsShown() == nil
and tostring(lvl:GetText()) == "8"
and c.r == 1.00 and c.g == 0.10 and c.b == 0.10
"#,
)
.unwrap();
assert!(ok, "attackable +5: red number, no skull ({:?})", s.errors());
// A hostile 10+ levels up: UnitLevel reads −1 → the skull replaces the number.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Guard".into()),
health: 400,
max_health: 400,
level: 13,
reaction: 2,
can_attack: true,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let ok: bool = s
.eval(
r#"
local lvl = getglobal("BenillaTargetFrameTextureFrameLevelText")
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
return UnitLevel("target") == -1
and skull:IsShown() ~= nil and lvl:IsShown() == nil
"#,
)
.unwrap();
assert!(ok, "hostile +10: the skull shows ({:?})", s.errors());
// A DEAD mob is NOT a corpse (§5: UnitIsCorpse is a pure TYPEID_CORPSE object check, and
// UnitLevel has no health test) — the ref shows a dead mob's NUMBER, not the skull.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Elder Mottled Boar".into()),
health: 0,
max_health: 40,
level: 8,
reaction: 4,
dead: true,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let ok: bool = s
.eval(
r#"
local lvl = getglobal("BenillaTargetFrameTextureFrameLevelText")
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
return UnitIsCorpse("target") == nil
and lvl:IsShown() ~= nil and skull:IsShown() == nil
and tostring(lvl:GetText()) == "8"
"#,
)
.unwrap();
assert!(
ok,
"dead mob: the number shows, no skull ({:?})",
s.errors()
);
// A resolved CORPSE world object: the ref's first branch — the skull, whatever the level.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Corpse of Somebody".into()),
level: 8,
corpse_object: true,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let ok: bool = s
.eval(
r#"
local skull = getglobal("BenillaTargetFrameTextureFrameHighLevelTexture")
return UnitIsCorpse("target") == 1 and skull:IsShown() ~= nil
"#,
)
.unwrap();
assert!(ok, "corpse object: the skull shows ({:?})", s.errors());
// The green→grey boundary rides GetQuestGreenRange: at player 30 the band is 7 —
// 7 below (23) still standard-green, 8 below (22) trivial-grey.
s.set_player_req_state(PlayerReqState {
level: 30,
..Default::default()
});
s.set_unit(
"player",
Some(UnitState {
exists: true,
level: 30,
is_player: true,
health: 50,
max_health: 50,
..UnitState::default()
}),
);
let ok: bool = s
.eval(
r#"
local g, t = GetDifficultyColor(23), GetDifficultyColor(22)
return GetQuestGreenRange() == 7
and g.r == 0.25 and g.g == 0.75 and g.b == 0.25
and t.r == 0.50 and t.g == 0.50 and t.b == 0.50
"#,
)
.unwrap();
assert!(ok, "green range boundary at level 30 ({:?})", s.errors());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The PvP flag icon on the player and target frames (decision 0646 §4): the reference's
/// three-branch law, driven through the real shipped XML. FFA outranks the faction flag; the
/// faction leg needs BOTH a side and the flag; and the player's `igPVPUpdate` sounds on the
/// UNIT_FACTION edge, not on every repaint.
#[test]
fn pvp_icon_follows_the_three_branch_law() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let _ = s.take_sounds(); // the frames' own load-time kits (target hide) aren't ours
let icon_shown = |s: &UiScript, frame: &str| -> bool {
s.eval(&format!(
"return {frame}TextureFramePVPIcon:IsVisible() and true or false"
))
.unwrap()
};
// The extracted quad path for a shown icon (nil while hidden).
let icon_path = |s: &mut UiScript, needle: &str| -> bool {
s.resolve();
s.extract().into_iter().any(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)),
)
};
let alliance_player = |pvp: bool, ffa: bool| UnitState {
exists: true,
name: Some("Benilla".into()),
health: 50,
max_health: 50,
level: 10,
is_player: true,
faction_group: Some("Alliance".into()),
pvp,
is_pvp_ffa: ffa,
..UnitState::default()
};
// Unflagged: no icon, no sound.
s.set_unit("player", Some(alliance_player(false, false)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("player".into())]);
assert!(
!icon_shown(&s, "BenillaPlayerFrame"),
"unflagged shows none"
);
assert!(s.take_sounds().is_empty(), "no sound while unflagged");
// Flagged: the Alliance icon, and one igPVPUpdate for the flag change.
s.set_unit("player", Some(alliance_player(true, false)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("player".into())]);
assert!(icon_shown(&s, "BenillaPlayerFrame"));
assert!(icon_path(&mut s, "UI-PVP-Alliance"), "faction leg art");
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igPVPUpdate".into())],
"the flag change sounds once"
);
// A repaint that is NOT a flag change (a health tick) must not re-sound.
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
assert!(s.take_sounds().is_empty(), "repaints don't re-sound");
// FFA outranks the faction flag even when both are set — the reference's branch order.
s.set_unit("player", Some(alliance_player(true, true)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("player".into())]);
assert!(icon_path(&mut s, "UI-PVP-FFA"), "FFA wins the branch");
let _ = s.take_sounds();
// No resolvable side (a Monster/neutral template) hides the icon however flagged it is —
// the `factionGroup and UnitIsPVP` gate. A guard the app can't name a side for draws nothing
// rather than reaching for a texture that doesn't ship.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Kobold Vermin".into()),
health: 40,
max_health: 40,
level: 8,
reaction: 2,
pvp: true,
faction_group: None,
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(
!icon_shown(&s, "BenillaTargetFrame"),
"flagged but sideless draws no icon"
);
// A Horde target that IS flagged takes its own faction's art on the target frame.
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: Some("Orgrimmar Grunt".into()),
health: 40,
max_health: 40,
level: 8,
reaction: 2,
pvp: true,
faction_group: Some("Horde".into()),
..UnitState::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(icon_shown(&s, "BenillaTargetFrame"));
assert!(icon_path(&mut s, "UI-PVP-Horde"), "the target's own side");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The target name plate's two middle legs, unblocked by the PvP wire (decision 0646 §4): a
/// friendly player who is PvP-flagged reads GREEN, an unflagged one stays blue. Before the wire
/// both collapsed into blue.
#[test]
fn flagged_friendly_player_plate_is_green() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let plate_color = |s: &mut UiScript| -> [f32; 4] {
s.resolve();
s.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Texture {
path: Some(p),
color: Some(c),
..
} if p.contains("LevelBackground") => Some(c),
_ => None,
})
.expect("target name-plate quad present")
};
let friendly_player = |pvp: bool| UnitState {
exists: true,
name: Some("Guildmate".into()),
health: 50,
max_health: 50,
level: 20,
is_player: true,
reaction: 5,
can_attack: false,
faction_group: Some("Alliance".into()),
pvp,
..UnitState::default()
};
s.set_unit("target", Some(friendly_player(false)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let blue = plate_color(&mut s);
assert!(
blue[0].abs() < 1e-6 && blue[1].abs() < 1e-6 && (blue[2] - 1.0).abs() < 1e-6,
"an unflagged friendly player is blue, got {blue:?}"
);
s.set_unit("target", Some(friendly_player(true)));
s.fire_event("UNIT_FACTION", vec![ScriptValue::Str("target".into())]);
let green = plate_color(&mut s);
assert!(
green[0].abs() < 1e-6 && (green[1] - 1.0).abs() < 1e-6 && green[2].abs() < 1e-6,
"a PvP-flagged friendly player is green (UnitReactionColor[6]), got {green:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The classification border law (decision 0782, ref-TargetFrame.lua l.205-218) end to end: the
/// gated rank on the snapshot → `UnitClassification` → which of the three shipped border textures
/// actually reaches the draw list. Asserting the *extracted quad* rather than a Lua getter is the
/// point — it is the pixels, and it catches a swap that sets the path on the wrong region.
///
/// The two facts worth a test rather than a comment: three of the five classifications share the
/// Elite art (1.12 ships no rare-elite border at all), and the border must repaint on
/// UNIT_CLASSIFICATION_CHANGED alone, with no re-target.
#[test]
fn target_frame_border_follows_the_classification_law() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
// Every TargetingFrame border path in the draw list. The player frame contributes the plain
// art on every frame, so the assertions below are about which *extra* border appears.
let borders = |s: &mut UiScript| -> Vec<String> {
s.resolve();
let mut v: Vec<String> = s
.extract()
.into_iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. }
if p.contains("UI-TargetingFrame") && !p.contains("LevelBackground") =>
{
Some(p.clone())
}
_ => None,
})
.collect();
v.sort();
v.dedup();
v
};
let has = |v: &[String], suffix: &str| v.iter().any(|p| p.ends_with(suffix));
let mob = |rank: u32| UnitState {
exists: true,
name: Some("Ol' Sooty".into()),
health: 400,
max_health: 400,
level: 26,
reaction: 2,
can_attack: true,
rank,
..UnitState::default()
};
// rank 0 — a plain mob: no elite and no rare art anywhere.
s.set_unit("target", Some(mob(0)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let v = borders(&mut s);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
"normal"
);
assert!(
!has(&v, "UI-TargetingFrame-Elite") && !has(&v, "UI-TargetingFrame-Rare"),
"rank 0 wears the plain border, got {v:?}"
);
// ranks 1/2/3 — elite, rare-elite and world boss ALL take the one Elite texture. 1.12 ships no
// UI-TargetingFrame-Rare-Elite (absent from the patch chain), which is why 2 lands here.
for (rank, word) in [(1, "elite"), (2, "rareelite"), (3, "worldboss")] {
s.set_unit("target", Some(mob(rank)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let v = borders(&mut s);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
word
);
assert!(
has(&v, "UI-TargetingFrame-Elite") && !has(&v, "UI-TargetingFrame-Rare"),
"rank {rank} ({word}) takes the Elite border, got {v:?}"
);
}
// rank 4 — rare (the silver dragon), the only classification with art of its own.
s.set_unit("target", Some(mob(4)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let v = borders(&mut s);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
"rare"
);
assert!(
has(&v, "UI-TargetingFrame-Rare") && !has(&v, "UI-TargetingFrame-Elite"),
"rank 4 takes the Rare border, got {v:?}"
);
// The repaint wire: the creature query landing on an already-targeted mob raises its rank, and
// UNIT_CLASSIFICATION_CHANGED alone must swap the border — no PLAYER_TARGET_CHANGED.
s.set_unit("target", Some(mob(0)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
assert!(
!has(&borders(&mut s), "UI-TargetingFrame-Elite"),
"precondition: plain border before the query lands"
);
s.set_unit("target", Some(mob(1)));
s.fire_event(
"UNIT_CLASSIFICATION_CHANGED",
vec![ScriptValue::Str("target".into())],
);
assert!(
has(&borders(&mut s), "UI-TargetingFrame-Elite"),
"the event alone repaints the border"
);
// The player frame never reclassifies: its own art stays plain with an elite target up.
let plain_on_player: bool = s
.eval(
r#"
local p = getglobal("BenillaPlayerFrameTextureFrameTexture")
return p ~= nil and BenillaPlayerFrame.frameTexture == nil
"#,
)
.unwrap();
assert!(
plain_on_player,
"the player frame has the region but caches no handle, so nothing can swap it"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The recessed-bar look, asserted in the draw list: the ring art's metal well-edges must paint
/// **over** the health/power fills, and the name/level text over both.
///
/// This is the shape decision 0884 broke. Our transcription used to get the ring over the bars by
/// *declaring* `$parentTextureFrame` after the StatusBars, leaning on the old draw key's
/// insertion-order tie-break between siblings at one `(strata, level)`. 0884 pinned the real law —
/// the draw **layer** is bucket-wide and outranks the frame — so the bars' ARTWORK fills started
/// painting over the TextureFrame's BACKGROUND ring art and the frames read as pasted-on slabs.
/// The fix is the reference's own mechanism (`TargetFrame.lua` l.32-34): push the bars one frame
/// level *below* the texture frame, where no layer can lift them back over it.
#[test]
fn the_ring_art_paints_over_the_bars() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Onemage".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..UnitState::default()
}),
);
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.resolve();
let quads = s.extract();
// The ring art: `UI-TargetingFrame` exactly — not the `-LevelBackground` / `-Elite` siblings.
let ring = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-TargetingFrame"))
})
.expect("the player frame's ring art");
// The bar fills (both bars share the texture; take the highest z of the two).
let fill = quads
.iter()
.filter(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-StatusBar"))
})
.map(|q| q.z)
.max()
.expect("a bar fill");
let name = quads
.iter()
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "Onemage"))
.expect("the name text");
assert!(
ring.z > fill,
"the ring art must paint OVER the bar fills (ring z={:#x}, fill z={fill:#x})",
ring.z
);
assert!(
name.z > fill,
"the name text must paint OVER the bar fills (name z={:#x}, fill z={fill:#x})",
name.z
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The same recessed-bar law for the PARTY member frames, which carry an independent copy of the
/// idiom (`PartyFrame.xml`'s `$parentTextureFrame` over `$parentHealthBar`/`$parentManaBar`).
///
/// Split from the player/target test on purpose: the two files reach the same look through separate
/// OnLoads, so one green assertion says nothing about the other. This is the copy 0884 broke in
/// silence — nobody was in a party when the director reported the player frame.
#[test]
fn the_party_art_paints_over_the_bars() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The loot test's prefix (`loot_tests.rs`): PartyFrame's inline <Script> reads
// StaticPopupDialogs, which UiPanels.xml defines, and its per-member dropdown OnLoad walks the
// whole popup kit.
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
// BENILLA_POWER_COLORS (the shared power-colour table) lives in UnitFrames.xml; the party
// frames' Update reads it. The player/target frames it also defines stay hidden here — no unit.
load_xml(&s, "UnitFrames.xml");
load_xml(&s, "PartyFrame.xml");
s.set_unit(
"party1",
Some(UnitState {
exists: true,
name: Some("Onepriest".into()),
health: 60,
max_health: 100,
level: 60,
power_type: 0,
power: 60,
max_power: 100,
..UnitState::default()
}),
);
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
s.resolve();
let quads = s.extract();
// Scoped to member frame 1 by owner name, so a stray UI-StatusBar from any other frame in the
// manifest can never stand in for the bar under test.
let mine = |q: &benilla_ui::script::ExtractedQuad| {
s.quad_owner_name(q.target)
.is_some_and(|n| n.starts_with("PartyMemberFrame1"))
};
let art = quads
.iter()
.find(|q| {
mine(q)
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-PartyFrame"))
})
.expect("the party member frame art");
let fill = quads
.iter()
.filter(|q| {
mine(q)
&& matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
if p.ends_with("UI-StatusBar"))
})
.map(|q| q.z)
.max()
.expect("a bar fill");
assert!(
art.z > fill,
"the party art must paint OVER the bar fills (art z={:#x}, fill z={fill:#x})",
art.z
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **What a feigning hunter looks like on the frames** (decision 1022) — the end of the chain the
/// snapshot starts: `UNIT_DYNFLAG_DEAD` zeroes `UnitHealth`/`UnitMana` while the maxima stay real
/// (`UnitHealth 0x5174d0` gates, `UnitHealthMax 0x5175b0` does not), so both bars run **empty over
/// a full-size track** rather than collapsing to a 0/0 nothing, and the target frame's DEAD text
/// lights on the same `UnitHealth(unit) <= 0` test a corpse trips.
#[test]
fn a_feigning_target_paints_empty_bars_and_the_dead_text() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let hunter = |health: u32, power: u32, dead: bool| {
Some(UnitState {
exists: true,
name: Some("Nazriel".into()),
health,
max_health: 1500,
level: 60,
power_type: 0,
power,
max_power: 900,
dead,
reaction: 2, // hostile — the frame we would be watching him through
..UnitState::default()
})
};
s.set_unit("target", hunter(1200, 300, false));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
let alive: bool = s
.eval(
r#"
local hb, pb = BenillaTargetFrameHealthBar, BenillaTargetFramePowerBar
return hb:GetValue() == 1200 and pb:GetValue() == 300
and BenillaTargetFrameTextureFrameDeadText:GetText() == ""
"#,
)
.unwrap();
assert!(alive, "the control: a live hunter reads live");
// He feigns. Only the flag moved on the wire — the snapshot turns it into these three.
s.set_unit("target", hunter(0, 0, true));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("target".into())]);
let (hp, hmax, mana, mmax): (f64, f64, f64, f64) = (
s.eval("return BenillaTargetFrameHealthBar:GetValue()")
.unwrap(),
s.eval("local _, m = BenillaTargetFrameHealthBar:GetMinMaxValues() return m")
.unwrap(),
s.eval("return BenillaTargetFramePowerBar:GetValue()")
.unwrap(),
s.eval("local _, m = BenillaTargetFramePowerBar:GetMinMaxValues() return m")
.unwrap(),
);
assert_eq!((hp, hmax), (0.0, 1500.0), "empty health bar, real track");
assert_eq!((mana, mmax), (0.0, 900.0), "empty mana bar, real track");
assert!(
s.eval::<bool>("return BenillaTargetFramePowerBar:IsVisible()")
.unwrap(),
"the mana bar empties, it does not disappear — UnitManaMax 0x5177e0 is ungated"
);
assert_eq!(
s.eval::<String>("return BenillaTargetFrameTextureFrameDeadText:GetText()")
.unwrap(),
"DEAD",
"TargetFrame_CheckDead's UnitHealth(unit) <= 0 test, tripped by the flag"
);
assert!(
s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap(),
"UnitIsDead 0x517ac0's dynflag leg reaches the API too"
);
// He stands back up: the flag clears, and nothing about the body needed restoring.
s.set_unit("target", hunter(1200, 300, false));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("target".into())]);
let up: bool = s
.eval(
r#"
return BenillaTargetFrameHealthBar:GetValue() == 1200
and BenillaTargetFramePowerBar:GetValue() == 300
and BenillaTargetFrameTextureFrameDeadText:GetText() == ""
and not UnitIsDead("target")
"#,
)
.unwrap();
assert!(up, "the feign ends and the frame reads live again");
}