Files
benilla-octo/crates/benilla-ui/src/script/unit/tests.rs
T
SamandClaude Fable 5 b5bc569156 the addon arc: the AddOns folder opens
- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect
- both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list
- the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand
- the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8

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

697 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `Unit*` binding tests (the parent module is the unit under test).
use crate::script::{UiScript, UnitState};
fn player() -> UnitState {
UnitState {
exists: true,
name: Some("Benilla".into()),
health: 72,
max_health: 100,
level: 12,
power_type: 1, // rage
power: 35,
max_power: 100,
dead: false,
reaction: 0,
race: Some("Night Elf".into()),
race_file: Some("NightElf".into()),
class: Some("Warrior".into()),
class_file: Some("WARRIOR".into()),
sex: 3, // female
..Default::default()
}
}
#[test]
fn unit_reaction_reports_the_scale_value_or_nil() {
let mut s = UiScript::new().unwrap();
// Neutral target (UnitReaction 4) → the integer; the name-plate palette indexes it.
s.set_unit(
"target",
Some(UnitState {
exists: true,
reaction: 4,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitReaction("target", "player")"#)
.unwrap(),
4
);
// reaction 0 (unresolved) reports as nil — the API's "can't tell".
s.set_unit(
"player",
Some(UnitState {
exists: true,
reaction: 0,
..Default::default()
}),
);
assert!(s
.eval::<bool>(r#"return UnitReaction("player", "target") == nil"#)
.unwrap());
}
/// The target plate's faction decision (TargetFrame_CheckFaction, as `UnitFrame_Update`
/// composes it): a player-controlled target picks red (mutually hostile) or blue (else) and
/// NEVER the reaction green; only an NPC reads the reaction swatch. This pins the predicate
/// composition the plate branch depends on — a friendly *player* must resolve to blue, which is
/// exactly the green-vs-blue bug the branch fixes.
#[test]
fn the_name_plate_faction_branch_picks_player_red_blue_over_the_reaction_swatch() {
let mut s = UiScript::new().unwrap();
// The plate's decision, verbatim from the XML Update (a chunk whose if-branches each return).
let plate = r#"
local u = "target"
if UnitIsPlayer(u) then
if UnitIsEnemy(u, "player") then return "red" else return "blue" end
else
return UnitReaction(u, "player") and "reaction" or "reaction-blue"
end
"#;
let decide = |s: &mut UiScript| s.eval::<String>(plate).unwrap();
// A friendly PLAYER (reaction 5) → blue, not the reaction green.
s.set_unit(
"target",
Some(UnitState {
exists: true,
is_player: true,
reaction: 5,
..Default::default()
}),
);
assert_eq!(decide(&mut s), "blue");
// A hostile PLAYER (reaction 2) → red.
s.set_unit(
"target",
Some(UnitState {
exists: true,
is_player: true,
reaction: 2,
..Default::default()
}),
);
assert_eq!(decide(&mut s), "red");
// A friendly NPC (same reaction 5, not a player) → the reaction swatch (green), unchanged.
s.set_unit(
"target",
Some(UnitState {
exists: true,
is_player: false,
reaction: 5,
..Default::default()
}),
);
assert_eq!(decide(&mut s), "reaction");
// UnitIsPlayer itself: 1 for a player, nil otherwise.
assert!(s
.eval::<bool>(r#"return UnitIsPlayer("target") == nil"#)
.unwrap());
s.set_unit(
"target",
Some(UnitState {
exists: true,
is_player: true,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsPlayer("target")"#).unwrap(),
1
);
}
#[test]
fn unit_level_reads_minus_one_when_the_level_cant_be_told() {
use crate::script::PlayerReqState;
let mut s = UiScript::new().unwrap();
s.set_player_req_state(PlayerReqState {
level: 3,
..Default::default()
});
// A hostile 10+ levels up (reaction 2, level 13 vs player 3) → −1: the target frame's
// skull branch (TargetFrame_CheckLevel's `level <= 0`).
s.set_unit(
"target",
Some(UnitState {
exists: true,
level: 13,
reaction: 2,
..Default::default()
}),
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), -1);
// One level short of the band (12 vs 3) → the raw number.
s.set_unit(
"target",
Some(UnitState {
exists: true,
level: 12,
reaction: 2,
..Default::default()
}),
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 12);
// The same 10-up gap on a NEUTRAL (reaction 4) tells its level — the gate is hostile-only.
s.set_unit(
"target",
Some(UnitState {
exists: true,
level: 13,
reaction: 4,
..Default::default()
}),
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 13);
// A world boss (creature rank 3) hides its level regardless of the gap.
s.set_unit(
"target",
Some(UnitState {
exists: true,
level: 5,
reaction: 4,
rank: 3,
..Default::default()
}),
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), -1);
// A PLAYER never reads −1 (rank/reaction notwithstanding).
s.set_unit(
"target",
Some(UnitState {
exists: true,
level: 60,
reaction: 2,
is_player: true,
..Default::default()
}),
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 60);
// A raw level 0 (unstreamed) returns VERBATIM — never −1 (§5: `0x517fc0` pushes the
// raw field; only the boss/hostile legs substitute). Same skull outcome via `<= 0`.
s.set_unit(
"target",
Some(UnitState {
exists: true,
level: 0,
reaction: 2,
..Default::default()
}),
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 0);
}
#[test]
fn corpse_can_attack_and_green_range_bindings() {
use crate::script::PlayerReqState;
let mut s = UiScript::new().unwrap();
// UnitIsCorpse: a pure TYPEID_CORPSE object check (§5) — a DEAD unit is NOT a corpse…
s.set_unit(
"target",
Some(UnitState {
exists: true,
dead: true,
..Default::default()
}),
);
assert!(s
.eval::<bool>(r#"return UnitIsCorpse("target") == nil"#)
.unwrap());
// …only a resolved corpse world object is.
s.set_unit(
"target",
Some(UnitState {
exists: true,
corpse_object: true,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsCorpse("target")"#).unwrap(),
1
);
// UnitCanAttack reads the app-fed 0x606980 verdict off the non-player token, both
// argument orders; false → nil.
assert!(s
.eval::<bool>(r#"return UnitCanAttack("player", "target") == nil"#)
.unwrap());
s.set_unit(
"target",
Some(UnitState {
exists: true,
can_attack: true,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitCanAttack("player", "target")"#)
.unwrap(),
1
);
assert_eq!(
s.eval::<i64>(r#"return UnitCanAttack("target", "player")"#)
.unwrap(),
1
);
assert!(s
.eval::<bool>(r#"return UnitIsCorpse("target") == nil"#)
.unwrap());
// GetQuestGreenRange: the grey band for the player's level — 4 at level 3, 9 at 46,
// 12 clamped past the table (the byte-verified GREY_BAND).
for (pl, want) in [(3u32, 4i64), (46, 9), (120, 12)] {
s.set_player_req_state(PlayerReqState {
level: pl,
..Default::default()
});
assert_eq!(
s.eval::<i64>("return GetQuestGreenRange()").unwrap(),
want,
"green range at level {pl}"
);
}
}
#[test]
fn target_unit_queues_the_token_for_the_app_to_resolve() {
let mut s = UiScript::new().unwrap();
// Nothing queued until a call lands.
assert!(s.take_target_requests().is_empty());
// Each call queues its raw token, in order; a nil token is ignored.
s.eval::<()>(r#"TargetUnit("player")"#).unwrap();
s.eval::<()>(r#"TargetUnit("target")"#).unwrap();
s.eval::<()>(r#"TargetUnit(nil)"#).unwrap();
assert_eq!(s.take_target_requests(), vec!["player", "target"]);
// The drain is a take — a second read is empty.
assert!(s.take_target_requests().is_empty());
}
#[test]
fn set_unit_is_read_by_the_bindings() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Benilla"
);
assert_eq!(s.eval::<i64>(r#"return UnitHealth("player")"#).unwrap(), 72);
assert_eq!(
s.eval::<i64>(r#"return UnitHealthMax("player")"#).unwrap(),
100
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("player")"#).unwrap(), 12);
assert!(!s.eval::<bool>(r#"return UnitIsDead("player")"#).unwrap());
}
#[test]
fn absent_token_reports_not_existing_with_zero_numbers() {
let s = UiScript::new().unwrap();
assert!(!s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
assert_eq!(
s.eval::<i64>(r#"return UnitHealthMax("target")"#).unwrap(),
0
);
assert_eq!(s.eval::<i64>(r#"return UnitLevel("target")"#).unwrap(), 0);
assert!(s
.eval::<bool>(r#"return UnitName("target") == nil"#)
.unwrap());
assert!(!s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
}
#[test]
fn a_dead_unit_reports_dead_and_zero_health() {
let mut s = UiScript::new().unwrap();
s.set_unit(
"target",
Some(UnitState {
exists: true,
name: None,
health: 0,
max_health: 3200,
level: 30,
dead: true,
..Default::default()
}),
);
assert!(s.eval::<bool>(r#"return UnitExists("target")"#).unwrap());
assert!(s.eval::<bool>(r#"return UnitIsDead("target")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("target")"#).unwrap(), 0);
// Name unknown (no name-query yet) → nil, the absent-name shape.
assert!(s
.eval::<bool>(r#"return UnitName("target") == nil"#)
.unwrap());
}
#[test]
fn power_bindings_read_the_active_type() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player())); // rage 35/100
assert_eq!(
s.eval::<(i64, String)>(r#"return UnitPowerType("player")"#)
.unwrap(),
(1, "RAGE".into())
);
assert_eq!(s.eval::<i64>(r#"return UnitMana("player")"#).unwrap(), 35);
assert_eq!(
s.eval::<i64>(r#"return UnitManaMax("player")"#).unwrap(),
100
);
// **The 1.12 verbs take one argument and report the ACTIVE power**, whatever it is — this
// unit is a rage user and `UnitMana` reads 35 rage, which looks wrong and is exactly right
// (`UnitFrame.lua`: `local currValue = UnitMana(unit)` for every power type). Asking about a
// specific type is `UnitPowerType(unit)` first; there is no second argument to pass, and the
// Era pair that had one is gone (decision 1190's beyond-1.12 list, 1188 phase 5).
assert!(
s.eval::<bool>("return UnitPower == nil and UnitPowerMax == nil")
.unwrap(),
"the Era spellings must not linger beside the 1.12 ones"
);
// An absent unit: type reads as mana, values 0.
assert_eq!(
s.eval::<(i64, String)>(r#"return UnitPowerType("target")"#)
.unwrap(),
(0, "MANA".into())
);
}
#[test]
fn get_money_reads_the_pushed_purse() {
let mut s = UiScript::new().unwrap();
// No feed yet: 0 copper.
assert_eq!(s.eval::<i64>("return GetMoney()").unwrap(), 0);
s.set_money(123_456);
assert_eq!(s.eval::<i64>("return GetMoney()").unwrap(), 123_456);
}
#[test]
fn unit_xp_reads_the_player_globals_only_for_the_player_token() {
let mut s = UiScript::new().unwrap();
// No feed yet: 0/0.
assert_eq!(s.eval::<i64>(r#"return UnitXP("player")"#).unwrap(), 0);
assert_eq!(s.eval::<i64>(r#"return UnitXPMax("player")"#).unwrap(), 0);
s.set_player_xp(4200, 6000);
assert_eq!(s.eval::<i64>(r#"return UnitXP("player")"#).unwrap(), 4200);
assert_eq!(
s.eval::<i64>(r#"return UnitXPMax("player")"#).unwrap(),
6000
);
// Any non-player token reports 0 (no creature/other unit exposes XP — the live shape).
assert_eq!(s.eval::<i64>(r#"return UnitXP("target")"#).unwrap(), 0);
assert_eq!(s.eval::<i64>(r#"return UnitXPMax("target")"#).unwrap(), 0);
}
#[test]
fn set_unit_none_removes_the_token() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert!(s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
s.set_unit("player", None);
assert!(!s.eval::<bool>(r#"return UnitExists("player")"#).unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitHealth("player")"#).unwrap(), 0);
}
#[test]
fn party_frame_predicates_report_1_or_nil() {
let mut s = UiScript::new().unwrap();
s.set_unit(
"party1",
Some(UnitState {
exists: true,
is_connected: true,
is_afk: true,
is_dnd: false,
pvp: true,
is_pvp_ffa: false,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsConnected("party1")"#)
.unwrap(),
1
);
assert_eq!(s.eval::<i64>(r#"return UnitIsAFK("party1")"#).unwrap(), 1);
assert!(s
.eval::<bool>(r#"return UnitIsDND("party1") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitIsPVP("party1")"#).unwrap(), 1);
assert!(s
.eval::<bool>(r#"return UnitIsPVPFreeForAll("party1") == nil"#)
.unwrap());
// Flip every flag: the false/true cases aren't just "always true".
s.set_unit(
"party1",
Some(UnitState {
exists: true,
is_connected: false,
is_afk: false,
is_dnd: true,
pvp: false,
is_pvp_ffa: true,
..Default::default()
}),
);
assert!(s
.eval::<bool>(r#"return UnitIsConnected("party1") == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitIsAFK("party1") == nil"#)
.unwrap());
assert_eq!(s.eval::<i64>(r#"return UnitIsDND("party1")"#).unwrap(), 1);
assert!(s
.eval::<bool>(r#"return UnitIsPVP("party1") == nil"#)
.unwrap());
assert_eq!(
s.eval::<i64>(r#"return UnitIsPVPFreeForAll("party1")"#)
.unwrap(),
1
);
// An absent token reports nil regardless of any flag's zero value.
assert!(s
.eval::<bool>(r#"return UnitIsConnected("party2") == nil"#)
.unwrap());
}
#[test]
fn unit_race_class_sex_report_the_snapshot_or_the_absent_shape() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
assert_eq!(
s.eval::<(String, String)>(r#"return UnitRace("player")"#)
.unwrap(),
("Night Elf".into(), "NightElf".into())
);
assert_eq!(
s.eval::<(String, String)>(r#"return UnitClass("player")"#)
.unwrap(),
("Warrior".into(), "WARRIOR".into())
);
assert_eq!(s.eval::<i64>(r#"return UnitSex("player")"#).unwrap(), 3);
// An absent token: nil, nil / nil — the live API's absent-unit shape.
assert!(s
.eval::<bool>(r#"local a, b = UnitRace("target") return a == nil and b == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"local a, b = UnitClass("target") return a == nil and b == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitSex("target") == nil"#)
.unwrap());
// A snapshot whose race/class haven't resolved yet (feed pending): same nils.
s.set_unit(
"target",
Some(UnitState {
exists: true,
..Default::default()
}),
);
assert!(s
.eval::<bool>(r#"local a, b = UnitRace("target") return a == nil and b == nil"#)
.unwrap());
assert!(s
.eval::<bool>(r#"return UnitSex("target") == nil"#)
.unwrap());
}
/// `UnitFactionGroup` returns the (english, localized) pair the PvP-icon law reads, and `nil, nil`
/// for a unit with no side — the state the reference's `if ( factionGroup and … )` gate exists
/// for (decision 0646 §1). `TogglePVP` queues one toggle per call.
#[test]
fn faction_group_pair_and_the_pvp_toggle() {
let mut s = UiScript::new().unwrap();
s.set_unit(
"target",
Some(UnitState {
exists: true,
faction_group: Some("Horde".into()),
..Default::default()
}),
);
let (english, localized) = s
.eval::<(String, String)>(r#"return UnitFactionGroup("target")"#)
.unwrap();
assert_eq!((english.as_str(), localized.as_str()), ("Horde", "Horde"));
// No side (a Monster/neutral template, or a unit whose template hasn't streamed).
s.set_unit(
"target",
Some(UnitState {
exists: true,
faction_group: None,
..Default::default()
}),
);
assert!(s
.eval::<bool>(r#"local a, b = UnitFactionGroup("target") return a == nil and b == nil"#)
.unwrap());
// An absent unit answers the same way.
assert!(s
.eval::<bool>(r#"local a = UnitFactionGroup("focus") return a == nil"#)
.unwrap());
assert_eq!(s.take_pvp_toggles(), 0, "nothing queued yet");
s.run("TogglePVP() TogglePVP()").unwrap();
assert_eq!(
s.take_pvp_toggles(),
2,
"one send per call, never collapsed"
);
assert_eq!(s.take_pvp_toggles(), 0, "the drain empties the queue");
}
/// `UnitClassification` (decision 0782, byte-verified table `0x850424`): five words indexed by the
/// gated rank, a STRING for every input — including an absent token, which the binary answers
/// `"normal"` for (its unresolved path loads index 0), never nil.
#[test]
fn unit_classification_reads_the_five_word_table() {
let mut s = UiScript::new().unwrap();
for (rank, word) in [
(0, "normal"),
(1, "elite"),
(2, "rareelite"),
(3, "worldboss"),
(4, "rare"),
] {
s.set_unit(
"target",
Some(UnitState {
exists: true,
rank,
..UnitState::default()
}),
);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
word,
"rank {rank}"
);
}
// No snapshot at all, and a missing argument: still "normal", never nil. The target frame's
// border branch compares this against string literals, so a nil here would be a Lua error the
// reference cannot produce.
s.set_unit("target", None);
assert_eq!(
s.eval::<String>(r#"return UnitClassification("target")"#)
.unwrap(),
"normal",
"an absent unit is normal, not nil"
);
assert_eq!(
s.eval::<String>(r#"return UnitClassification()"#).unwrap(),
"normal",
"a missing token is normal, not nil"
);
}
// ── `UnitAffectingCombat` and `TargetByName` ────────────────────────────────────────────────────
/// `UnitAffectingCombat` → the number 1 / nil, and **one arm serves both "not in combat" and "no
/// such unit"** (`0x517e48` and `0x517e5c` jump to the same `0x517e73`). That indistinguishability
/// is the behaviour, not a shortcut: an addon must not be able to probe existence with this verb.
#[test]
fn unit_affecting_combat_is_one_or_nil_and_hides_the_missing_unit() {
let mut s = UiScript::new().unwrap();
let mut hot = player();
hot.in_combat = true;
s.set_unit("player", Some(hot));
s.set_unit("target", Some(player())); // exists, peaceful
assert_eq!(
s.eval::<i64>(r#"return UnitAffectingCombat("player")"#)
.unwrap(),
1,
"the number 1, never a boolean"
);
assert!(s
.eval::<bool>(r#"return UnitAffectingCombat("target") == nil"#)
.unwrap());
assert!(
s.eval::<bool>(r#"return UnitAffectingCombat("party3") == nil"#)
.unwrap(),
"an unresolvable token is the SAME nil a peaceful unit gives"
);
// A number is accepted and stringified (`0x6f3510` is number-OR-string): it resolves the
// token "5", finds nothing, and answers nil — it does not raise.
assert!(s
.eval::<bool>("return UnitAffectingCombat(5) == nil")
.unwrap());
}
/// A missing or wrong-typed argument **raises** — `0x6f4940` is `luaL_error` and does not return,
/// so the caller's statement is abandoned rather than continuing with a nil.
#[test]
fn unit_affecting_combat_raises_on_a_bad_argument() {
let s = UiScript::new().unwrap();
for call in ["UnitAffectingCombat()", "UnitAffectingCombat({})"] {
let err = s
.eval::<mlua::Value>(&format!("return {call}"))
.unwrap_err();
assert!(
format!("{err}").contains(r#"Usage: UnitAffectingCombat("unit")"#),
"{call} must raise the usage line, got {err}"
);
}
}
/// `TargetByName(name [, exactMatch])` queues the name **and the second argument** — the flag the
/// slash command has no way to supply, which turns the resolver's longest-common-prefix tier off.
#[test]
fn target_by_name_queues_the_name_and_the_exact_flag() {
let mut s = UiScript::new().unwrap();
assert!(s.take_target_by_name_requests().is_empty());
s.run(r#"TargetByName("Rag")"#).unwrap();
s.run(r#"TargetByName("Ragnaros", 1)"#).unwrap();
s.run(r#"TargetByName("Ragnaros", 0)"#).unwrap();
s.run(r#"TargetByName("Ragnaros", true)"#).unwrap();
s.run("TargetByName(5)").unwrap(); // a number stringifies, as `0x6f3690` does
assert_eq!(
s.take_target_by_name_requests(),
vec![
("Rag".to_string(), false),
("Ragnaros".to_string(), true),
("Ragnaros".to_string(), false),
("Ragnaros".to_string(), true),
("5".to_string(), false),
]
);
assert!(s.take_target_by_name_requests().is_empty());
// A missing/wrong-typed name raises (`0x489d69 call 0x6f3510` → `0x489de1`).
let err = s.eval::<mlua::Value>("return TargetByName()").unwrap_err();
assert!(
format!("{err}").contains(r#"Usage: TargetByName("name")"#),
"got {err}"
);
}