- the name cache persists to `benilla-config/cache/<realm>.tsv`, because the reference's `namecache.wdb` / `petnamecache.wdb` do and that is why it never sees the races we lost every time — unstabling a pet raised `Usage: SetText("text")` off an unresolved `UnitName("pet")`; the header carries magic, format, build, locale and realm and any mismatch discards the file (no checksum, no TTL: eviction is explicit only, via `SMSG_INVALIDATE_PLAYER`, decoded now and never sent by vmangos), negatives persist too, written on a 10 s debounce and at exit; a stable list also seeds the pet-name cache from the names it already carries
- `SMSG_NOTIFICATION` is the red UIErrorsFrame toast, not a chat line — `.gm on` printed twice because vmangos sends both a system message and a notification and both were rendered as chat; the stand-in dated from before the errors frame existed; `SMSG_AREA_TRIGGER_MESSAGE` moves with it on the yellow arm (its handler ends `xor edx,edx` — flag 0 — where the notification's is flag 1)
- the target frame's dead text is the GlobalString `DEAD` = "Dead" resolved through `text=` like the target-of-target frame, not the key typed into `SetText` in caps, and it takes `UnitIsConnected` so a disconnected player does not read as dead; a shipped-script sweep now refuses any key-shaped literal handed to a text sink; and the Help window is one click deep — Home's button opens the ticket text box, Auto-Unstuck sits beside it, and a ticket files as category 0, which a GM's queue names "Unknown" (a guard added days earlier refused 0 on reasoning from the enum rather than from the function that reads it)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1844 lines
69 KiB
Rust
1844 lines
69 KiB
Rust
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) {
|
||
// The app runs the real `Interface\FrameXML\GlobalStrings.lua` off the player's chain
|
||
// BEFORE any XML (`load_global_strings`); this stands in for it, with the one key these
|
||
// frames resolve at load time — `DEAD` l.898, which both dead-text FontStrings name in
|
||
// their `text=` attribute. Without it the loader's literal fallback would leave the KEY on
|
||
// screen, which is the bug this file guards below.
|
||
s.run(r#"DEAD = "Dead""#).unwrap();
|
||
load_xml(s, "Fonts.xml");
|
||
load_xml(s, "UIParent.xml");
|
||
// The bars' numerals machinery (decision 1082), which the manifest loads immediately ahead of
|
||
// UnitFrames.xml and which every bar's OnLoad wires into since 1143.
|
||
load_xml(s, "TextStatusBar.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 PlayerFrame:IsVisible() and not TargetFrame: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 PlayerFrame:IsVisible()").unwrap());
|
||
let ok: bool = s
|
||
.eval(
|
||
r#"
|
||
local hb, pb = PlayerFrameHealthBar, PlayerFramePowerBar
|
||
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 PlayerFrameTextureFrameNameText: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 PlayerFrameTextureFrameNameText: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 TargetFrame:IsVisible()
|
||
and not TargetFramePowerBar:IsVisible()
|
||
and TargetFrameTextureFrameNameText:GetText() == "Young Wolf"
|
||
and TargetFrameTextureFrameLevelText:GetText() == "3"
|
||
and not TargetFrameTextureFrameDeadText:IsShown() -- living target: no dead word
|
||
"#,
|
||
)
|
||
.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 TargetFrame: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 = PlayerFrameTextureFrameHitIndicator
|
||
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(PlayerFrameTextureFrameHitIndicator: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 PlayerFrameTextureFrameHitIndicator: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 = PlayerFrameTextureFrameHitIndicator
|
||
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 PlayerFrameTextureFrameHitIndicator: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 PlayerFrameTextureFrameHitIndicator: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 PlayerFrame: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: Vec::new(),
|
||
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: Vec::new(),
|
||
loot_method: "group".into(),
|
||
master_looter: None,
|
||
loot_threshold: 2,
|
||
});
|
||
s.resolve();
|
||
|
||
let (cx, cy) = s
|
||
.eval::<(f64, f64)>("return PlayerFrame: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, "MoneyFrame.xml");
|
||
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("TargetFrameTextureFrameLevelText")
|
||
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
|
||
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("TargetFrameTextureFrameLevelText")
|
||
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
|
||
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("TargetFrameTextureFrameLevelText")
|
||
local skull = getglobal("TargetFrameTextureFrameHighLevelTexture")
|
||
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("TargetFrameTextureFrameHighLevelTexture")
|
||
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, "PlayerFrame"), "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, "PlayerFrame"));
|
||
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, "TargetFrame"),
|
||
"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, "TargetFrame"));
|
||
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("PlayerFrameTextureFrameTexture")
|
||
return p ~= nil and PlayerFrame.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, "MoneyFrame.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,
|
||
is_connected: true, // CheckDead's second term — a feign is not a link-drop
|
||
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 = TargetFrameHealthBar, TargetFramePowerBar
|
||
return hb:GetValue() == 1200 and pb:GetValue() == 300
|
||
and not TargetFrameTextureFrameDeadText:IsShown()
|
||
"#,
|
||
)
|
||
.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 TargetFrameHealthBar:GetValue()").unwrap(),
|
||
s.eval("local _, m = TargetFrameHealthBar:GetMinMaxValues() return m")
|
||
.unwrap(),
|
||
s.eval("return TargetFramePowerBar:GetValue()").unwrap(),
|
||
s.eval("local _, m = TargetFramePowerBar: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 TargetFramePowerBar:IsVisible()")
|
||
.unwrap(),
|
||
"the mana bar empties, it does not disappear — UnitManaMax 0x5177e0 is ungated"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return TargetFrameTextureFrameDeadText:IsShown() and true or false")
|
||
.unwrap(),
|
||
"TargetFrame_CheckDead's UnitHealth(unit) <= 0 test, tripped by the flag"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return TargetFrameTextureFrameDeadText:GetText()")
|
||
.unwrap(),
|
||
"Dead",
|
||
"the WORD is the GlobalString `DEAD` (l.898), never the key: a literal \"DEAD\" here \
|
||
is the caps bug the director caught on Onyxia"
|
||
);
|
||
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 TargetFrameHealthBar:GetValue() == 1200
|
||
and TargetFramePowerBar:GetValue() == 300
|
||
and not TargetFrameTextureFrameDeadText:IsShown()
|
||
and not UnitIsDead("target")
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(up, "the feign ends and the frame reads live again");
|
||
}
|
||
|
||
/// The resting status flash (decision 1082, ref `PlayerFrame_UpdateStatus` + `_OnUpdate`): while
|
||
/// resting the player frame wears the gold status ring, the zzz state icon and its glow, all
|
||
/// pulsing on a 0.5 s alpha wave; auto-attack (PLAYER_ENTER_COMBAT) swaps them for the red
|
||
/// ring/swords/disc — resting still wins when both hold — and leaving both states clears the lot.
|
||
#[test]
|
||
fn the_player_frame_flashes_zzz_while_resting() {
|
||
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("Prober".into()),
|
||
health: 100,
|
||
max_health: 100,
|
||
level: 12,
|
||
power_type: 0,
|
||
power: 80,
|
||
max_power: 80,
|
||
dead: false,
|
||
reaction: 0,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
|
||
// Into the inn: the resting flag lands and PLAYER_UPDATE_RESTING repaints.
|
||
s.set_rest_state(1, 500, true);
|
||
s.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||
let resting: bool = s
|
||
.eval(
|
||
r#"
|
||
local tf = "PlayerFrameTextureFrame"
|
||
return getglobal(tf .. "StatusTexture"):IsShown()
|
||
and getglobal(tf .. "RestIcon"):IsShown()
|
||
and not getglobal(tf .. "AttackIcon"):IsShown()
|
||
and PlayerFrameStatusGlow:IsShown()
|
||
and PlayerFrameStatusGlowRestGlow:IsShown()
|
||
and not PlayerFrameStatusGlowAttackGlow:IsShown()
|
||
and not getglobal(tf .. "AttackBackground"):IsShown()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(
|
||
resting,
|
||
"resting shows the gold ring + zzz + glow, nothing red"
|
||
);
|
||
|
||
// The pulse: two OnUpdate ticks move the ring's alpha (the 0.5 s triangle wave).
|
||
let a0: f64 = s
|
||
.eval("return PlayerFrameTextureFrameStatusTexture:GetAlpha()")
|
||
.unwrap();
|
||
s.run("this = PlayerFrame; PlayerFrame_OnUpdate(0.25)")
|
||
.unwrap();
|
||
let a1: f64 = s
|
||
.eval("return PlayerFrameTextureFrameStatusTexture:GetAlpha()")
|
||
.unwrap();
|
||
assert!(
|
||
(a0 - a1).abs() > 0.1,
|
||
"the flash moves the status alpha ({a0} → {a1})"
|
||
);
|
||
|
||
// Swinging while resting: resting still wins (the ref's branch order).
|
||
s.fire_event("PLAYER_ENTER_COMBAT", vec![]);
|
||
assert!(
|
||
s.eval::<bool>("return PlayerFrameTextureFrameRestIcon:IsShown()")
|
||
.unwrap(),
|
||
"resting outranks auto-attack"
|
||
);
|
||
|
||
// Out of the inn mid-swing: the red attack set takes over.
|
||
s.set_rest_state(2, 0, false);
|
||
s.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||
let attacking: bool = s
|
||
.eval(
|
||
r#"
|
||
local tf = "PlayerFrameTextureFrame"
|
||
return getglobal(tf .. "StatusTexture"):IsShown()
|
||
and getglobal(tf .. "AttackIcon"):IsShown()
|
||
and not getglobal(tf .. "RestIcon"):IsShown()
|
||
and PlayerFrameStatusGlowAttackGlow:IsShown()
|
||
and getglobal(tf .. "AttackBackground"):IsShown()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(attacking, "auto-attack shows the red ring + swords + disc");
|
||
|
||
// Swords down: everything clears.
|
||
s.fire_event("PLAYER_LEAVE_COMBAT", vec![]);
|
||
let clear: bool = s
|
||
.eval(
|
||
r#"
|
||
local tf = "PlayerFrameTextureFrame"
|
||
return not getglobal(tf .. "StatusTexture"):IsShown()
|
||
and not getglobal(tf .. "RestIcon"):IsShown()
|
||
and not getglobal(tf .. "AttackIcon"):IsShown()
|
||
and not PlayerFrameStatusGlow:IsShown()
|
||
and not getglobal(tf .. "AttackBackground"):IsShown()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert!(clear, "neither state → no status dressing at all");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// The zzz badge paints OVER the level number (decision 1093): the ref keeps the number in the
|
||
/// texture frame's BACKGROUND layer and the state icons up in OVERLAY, so the opaque badge covers
|
||
/// it while resting. The layer split is the only mechanism that CAN hide it — a fontstring never
|
||
/// ducks under a texture of its own layer (0884's bucket-wide quads-then-text law) — which is
|
||
/// exactly how 1082's transcription slipped: it put the number in OVERLAY beside the icons, and
|
||
/// the number rode on the badge (director report, 2026-08-07).
|
||
#[test]
|
||
fn the_rest_badge_covers_the_level_number() {
|
||
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("Prober".into()),
|
||
health: 100,
|
||
max_health: 100,
|
||
level: 12,
|
||
power_type: 0,
|
||
power: 80,
|
||
max_power: 80,
|
||
dead: false,
|
||
reaction: 0,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||
s.set_rest_state(1, 500, true);
|
||
s.fire_event("PLAYER_UPDATE_RESTING", vec![]);
|
||
s.resolve();
|
||
let quads = s.extract();
|
||
|
||
// Every UI-StateIcon quad (the badge AND its ADD glow — the glow rides a later frame and
|
||
// sits higher still): even the LOWEST must clear the number.
|
||
let badge = quads
|
||
.iter()
|
||
.filter(|q| {
|
||
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
|
||
if p.ends_with("UI-StateIcon"))
|
||
})
|
||
.map(|q| q.z)
|
||
.min()
|
||
.expect("the zzz badge");
|
||
let level = quads
|
||
.iter()
|
||
.find(|q| matches!(&q.content, QuadContent::Text { text: Some(t), .. } if t == "12"))
|
||
.expect("the level text");
|
||
assert!(
|
||
badge > level.z,
|
||
"the badge must cover the level number (badge z={badge:#x}, level z={:#x})",
|
||
level.z
|
||
);
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// **The on-bar numerals** (decision 1143) — the half 1140 promised and did not deliver. The Status
|
||
/// Bar Text switch landed with only the XP bar wired to it, so turning it on changed nothing on the
|
||
/// frames people actually watch (director report). Now the player's health and power bars carry
|
||
/// "value / max", and the switch pins them.
|
||
///
|
||
/// Which bars it pins is the REFERENCE's own split, transcribed rather than chosen: 1.12 sets
|
||
/// `textLockable` on the player's two bars, the pet's two and the XP bar, and on nothing else — so
|
||
/// the target frame's numerals are hover-only there and stay blank here. The power bar is whatever
|
||
/// the unit runs on; a rage unit reads "45 / 80" the same as a mana one.
|
||
#[test]
|
||
fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
load_unit_frames(&s);
|
||
|
||
let alive = |health: u32, power: u32, power_type: u8| UnitState {
|
||
exists: true,
|
||
name: Some("Somebody".into()),
|
||
health,
|
||
max_health: 100,
|
||
level: 12,
|
||
power_type,
|
||
power,
|
||
max_power: 80,
|
||
dead: false,
|
||
reaction: 4,
|
||
..UnitState::default()
|
||
};
|
||
s.set_unit("player", Some(alive(72, 45, 1))); // power_type 1 = RAGE
|
||
s.set_unit("target", Some(alive(50, 20, 0)));
|
||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
|
||
let text = |s: &UiScript, name: &str| -> String {
|
||
s.eval::<String>(&format!("return tostring(({name}:GetText()) or \"\")"))
|
||
.unwrap()
|
||
};
|
||
let shown = |s: &UiScript, name: &str| -> bool {
|
||
s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap()
|
||
};
|
||
|
||
// Off (the shipped default): the strings carry the numbers, and nothing paints.
|
||
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
|
||
assert!(!shown(&s, "PlayerFrameTextureFramePowerBarText"));
|
||
|
||
// On, through the switch's own event — no repaint, no damage taken.
|
||
s.register_cvars([("statusBarText", "0")]);
|
||
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
|
||
.unwrap();
|
||
s.tick(0.0);
|
||
assert!(
|
||
shown(&s, "PlayerFrameTextureFrameHealthBarText"),
|
||
"your own health numerals pin on"
|
||
);
|
||
assert_eq!(
|
||
text(&s, "PlayerFrameTextureFrameHealthBarText"),
|
||
"72 / 100",
|
||
"bare numbers: the reference's \"Health\"/\"Rage\" labels were cut on the \
|
||
director's call (1147)"
|
||
);
|
||
assert_eq!(
|
||
text(&s, "PlayerFrameTextureFramePowerBarText"),
|
||
"45 / 80",
|
||
"the power bar shows the number alone, whatever resource it is"
|
||
);
|
||
|
||
// The target's stay dark with the switch ON: 1.12 never makes its bars textLockable, so the
|
||
// option does not reach them.
|
||
assert!(
|
||
!shown(&s, "TargetFrameTextureFrameHealthBarText"),
|
||
"the switch pins your own numbers, never the target's"
|
||
);
|
||
|
||
// ...but the HOVER reveals them, which is how you read a target's health in 1.12 (1146).
|
||
s.run("BenillaUnitFrameBar_OnEnter(TargetFrameHealthBar)")
|
||
.unwrap();
|
||
assert!(shown(&s, "TargetFrameTextureFrameHealthBarText"));
|
||
assert_eq!(text(&s, "TargetFrameTextureFrameHealthBarText"), "50 / 100");
|
||
s.run("BenillaUnitFrameBar_OnLeave(TargetFrameHealthBar)")
|
||
.unwrap();
|
||
assert!(
|
||
!shown(&s, "TargetFrameTextureFrameHealthBarText"),
|
||
"and go away again — the lockShow refcount balances"
|
||
);
|
||
|
||
// The same hover works with the switch OFF, which is the point of it on YOUR frame too: the
|
||
// numbers are there when you go looking, without living on the bar.
|
||
s.run("SetCVar(\"statusBarText\", \"0\", \"STATUS_BAR_TEXT\")")
|
||
.unwrap();
|
||
s.tick(0.0);
|
||
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
|
||
s.run("BenillaUnitFrameBar_OnEnter(PlayerFrameHealthBar)")
|
||
.unwrap();
|
||
assert!(
|
||
shown(&s, "PlayerFrameTextureFrameHealthBarText"),
|
||
"hover shows them even with the option off"
|
||
);
|
||
s.run("BenillaUnitFrameBar_OnLeave(PlayerFrameHealthBar)")
|
||
.unwrap();
|
||
assert!(!shown(&s, "PlayerFrameTextureFrameHealthBarText"));
|
||
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
|
||
.unwrap();
|
||
s.tick(0.0);
|
||
|
||
// A health change repaints through the bar's own OnValueChanged, like the reference's.
|
||
s.set_unit("player", Some(alive(31, 45, 1)));
|
||
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
|
||
assert_eq!(text(&s, "PlayerFrameTextureFrameHealthBarText"), "31 / 100");
|
||
|
||
// A power-type change repaints the same way (it used to re-LABEL the bar too — 1147 cut that).
|
||
s.set_unit("player", Some(alive(31, 60, 3)));
|
||
s.fire_event("UNIT_DISPLAYPOWER", vec![ScriptValue::Str("player".into())]);
|
||
assert_eq!(text(&s, "PlayerFrameTextureFramePowerBarText"), "60 / 80");
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// **The numerals do not collide** (decision 1147) — the pin for a bug the director caught on
|
||
/// screen: the pet frame's two numeral strings overlapped each other and its name.
|
||
///
|
||
/// The cause was a bad transcription, not a bad seat. 1.12's pet bars are 70×8 at `(47,-22)` and
|
||
/// `(47,-29)` — a **7 px pitch** for strings taller than that — so the reference does not centre the
|
||
/// second one: it drops it clear, to `(82,-38)`, just under a bar that ends at −37. 1143 replaced
|
||
/// those literals with "anchor each string to its own bar's centre", on a comparison that read the
|
||
/// reference's TEXT offsets against our BAR offsets and concluded the geometry differed. It does
|
||
/// not: our bars are byte-identical to the reference's, so its literals apply unchanged.
|
||
///
|
||
/// This asserts the PROPERTY, not the numbers, so it survives a future nudge (1145 moved the
|
||
/// player's pair the day before this). Text metrics come from the harness's own measurer — a
|
||
/// FontString with a single CENTER anchor and no measured size cannot pin either edge, so it falls
|
||
/// back to its owner's rect and every string would appear to sit in the same box.
|
||
#[test]
|
||
fn no_two_numeral_strings_overlap_on_any_frame() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_screen_size(1024.0, 768.0);
|
||
load_unit_frames(&s);
|
||
|
||
let alive = |health: u32, power: u32| UnitState {
|
||
exists: true,
|
||
name: Some("Onehunter".into()),
|
||
health,
|
||
max_health: health,
|
||
level: 40,
|
||
power_type: 0,
|
||
power,
|
||
max_power: power,
|
||
dead: false,
|
||
reaction: 4,
|
||
..UnitState::default()
|
||
};
|
||
s.set_unit("player", Some(alive(4122, 3300)));
|
||
s.set_unit("target", Some(alive(4122, 3300)));
|
||
s.set_unit("pet", Some(alive(256, 100)));
|
||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
|
||
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
|
||
s.register_cvars([("statusBarText", "0")]);
|
||
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
|
||
.unwrap();
|
||
s.tick(0.0);
|
||
|
||
// The host's half of the measure round-trip, at the numerals' real font size: NumberFontNormal
|
||
// is ~14 px tall, and a digit runs ~7 px wide. The round-trip answers a frame late, so settle.
|
||
for _ in 0..4 {
|
||
let answers: Vec<(u32, f32, f32, u64)> = s
|
||
.fontstrings_needing_measure()
|
||
.into_iter()
|
||
.map(|r| (r.id, r.text.chars().count() as f32 * 7.0, 14.0, r.key))
|
||
.collect();
|
||
s.set_measured_text_unwrapped(&answers);
|
||
s.tick(0.05);
|
||
s.resolve();
|
||
}
|
||
|
||
// The pin is the SEPARATION between the two numeral seats, which is the thing that broke and
|
||
// the thing the reference authored deliberately. Not "the line boxes must not overlap": at a
|
||
// 14 px NumberFontNormal the reference's own player seats are 12 px apart, so the boxes DO
|
||
// overlap there while the ink (a digit's ~10 px cap height, centred) does not. Only a seat
|
||
// pitch well under the font is a real collision — the pet's was 7 px.
|
||
for (frame, upper, lower, want) in [
|
||
(
|
||
"player",
|
||
"PlayerFrameTextureFrameHealthBarText",
|
||
"PlayerFrameTextureFramePowerBarText",
|
||
12.0,
|
||
),
|
||
(
|
||
"pet",
|
||
"PetFrameTextureFrameHealthBarText",
|
||
"PetFrameTextureFrameManaBarText",
|
||
11.0,
|
||
),
|
||
] {
|
||
let mid = |s: &UiScript, n: &str| -> f64 {
|
||
s.eval::<f64>(&format!("return ({n}:GetTop() + {n}:GetBottom()) / 2"))
|
||
.unwrap()
|
||
};
|
||
let apart = mid(&s, upper) - mid(&s, lower);
|
||
assert!(
|
||
(apart - want).abs() < 0.51,
|
||
"{frame}: the numerals sit {apart:.1} px apart, the reference authors {want:.0}"
|
||
);
|
||
}
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// **`UnitFrame_OnEnter`/`OnLeave` exist under the REFERENCE's names, `this`-shaped, so an addon
|
||
/// that hooks them is reached.**
|
||
///
|
||
/// `TipBuddy.lua:2770-2773` is the exact idiom, and it is the 1.12 customisation model in general:
|
||
///
|
||
/// ```lua
|
||
/// originalUnitFrame_OnEnter = UnitFrame_OnEnter
|
||
/// function UnitFrame_OnEnter() originalUnitFrame_OnEnter() … end
|
||
/// ```
|
||
///
|
||
/// Against a missing global that captured nil and raised on the first hover. Note these are
|
||
/// ADAPTERS, not renames: the reference's contract takes no arguments and reads `this`, ours takes
|
||
/// the frame explicitly, so a rename would have reached our body with a nil frame — reachable but
|
||
/// broken, which is worse than inert.
|
||
///
|
||
/// The same class as the Bagnon bag bug, and equally invisible to the corpus survey: it loads
|
||
/// addons and fires events, but never hovers anything.
|
||
#[test]
|
||
fn the_unit_frame_hover_hooks_carry_the_references_names() {
|
||
let s = UiScript::new().unwrap();
|
||
load_unit_frames(&s);
|
||
|
||
assert!(
|
||
s.eval::<bool>(
|
||
"return type(UnitFrame_OnEnter) == 'function' \
|
||
and type(UnitFrame_OnLeave) == 'function'"
|
||
)
|
||
.unwrap(),
|
||
"the reference's names must exist for an addon to capture"
|
||
);
|
||
|
||
// TipBuddy's idiom, run for real against a live unit frame.
|
||
s.run(
|
||
r#"
|
||
HOVERS = 0
|
||
local original = UnitFrame_OnEnter
|
||
function UnitFrame_OnEnter()
|
||
HOVERS = HOVERS + 1
|
||
original()
|
||
end
|
||
this = PlayerFrame
|
||
UnitFrame_OnEnter()
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
|
||
assert_eq!(
|
||
s.eval::<i64>("return HOVERS").unwrap(),
|
||
1,
|
||
"the addon's replacement must run"
|
||
);
|
||
assert!(
|
||
s.errors().is_empty(),
|
||
"and calling through to the original must not raise: {:?}",
|
||
s.errors()
|
||
);
|
||
}
|
||
|
||
/// **`SetRaidTargetIconTexture` lands each mark on its own cell of the 4×4 sheet.**
|
||
///
|
||
/// The helper an addon uses to draw a raid mark it got from `GetRaidTargetIndex` — three corpus
|
||
/// addons in two codebases (CustomNameplates; Optional/oRA2's shared `MainTank.lua:1216` and
|
||
/// `PlayerTarget.lua:1472`). It owns no frame and reads no unit: the texture is the caller's.
|
||
///
|
||
/// The coordinates are the point, so all eight are checked rather than one. Star is the top-left
|
||
/// cell and skull the bottom-right of the first two rows — which is also what pins the reference's
|
||
/// own `/ ROWS` row derivation as harmless here: with 4 rows and 4 columns the wrap lands where the
|
||
/// sheet's mark order says it should.
|
||
#[test]
|
||
fn the_raid_mark_helper_maps_each_index_to_its_cell() {
|
||
let s = UiScript::new().unwrap();
|
||
load_unit_frames(&s);
|
||
s.run(r#"RTMark = UIParent:CreateTexture("RTMark", "OVERLAY")"#)
|
||
.unwrap();
|
||
|
||
// (index, left, right, top, bottom) — 0.25 per cell, four across then down a row.
|
||
let cells = [
|
||
(1, 0.00, 0.25, 0.00, 0.25), // star
|
||
(2, 0.25, 0.50, 0.00, 0.25), // circle
|
||
(3, 0.50, 0.75, 0.00, 0.25), // diamond
|
||
(4, 0.75, 1.00, 0.00, 0.25), // triangle
|
||
(5, 0.00, 0.25, 0.25, 0.50), // moon — the wrap onto row 2
|
||
(6, 0.25, 0.50, 0.25, 0.50), // square
|
||
(7, 0.50, 0.75, 0.25, 0.50), // cross
|
||
(8, 0.75, 1.00, 0.25, 0.50), // skull
|
||
];
|
||
for (i, l, r, t, b) in cells {
|
||
s.run(&format!("SetRaidTargetIconTexture(RTMark, {i})"))
|
||
.unwrap();
|
||
let got: (f64, f64, f64, f64) = s.eval("return RTMark:GetTexCoord()").unwrap();
|
||
assert_eq!(
|
||
got,
|
||
(l, r, t, b),
|
||
"mark {i} must sample the cell at ({l}, {r}, {t}, {b})"
|
||
);
|
||
}
|
||
assert!(s.errors().is_empty(), "no errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// B317 — the player's OWN frame wears the leader and master-looter icons. Every other frame that
|
||
/// can wear them has since 0434 phase 2; the player frame predated the party wire and listed them
|
||
/// OUT, so the one person who could not see who was leading was the leader.
|
||
///
|
||
/// The two predicates differ, and the asymmetry is the reference's (`PlayerFrame_UpdatePartyLeader`):
|
||
/// the leader icon asks `IsPartyLeader()`, the master icon asks whether `GetLootMethod`'s party
|
||
/// index is `0` — the player's own seat on that scale — *and* that we are grouped at all.
|
||
#[test]
|
||
fn the_player_frame_wears_the_leader_and_master_looter_icons() {
|
||
use benilla_ui::script::{PartyMemberInfo, PartyState};
|
||
|
||
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("Frostshake".into()),
|
||
health: 100,
|
||
max_health: 100,
|
||
level: 60,
|
||
..UnitState::default()
|
||
}),
|
||
);
|
||
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
|
||
|
||
let shown = |s: &UiScript, region: &str| -> bool {
|
||
s.eval::<bool>(&format!("return {region}:IsVisible()"))
|
||
.unwrap()
|
||
};
|
||
let leader = "PlayerFrameTextureFrameLeaderIcon";
|
||
let master = "PlayerFrameTextureFrameMasterIcon";
|
||
|
||
// Solo: neither. A solo player "leads" nothing — `IsPartyLeader()` is nil without a group.
|
||
assert!(!shown(&s, leader), "solo: no leader icon");
|
||
assert!(!shown(&s, master), "solo: no master-looter icon");
|
||
|
||
let party = |leader_index: u32, master_looter: Option<u32>, method: &str| PartyState {
|
||
members: vec![PartyMemberInfo {
|
||
name: "Thalyn".into(),
|
||
guid: 0x7A17,
|
||
}],
|
||
leader_index,
|
||
raid: Vec::new(),
|
||
loot_method: method.into(),
|
||
master_looter,
|
||
loot_threshold: 2,
|
||
};
|
||
|
||
// Grouped, we lead, group loot: leader icon only.
|
||
s.set_party(party(0, None, "group"));
|
||
s.fire_event("PARTY_LEADER_CHANGED", vec![]);
|
||
assert!(shown(&s, leader), "we lead: the leader icon shows");
|
||
assert!(!shown(&s, master), "group loot: no master-looter icon");
|
||
|
||
// Master loot, and we are the master (index 0 — the player's own seat).
|
||
s.set_party(party(0, Some(0), "master"));
|
||
s.fire_event("PARTY_LOOT_METHOD_CHANGED", vec![]);
|
||
assert!(shown(&s, master), "we are master looter: the icon shows");
|
||
|
||
// The master looter is party1 instead: the icon is theirs, not ours.
|
||
s.set_party(party(0, Some(1), "master"));
|
||
s.fire_event("PARTY_LOOT_METHOD_CHANGED", vec![]);
|
||
assert!(!shown(&s, master), "somebody else masters: our icon hides");
|
||
assert!(shown(&s, leader), "…and we still lead");
|
||
|
||
// Leadership passes to party1: the crown goes with it.
|
||
s.set_party(party(1, Some(1), "master"));
|
||
s.fire_event("PARTY_LEADER_CHANGED", vec![]);
|
||
assert!(!shown(&s, leader), "we no longer lead: the crown hides");
|
||
|
||
// Leaving the group clears both, through PARTY_MEMBERS_CHANGED alone.
|
||
s.set_party(PartyState::default());
|
||
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
|
||
assert!(
|
||
!shown(&s, leader) && !shown(&s, master),
|
||
"ungrouped: both hide"
|
||
);
|
||
|
||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||
}
|