Files
benilla-octo/crates/benilla-ui/src/script/unit/tests.rs
T
SamandClaude Fable 5.1 ed524ab184 druids get their relic slot, and the loot window is the reference's own
- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference
- the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil
- the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them
- the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:00:40 +07:00

1347 lines
49 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::{PartyState, 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() {
use crate::script::SelectionRequest;
let mut s = UiScript::new().unwrap();
// Nothing queued until a call lands.
assert!(s.take_selection_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_selection_requests(),
vec![
SelectionRequest::Unit("player".into()),
SelectionRequest::Unit("target".into()),
]
);
// The drain is a take — a second read is empty.
assert!(s.take_selection_requests().is_empty());
}
/// The three verbs share ONE queue, and the queue keeps their call order — which is the whole
/// reason it is one queue (`0x489a40` is one function for all of them, and a macro can observe
/// the order). A nil `AssistUnit` argument is dropped like `TargetUnit`'s, and
/// `TargetLastEnemy()` names no unit at all.
#[test]
fn the_selection_queue_carries_all_three_verbs_in_call_order() {
use crate::script::SelectionRequest;
let mut s = UiScript::new().unwrap();
s.eval::<()>(r#"TargetUnit("party1")"#).unwrap();
s.eval::<()>(r#"AssistUnit("target")"#).unwrap();
s.eval::<()>(r#"AssistUnit(nil)"#).unwrap();
s.eval::<()>("TargetLastEnemy()").unwrap();
assert_eq!(
s.take_selection_requests(),
vec![
SelectionRequest::Unit("party1".into()),
SelectionRequest::Assist("target".into()),
SelectionRequest::LastEnemy,
]
);
assert!(s.take_selection_requests().is_empty());
}
/// `TargetNearestFriend`'s reverse flag, read exactly as `0x6f1c10(idx 1, default 0)` does:
/// absent or nil is forward, and 1.12's own `Bindings.xml` note — *"1 (or \"true\") means
/// reverse!"* — is why a boolean and a number both count. A numeric `0` is forward.
#[test]
fn target_nearest_friend_queues_its_reverse_flag() {
let mut s = UiScript::new().unwrap();
assert!(s.take_target_nearest_friend_requests().is_empty());
s.eval::<()>("TargetNearestFriend()").unwrap();
s.eval::<()>("TargetNearestFriend(1)").unwrap();
s.eval::<()>("TargetNearestFriend(true)").unwrap();
s.eval::<()>("TargetNearestFriend(0)").unwrap();
s.eval::<()>("TargetNearestFriend(nil)").unwrap();
assert_eq!(
s.take_target_nearest_friend_requests(),
vec![false, true, true, false, false]
);
assert!(s.take_target_nearest_friend_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. (This read `"focus"` until the unrecognised-token raise
// landed — a token 1.12 does not have at all, so it now raises rather than being "absent". The
// question the line means to ask needs a RECOGNISED token that names nothing.)
assert!(s
.eval::<bool>(r#"local a = UnitFactionGroup("party4") 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 — and that is exactly why it RAISES.** The gate
// `0x6f3510` is number-OR-string, so `5` is coerced via `%.14g` to the token `"5"`; `"5"` then
// matches none of the resolver's nine prefixes and falls into
// `luaL_error("Unknown unit name: %s")`. This assertion read `== nil` and "it does not raise"
// until wow-re's own §5 cross-check refuted the no-error-path claim it rested on
// (`raid-roster-bindings.md` §1) — the acceptance was right, the conclusion was not.
assert!(
s.run("UnitAffectingCombat(5)").is_err(),
"a number is coerced to a token that matches nothing, so it raises"
);
}
/// 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}"
);
}
/// **`UnitIsCharmed` — the number `1`, the nil, and the asymmetry.**
///
/// `KLHThreatMeter\Code\KTM_My.lua:533` is the corpus line blocked on it; seven other addons name
/// it. The binding (`0x516cf0`) reads `UNIT_FIELD_CHARMEDBY != 0` — a 64-bit non-zero test on
/// fields 10/11, **not** a `UNIT_FIELD_FLAGS` bit, which is what a boolean-shaped unit question
/// invites you to assume.
#[test]
fn unit_is_charmed_answers_one_or_nil_and_only_for_the_charmed_side() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
let mut mc = player();
mc.charmed = true;
s.set_unit("target", Some(mc));
// A hit is the NUMBER 1 — `lua_pushnumber` writes tag 3; tag 1 (boolean) is never written on
// either arm, so `== true` in an addon must NOT match.
assert_eq!(
s.eval::<i64>(r#"return UnitIsCharmed("target")"#).unwrap(),
1
);
assert_eq!(
s.eval::<String>(r#"return type(UnitIsCharmed("target"))"#)
.unwrap(),
"number",
"the reference pushes a number, not a boolean"
);
assert_eq!(
s.eval::<i64>(r#"return select('#', UnitIsCharmed("target"))"#)
.unwrap(),
1,
"exactly one value on the hit path"
);
// An uncharmed unit and an absent one both answer nil — and nil, not `false` and not `0`.
for token in ["player", "party1"] {
assert!(
s.eval::<Option<i64>>(&format!(r#"return UnitIsCharmed("{token}")"#))
.unwrap()
.is_none(),
"{token} is not charmed, so the answer is nil"
);
assert_eq!(
s.eval::<String>(&format!(r#"return type(UnitIsCharmed("{token}"))"#))
.unwrap(),
"nil",
"…nil rather than false or 0"
);
}
// **The asymmetry.** The field is "who charms me", never "whom I charm", so the charmer reads
// nil while its victim reads 1 — here the player is doing the charming and is not charmed.
assert!(
s.eval::<Option<i64>>(r#"return UnitIsCharmed("player")"#)
.unwrap()
.is_none(),
"a charmER is not charmed; UNIT_FIELD_CHARM is never read by this binding"
);
}
/// **Unit tokens fold case, because the client's resolver does.**
///
/// `0x515970` compares every one of its literals with `SStrCmpI` → `_strnicmp`, whose fold is
/// `'A'..'Z' += 0x20`; not one of its ten compares reaches the case-sensitive sibling (wow-re
/// `system/ui/scratch/unit-token-grammar.md`, §5 trio). So this is a NARROWING fix — the real
/// client resolves these and we did not — rather than the superset 1189 had to take back out.
///
/// `Accountant.lua:107` is the corpus line that paid for it: `UnitFactionGroup("Player")`, capital
/// P, whose nil made `Accountant_SaveData[realm][faction]` a nil table index at l.192 and ended the
/// addon's session. Roughly ten addons pass `Player`, `PLAYER`, `Target`, `Pet`, `PARTY`, `NPC`,
/// `Mouseover` or `"Raid"..i`.
#[test]
fn a_unit_token_resolves_whatever_its_case() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
let mut tgt = player();
tgt.name = Some("Hogger".into());
s.set_unit("target", Some(tgt));
// Every spelling names the same unit — the getter, the number and the predicate alike.
for spelling in ["player", "Player", "PLAYER", "PlAyEr"] {
assert_eq!(
s.eval::<String>(&format!(r#"return UnitName("{spelling}")"#))
.unwrap(),
"Benilla",
"UnitName(\"{spelling}\") must resolve"
);
assert_eq!(
s.eval::<i64>(&format!(r#"return UnitLevel("{spelling}")"#))
.unwrap(),
12
);
assert!(s
.eval::<bool>(&format!(r#"return UnitExists("{spelling}")"#))
.unwrap());
}
for spelling in ["target", "Target", "TARGET"] {
assert_eq!(
s.eval::<String>(&format!(r#"return UnitName("{spelling}")"#))
.unwrap(),
"Hogger"
);
}
// **The two-unit call, which the map's fold does not cover on its own.**
// `pick_unit_token` chooses "whichever arg is not the player"; with a case-sensitive test,
// `("Player", "target")` reads as a non-player FIRST arg and the call answers about the wrong
// unit — a wrong answer rather than a missing one.
assert!(
s.eval::<bool>(
r#"return UnitIsEnemy("Player", "target") == UnitIsEnemy("player", "target")"#
)
.unwrap(),
"the directional pick folds case too"
);
// An unseated but RECOGNISED token is still absent whatever its case — folding must not invent
// units. (`bogus` moved out of this list when the unrecognised-token raise landed: it is not
// "absent", it is not a unit token at all, and the client says so.)
for spelling in ["Party1", "PARTY1", "raid9", "MouseOver"] {
assert!(
!s.eval::<bool>(&format!(r#"return UnitExists("{spelling}")"#))
.unwrap(),
"{spelling} was never seated"
);
}
}
/// The fold is on the way **IN** as well as out: a feed that pushes `"Target"` must not create a
/// second entry shadowing `"target"`, which is what a lookup-only fold would allow.
#[test]
fn seating_a_token_folds_its_key_too() {
let mut s = UiScript::new().unwrap();
let mut a = player();
a.name = Some("First".into());
s.set_unit("Target", Some(a));
assert_eq!(
s.eval::<String>(r#"return UnitName("target")"#).unwrap(),
"First",
"a token seated as \"Target\" is readable as \"target\""
);
// …and clearing it through the other spelling really clears it, rather than leaving a shadow.
s.set_unit("TARGET", None);
assert!(
!s.eval::<bool>(r#"return UnitExists("target")"#).unwrap(),
"removal folds too — no shadowed entry survives"
);
}
/// **An unrecognised token RAISES; a recognised one that names nothing is a quiet nil.**
///
/// `0x515970` falls off the end of its nine compares into `luaL_error(L, "Unknown unit name: %s")`,
/// which longjmps and never returns (wow-re `system/ui/scratch/unit-token-grammar.md`). Ours
/// answered nil for everything unknown — 1203's shape pointed the other way, a failure the client
/// reports and we swallowed.
///
/// The split is THREE-way, and the two quiet legs are the ones a "raise on nil" implementation gets
/// wrong.
#[test]
fn an_unrecognised_unit_token_raises_and_a_recognised_empty_one_does_not() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
// RAISE: nothing the resolver's compares match.
for bogus in ["bogus", "focus", "boss1", "arena1"] {
let err = s
.run(&format!(r#"UnitName("{bogus}")"#))
.expect_err(&format!("{bogus} must raise"))
.to_string();
assert!(
err.contains(&format!("Unknown unit name: {bogus}")),
"the raise carries the client's own text and the offending token: {err}"
);
}
// `npc` is the SOLE full-string compare, so a suffix on it matches nothing at all.
assert!(s.run(r#"UnitName("npctarget")"#).is_err());
// …while `npc` itself is recognised, folded like everything else.
assert!(s.run(r#"UnitName("NPC")"#).is_ok());
// QUIET NIL, leg 1: a recognised token naming nothing here.
for absent in [
"party5",
"raid17",
"pet",
"mouseover",
"partypet2",
"raidpet3",
] {
assert!(
s.eval::<Option<String>>(&format!(r#"return UnitName("{absent}")"#))
.unwrap()
.is_none(),
"{absent} is recognised and absent — nil, not a raise"
);
}
// QUIET NIL, leg 2: a recognised PREFIX with a junk suffix. The compares are prefix tests that
// stop at either NUL, so `playerfoo` matches `player` and never reaches the raise.
for junk in ["playerfoo", "raidx", "petx", "targetish"] {
assert!(
s.eval::<Option<String>>(&format!(r#"return UnitName("{junk}")"#))
.unwrap()
.is_none(),
"{junk} matches a PREFIX, so it is a quiet nil — not a raise. This is the leg an \
implementation that raises on \"did not resolve\" gets wrong."
);
}
// QUIET NIL, leg 3: the empty string and an absent argument. The per-binding argument gates are
// NOT uniform in the client and only two poles are verified, so this asserts the resolver's
// behaviour and does not invent a gate.
assert!(s.run(r#"UnitName("")"#).is_ok());
assert!(s.run("UnitName()").is_ok());
// A two-unit call gates BOTH arguments.
assert!(s.run(r#"UnitIsUnit("player", "bogus")"#).is_err());
assert!(s.run(r#"UnitIsUnit("bogus", "player")"#).is_err());
assert!(s.run(r#"UnitIsUnit("player", "party3")"#).is_ok());
}
/// **A multibyte unit token must not take the client down.**
///
/// The prefix test used to slice the token as a `&str` — `token[..p.len()]` — which PANICS when the
/// token is multibyte UTF-8 and the prefix length lands mid-character ("byte index N is not a char
/// boundary"). Any addon passing a non-ASCII token would have crashed the process rather than
/// getting the `Unknown unit name` raise the client gives.
///
/// The client compares with `_strnicmp` over BYTES and folds ASCII only, so byte comparison is both
/// the safe form and the faithful one — a token whose bytes differ above 0x7F is simply not one of
/// the nine prefixes.
#[test]
fn a_multibyte_unit_token_raises_rather_than_panicking() {
let mut s = UiScript::new().unwrap();
s.set_unit("player", Some(player()));
// Each of these has a multibyte character positioned so that a byte-index slice at one of the
// prefix lengths (3 for `pet`, 4 for `raid`, 5 for `party`, 6 for `player`/`target`) would land
// inside it.
for token in ["pé", "раid", "playér", "targét", "мышь", "日本語のトークン"] {
let err = s
.run(&format!(r#"UnitName("{token}")"#))
.expect_err("a non-ASCII token matches no prefix, so it raises")
.to_string();
assert!(
err.contains("Unknown unit name"),
"it must RAISE, not panic and not answer: {err}"
);
}
// …and an ASCII token that merely shares a prefix's leading bytes still behaves.
assert!(s
.eval::<Option<String>>(r#"return UnitName("playerfoo")"#)
.unwrap()
.is_none());
assert_eq!(
s.eval::<String>(r#"return UnitName("player")"#).unwrap(),
"Benilla"
);
}
/// **`UnitIsVisible` is object presence, and it is NOT `UnitExists`** — the pair an out-of-range
/// party member separates, and the branch pfUI takes seven times.
///
/// `0x516030` is 57 bytes with one branch:
/// `ClntObjMgrObjectPtr(resolve(token), TYPEMASK_UNIT) != NULL`. No field read, no comparison
/// beyond `test eax,eax`, no float opcode — so no distance, no radius, no visibility flag. The
/// range test is the *server's*: the out-of-range demotion unlinks the object from the very
/// manager index this query searches (wow-re `ui/scratch/unitisvisible-object-presence.md`).
///
/// The state that backs it is [`UnitState::has_object`], which `ui_party::feed`'s
/// `member_unit_state` has always branched on — its `store: Option<&ObjectStore>` argument, whose
/// own comment names `0x468460`. This test is what stops the two fields being conflated back
/// together, because in every *in-range* case they agree and only the out-of-range one tells them
/// apart.
#[test]
fn unit_is_visible_is_object_presence_not_existence() {
let mut s = UiScript::new().unwrap();
// In range: the object is held, both answer yes.
s.set_unit(
"party1",
Some(UnitState {
exists: true,
has_object: true,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsVisible("party1")"#).unwrap(),
1,
"a held object is visible — and the return is the NUMBER 1, never a boolean"
);
// **Out of range: the roster still has them, the object manager does not.** This is the row
// that matters; `exists` MUST stay true here, because that is what UnitExists's own GUID
// fallback answers, and folding the two fields into one would silently break it.
s.set_unit(
"party1",
Some(UnitState {
exists: true,
has_object: false,
..Default::default()
}),
);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsVisible("party1")"#)
.unwrap(),
None,
"no object -> nil, even though the token still exists"
);
assert!(
s.eval::<bool>(r#"return UnitExists("party1") and true or false"#)
.unwrap(),
"...and UnitExists must NOT have moved with it — the roster fallback is the difference"
);
// pfUI's actual line (`unitframes.lua:1873`), which chooses a flat portrait over a 3D one.
assert!(
s.eval::<bool>(
r#"return (not UnitIsVisible("party1") or not UnitIsConnected("party1")) and true or false"#
)
.unwrap(),
"the portrait branch fires for an out-of-range member"
);
// A token with no snapshot at all: nil, via the same "no snapshot" path every predicate uses.
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsVisible("party4")"#)
.unwrap(),
None
);
}
/// **The tapped pair, and the two ways it goes wrong.**
///
/// `UnitIsTapped 0x519c90` / `UnitIsTappedByPlayer 0x519d00` are a masked-byte pair — 108 bytes
/// each, differing only in the `UNIT_DYNAMIC_FLAGS` mask (`0x4`/`0x8`, UpdateField 143) and the
/// `Usage:` string. Each is `object present && (flags & mask)` and nothing else: no ownership, no
/// GUID compare, no party/raid or health conjunct (wow-re
/// `ui/scratch/tapped-bits-and-unit-faction.md`).
///
/// Asserted here because both are easy to get wrong in a way that still looks right:
///
/// - **Shape A, not shape C.** `UnitIsVisible` beside them has *no* `Usage:` raise; these two do.
/// Inheriting the neighbour's shape would fail quietly, in the permissive direction.
/// - **The pair is read as a conjunction.** `tapped && not tappedByPlayer` — someone *else's* kill
/// — is the condition addons actually draw, and either bit alone says nothing useful.
#[test]
fn the_tapped_pair_is_two_masks_of_one_field_and_raises_on_a_bad_argument() {
let mut s = UiScript::new().unwrap();
let set = |s: &mut UiScript, tapped, by_player| {
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: true,
tapped,
tapped_by_player: by_player,
..Default::default()
}),
);
};
// Untapped: both nil.
set(&mut s, false, false);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsTapped("target")"#)
.unwrap(),
None
);
// Tapped by someone ELSE — the grey-bar case, pfUI `api/unitframes.lua:2012`.
set(&mut s, true, false);
assert_eq!(
s.eval::<i64>(r#"return UnitIsTapped("target")"#).unwrap(),
1,
"the return is the NUMBER 1, never a boolean"
);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsTappedByPlayer("target")"#)
.unwrap(),
None
);
assert!(
s.eval::<bool>(
r#"return (UnitIsTapped("target") and not UnitIsTappedByPlayer("target")) and true or false"#
)
.unwrap(),
"someone else's kill — the conjunction addons actually draw"
);
// Tapped by ME: both set, so the grey-bar branch must NOT fire.
set(&mut s, true, true);
assert!(
!s.eval::<bool>(
r#"return (UnitIsTapped("target") and not UnitIsTappedByPlayer("target")) and true or false"#
)
.unwrap(),
"my own tap is not greyed"
);
// Object presence is a conjunct of BOTH: an out-of-range unit is neither, whatever the bits
// say — and the bits cannot even be read, because there is no descriptor to read them from.
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: false,
tapped: true,
tapped_by_player: false,
..Default::default()
}),
);
// (The feed can never build that combination — `ui_unit::snapshot` sets all three together —
// but the binding is asserted against it anyway, because a future feed that forgot would
// otherwise report a tapped unit the object manager does not hold.)
assert_eq!(
s.eval::<i64>(r#"return UnitIsTapped("target")"#).unwrap(),
1,
"the binding reads the field it is given; the conjunct is enforced at the FEED, and this \
assertion records which of the two owns it"
);
// SHAPE A. `UnitIsVisible` next door has no `Usage:` raise; these do — and **nil is in the
// list**, which is the half that does not come free: the shared `check_unit_token` lets a nil
// through by design (right for `UnitExists` and `UnitIsVisible`, wrong for these two), so the
// gate has to live in the binding. This assertion is what found that; the original spelling
// used `print`, which is not defined in our VM and so was quietly testing nil all along.
for bad in ["{}", "true", "nil", "", "function() end"] {
assert!(
s.run(&format!("UnitIsTapped({bad})")).is_err(),
"UnitIsTapped({bad}) must raise — shape A, not the neighbour's shape C"
);
assert!(
s.run(&format!("UnitIsTappedByPlayer({bad})")).is_err(),
"UnitIsTappedByPlayer({bad}) must raise too — the pair shares its gate"
);
}
}
/// **`UnitIsPartyLeader` is two legs ORed, and the solo case answers 1.**
///
/// `0x516210` is
///
/// ```text
/// o = ObjPtr(resolve(t), TYPEMASK_PLAYER)
/// (o != NULL && (o.PLAYER_FLAGS & 0x1)) || resolve(t) == g_groupLeaderGuid
/// ```
///
/// and it is **not** derivable from `IsPartyLeader()` + `GetPartyLeaderIndex()` however arranged —
/// the two legs cover disjoint failures (wow-re
/// `ui/scratch/party-leader-and-nameplate-verbs.md`, G1 REFUTED). Each leg is asserted with the
/// other one dead, because either alone looks sufficient until the case it cannot reach.
#[test]
fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
let mut s = UiScript::new().unwrap();
const ME: u64 = 0x1111;
const THEM: u64 = 0x2222;
// ── LEG 1 alone: the descriptor flag, with the leader GUID matching nobody.
// This is the leg that answers for a STRANGER who leads their own party — something no
// comparison against our group's leader could ever express.
s.set_party(PartyState {
leader_guid: 0x9999,
..Default::default()
});
s.set_unit(
"target",
Some(UnitState {
exists: true,
has_object: true,
guid: THEM,
group_leader: true,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsPartyLeader("target")"#)
.unwrap(),
1,
"the server's PLAYER_FLAGS bit answers on its own"
);
// ── LEG 2 alone: no descriptor flag, but the resolved GUID IS our leader. This is the
// out-of-range member — the client holds no object, so leg 1 has nothing to read.
s.set_party(PartyState {
leader_guid: THEM,
..Default::default()
});
s.set_unit(
"party1",
Some(UnitState {
exists: true,
has_object: false,
guid: THEM,
group_leader: false,
..Default::default()
}),
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsPartyLeader("party1")"#)
.unwrap(),
1,
"the GUID compare answers where there is no descriptor to read a flag from"
);
// ── Neither leg: a grouped member who does not lead.
s.set_unit(
"party2",
Some(UnitState {
exists: true,
has_object: true,
guid: ME,
group_leader: false,
..Default::default()
}),
);
assert_eq!(
s.eval::<Option<i64>>(r#"return UnitIsPartyLeader("party2")"#)
.unwrap(),
None
);
// ── **NO ZERO GUARD.** Ungrouped, the cached leader is 0 and an unresolvable-but-non-raising
// token resolves to 0 — so they match and the answer is 1. `IsPartyLeader 0x4e9130`
// short-circuits on a `0:0` leader and `0x516210` does not; that asymmetry is the finding,
// and answering nil here would be the divergence, however much it reads like a bug.
s.set_party(PartyState::default());
assert_eq!(
s.eval::<i64>("return UnitIsPartyLeader(nil)").unwrap(),
1,
"solo + unresolvable token: 0 == 0, and the reference answers 1"
);
assert_eq!(
s.eval::<i64>(r#"return UnitIsPartyLeader("target")"#)
.unwrap(),
1,
"...and so does a token with no snapshot, by the same route"
);
// A bad token still raises through the shared resolver — shape C with a shape-A tail.
assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err());
}
/// `UnitHasRelicSlot` — the number 1 or nil, per token, never a boolean.
///
/// This shipped **absent** for months on the belief that the relic slot post-dates 1.12, which is
/// false (decision 1785). Stock `PaperDollFrame.lua` calls it unconditionally at l.429 and l.580,
/// so while it was missing the character sheet raised `attempt to call global` for every class —
/// which is why the nil-global case is asserted here too, not just the answer.
#[test]
fn unit_has_relic_slot_answers_one_or_nil() {
let mut s = UiScript::new().unwrap();
let mut druid = player();
druid.class = Some("Druid".into());
druid.class_file = Some("DRUID".into());
druid.has_relic_slot = true;
s.set_unit("player", Some(druid));
let mut warrior = player();
warrior.has_relic_slot = false;
s.set_unit("target", Some(warrior));
// The truthy leg is the NUMBER 1 (`lua_pushnumber`, `0x519ec8`) — a caller comparing it to 1
// is stock idiom, and a boolean would silently fail that.
assert_eq!(
s.eval::<String>(r#"return tostring(UnitHasRelicSlot("player"))"#)
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("player"))"#)
.unwrap(),
"number"
);
// The false leg is nil, never `false` (`lua_pushnil`, `0x519edb`).
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("target"))"#)
.unwrap(),
"nil"
);
// No `"player"` fast path in the reference, so every token is answered the same way — the
// stock inspect path at `PaperDollFrame.lua:429` passes the inspected unit, not "player".
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("party1"))"#)
.unwrap(),
"nil"
);
// And it EXISTS — the regression that actually bit. A nil global here takes the whole
// character sheet down for every class, not just the three that answer 1.
assert_eq!(
s.eval::<String>("return type(UnitHasRelicSlot)").unwrap(),
"function"
);
}