Files
benilla-octo/crates/benilla-app/src/ui_script/inspect_tests.rs
T
SamandClaude Fable 5.1 7bb52f7808 a hundred stale claims retire, and text= was never a divergence
- a comment that justifies code by an engine difference is a claim with an expiry date, and the tree had ~96 expired ones across `assets/ui`, `ui_script/` and `benilla-ui` — a hygiene pass, no behaviour change: dangling `Benilla*` names with zero definitions (`BenillaMoney_*` at thirteen sites after 1937/1962 converged the coin cluster onto the stock `SmallMoneyFrameTemplate`; `BenillaGossipRow_Resize`, `BenillaErrorsFrame_AddMessage`, the digit feed 1285 deleted, `BenillaMerchantItem_OnClick` and three more), "the shipped `assets/ui/<X>.xml`" for thirty-odd files the directory no longer holds — it holds nine — each site now naming the chain file that actually loads, `UiPanels.xml` in the present tense after 1988 took the panel manager and the dialog engine stock, and expired self-deprecation: SimpleHTML "a widget kind we do not have" (1337 built it), "benilla has no console" (1948 bound `ConsoleExec`), `Model`'s "seven verbs not published yet" and "`ClearFog` is a guess" (all 23 are published, the carve sits 500 lines below, `UNBUILT` has been `[&str; 0]` since 2027), `frame_flag_gate`'s doc describing four groups of accepted mouse divergences over a `KNOWN` list that is empty (the empty array and its enforcement stay), five comments saying key delivery is not gated on `keyboard_enabled` where `script::keyboard`'s walk filters on exactly that field (1319), `SetRaidTarget` called a verb this house lacks beside an assert that its intent IS queued (1820), two questlog tests deferring to a screenshot for `GetPoint`/`IsVisible` the regions already answer, `benilla.toc`'s "trade still needs four engine verbs" (all four bound, 1966) and its ping `Model`s "this engine draws no pixels for" (2013 renders `MinimapPing.mdx` itself), and `rig_anim`'s `set_paused` naming a booth caller it does not have; and the code the prose was propping up goes with it: `GameMenuFrame.xml`'s six `<OnLoad>` label setters, because `text=` on a `<Button>` is a global-string lookup at the loader's attribute step (0995, built 61 seconds before the file was written) — the buttons carry `text="LOGOUT"`/`"EXIT_GAME"`/`"MACROS"`/`"RETURN_TO_GAME"`/`"MAIN_MENU"` as the reference's own do; `ScriptLogFrame`'s five authored close-button lines, now `inherits="UIPanelCloseButton"` off the chain's `UIPanelTemplates.xml` seated 757 lines above it (1846); and the editbox's 22-line "gap" doc claiming only the self-acquire `autoFocus` path is built and its `false` default unverified — path 1 is 1686's `visibility_focus`, and the default is `true`, byte-read off `flags = 1` at `0x779a29`/`0x779a2e`, so the test named `autofocus_does_not_focus_on_show…` is renamed for what it actually pins (2044)
- the `text=` divergence never existed: `resolve_text`'s doc had claimed since 0068 that falling back to the raw attribute on a GlobalStrings miss was "one deliberate divergence" from a reference that draws the empty string, and read at the byte it is the reference's own behaviour — `FrameScript_GetText 0x703bf0` does return a pre-seeded empty string (`0x882748`) when the global is not a string, but that return never reaches a label, because all three `text=` readers image-wide test it and substitute the raw attribute: `Button::LoadXML` at `0x778c07` (`0x778c31 mov eax,esi`), `CSimpleFontString::LoadXML` at `0x771006` byte-identically, and `0x7292a6`, which pushes `edi` on the empty arm; so the loader changes nothing, the two test comments that had called `PetLevelText`'s "Level level race class" placeholder and `CLOSE` our divergence now name the reference's arm (a real 1.12 client draws them too), and what IS ours is the extra warning on a key-shaped miss (0983 → 0991), a strict addition; beside it, `GetEffectiveScale`'s root factor is 1 not because "benilla has no `uiScale` CVar" — 1804 registered one, default 0.9 — but because the dial is applied at the raster seam, `768/uiScale` UI units of virtual-screen height rather than a scale on `UIParent`, so every coordinate the VM hands Lua is already in those units and the reference's `GetCursorPosition()/GetEffectiveScale()` transcriptions convert correctly with a root of 1 (2044)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 19:39:30 +07:00

579 lines
26 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 shipped **inspect window** driven end-to-end, engine-only (no Bevy): the real
//! The reference's own `Blizzard_InspectUI` addon loaded behind
//! `Fonts.xml`/`UIParent.xml`/`GameTooltip.xml` and fed a synthetic target snapshot + a foreign
//! equipment view (decision 0631). `character_tests.rs`'s harness, turned onto the other paper doll.
//!
//! What these are here to falsify, in order of how quietly it could have shipped broken:
//!
//! 1. **The range gate really gates.** `InspectUnit` out of range must not open the window. A
//! `CanInspect` that always answered truthy would look perfectly fine in play — you would just
//! be able to inspect across the zone — so the boundary is asserted at the verified thresholds,
//! including the strict-vs-non-strict asymmetry between the two predicates.
//! 2. **The slots come from the inspected unit, not from us.** The whole slice is one router change
//! (`Model::inv_slot`); if it fell back to the self feed, the window would show *your* gear on
//! someone else's doll — plausible-looking and completely wrong. So the two sources are fed
//! deliberately different items and the doll is checked against the foreign one.
//! 3. **The window's own lifecycle.** Open/close sounds, the level line off the target snapshot, the
//! `UNIT_INVENTORY_CHANGED` repaint, the rotate buttons moving the booth yaw, and
//! `ClearInspectPlayer` firing on hide (without which the app would keep resolving a closed
//! window's target forever).
use std::collections::HashMap;
use benilla_ui::script::{
InspectView, InvSlotView, InventorySlots, QuadContent, SoundRequest, UiScript, UnitReach,
UnitState,
};
/// A reach entry for a live, inspectable unit at squared distance `d2` — the ordinary case. The
/// `inspectable` half is `CanInspect`'s alone (the app folds vmangos's non-distance refusals into
/// it); every test in this file is about distance, so it stays true.
fn reach(dist_sq: f64) -> UnitReach {
UnitReach {
dist_sq,
inspectable: true,
}
}
use super::test_ui::load_ui as load_xml;
/// The inspected player: a Dwarf Paladin, level 34.
fn target_unit() -> UnitState {
UnitState {
exists: true,
name: Some("Thargrim".into()),
health: 900,
max_health: 900,
level: 34,
race: Some("Dwarf".into()),
race_file: Some("Dwarf".into()),
class: Some("Paladin".into()),
class_file: Some("PALADIN".into()),
sex: 2,
is_player: true,
player_controlled: true,
..Default::default()
}
}
/// A slot view as the inspect feed builds one: entry + icon + name + quality, and nothing an item
/// *object* would have supplied (no count>1, no durability, no lock) — see `ui_inspect`'s module doc.
fn foreign_slot(entry: u32, icon: &str, name: &str) -> InvSlotView {
InvSlotView {
item_id: entry,
icon: Some(icon.into()),
count: 1,
quality: 3,
name: Some(name.into()),
link: Some(format!("|cff0070dd|Hitem:{entry}:0:0:0|h[{name}]|h|r")),
..Default::default()
}
}
/// The inspected unit's equipment: a helm in the head slot (live-API id 1) and nothing else.
fn inspect_view(unit: &str) -> InspectView {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(foreign_slot(
7365,
"Interface\\Icons\\INV_Helmet_09",
"Mighty Helm",
));
InspectView {
unit: unit.into(),
guid: 0x0000_0000_0000_0042,
slots,
}
}
/// OUR equipment, deliberately a different item in the same slot — the router's falsifier.
fn own_slots() -> InventorySlots {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(foreign_slot(
1234,
"Interface\\Icons\\INV_Helmet_01",
"My Own Helm",
));
slots
}
/// Does a texture whose path contains `needle` actually get DRAWN this frame? Regions carry no Lua
/// `GetTexture` in this engine, so the icon assertions go through the render pass exactly as
/// `character_tests.rs`'s do — which also makes them a stronger claim (it renders, not merely
/// "the state says so").
fn drawn(s: &mut UiScript, needle: &str) -> bool {
s.resolve();
s.extract().iter().any(
|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle)),
)
}
/// Everything the window needs to open on `"target"`, in range.
fn armed() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// `PLAYER_LEVEL`, the template the stock level line formats through — the reference keeps its
// strings here, and our retired file had the sentence written into it (1832).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// The inspect window's four tabs (`Blizzard_InspectUI`) inherit it (1993).
load_xml(&s, r"Interface\FrameXML\CharacterFrameTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
// The stock slot buttons inherit `ItemButtonTemplate` — where their `$parentIconTexture`
// comes from, which `InspectPaperDollItemSlotButton_OnLoad` indexes unconditionally. Our own
// slot template declared its icon inline and needed no such file; the manifest has carried
// this entry far above the inspect window all along (decision 1832).
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `InspectFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI");
s.run("InspectFrame_LoadUI()").unwrap();
s.set_unit("target", Some(target_unit()));
s.set_inspect(Some(inspect_view("target")));
// 4 yards away (d² = 16) — comfortably inside the verified 100.0.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(16.0))]));
s
}
/// The loader itself: the window and its 19 slots + model pane materialize with no errors.
#[test]
fn shipped_inspect_frame_loads_clean() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
// `PLAYER_LEVEL`, the template the stock level line formats through — the reference keeps its
// strings here, and our retired file had the sentence written into it (1832).
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
// `TEXT`, which the stock `InspectPaperDollFrame_SetLevel` formats its level line through.
load_xml(&s, "Interface\\FrameXML\\BasicControls.xml");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.lua");
load_xml(&s, r"Interface\FrameXML\MoneyFrame.xml");
load_xml(&s, r"Interface\FrameXML\UIParent.xml");
load_xml(&s, "ScrollTemplates.xml"); // our scroll kit + the placeholder icon
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
// The inspect window's four tabs (`Blizzard_InspectUI`) inherit it (1993).
load_xml(&s, r"Interface\FrameXML\CharacterFrameTemplates.xml");
load_xml(&s, r"Interface\FrameXML\LocaleProperties.lua");
load_xml(&s, r"Interface\FrameXML\StaticPopup.xml");
// `InspectUnit` lives here now — the reference's own home for that name, and no longer in the
// window's own file (decision 1832). The manifest's order, and 49 other harnesses'.
load_xml(&s, "Interface\\FrameXML\\GameTooltip.xml");
// Before InspectFrame.xml, and required rather than tidy: this window's honor page inherits
// HonorFrame.xml's five row templates and `inherits=` resolves at LOAD, so without it the
// twelve honor rows materialize bare (decision 1512; the manifest states the same order).
// The stock slot buttons inherit `ItemButtonTemplate` — where their `$parentIconTexture`
// comes from, which `InspectPaperDollItemSlotButton_OnLoad` indexes unconditionally. Our own
// slot template declared its icon inline and needed no such file; the manifest has carried
// this entry far above the inspect window all along (decision 1832).
load_xml(&s, "Interface\\FrameXML\\ItemButtonTemplate.xml");
load_xml(&s, "Interface\\FrameXML\\HonorFrame.xml");
// The window is a LoadOnDemand addon, reached the way the app reaches it: seated off the
// chain as a registry row (1957) and loaded by the reference's own `InspectFrame_LoadUI`
// (UIParent.xml; 1967).
super::test_ui::seat_chain_addon(&mut s, "Blizzard_InspectUI");
s.run("InspectFrame_LoadUI()").unwrap();
// All 19 slots exist and carry their GetInventorySlotInfo id (1..=19, no ammo slot).
// The stock `InspectPaperDollItemSlotButton_OnLoad` puts it on the frame's own ID
// (`this:SetID(id)`); our retired file kept it in an `invSlotId` field of its own (1832).
for (name, id) in [
("InspectHeadSlot", 1),
("InspectBackSlot", 15),
("InspectMainHandSlot", 16),
("InspectRangedSlot", 18),
("InspectTabardSlot", 19),
] {
assert_eq!(
s.eval::<i64>(&format!("return {name}:GetID()")).unwrap(),
id,
"{name} inventory slot id"
);
}
}
/// **The gate really gates.** Out of range, `InspectUnit` sends nothing and opens nothing; the same
/// call in range does both. A `CanInspect` stuck truthy passes every other test in this file.
#[test]
fn inspect_unit_refuses_out_of_range() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = armed();
// 11 yards (d² = 121) — past the verified 100.0 threshold.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(121.0))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"out of range: the window must not open"
);
assert!(
s.take_inspect_notifies().is_empty(),
"out of range: no CMSG_INSPECT request is queued"
);
// Step inside the threshold: the same call now opens and requests.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(99.9))]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"in range: the window opens"
);
assert_eq!(
s.take_inspect_notifies(),
vec!["target".to_string()],
"in range: NotifyInspect queued the token for the app to resolve"
);
}
/// The two verified thresholds and their **different operators** — `CanInspect` refuses only on a
/// strict `100.0 < d²` (so exactly 100.0 is IN range), while `CheckInteractDistance` admits only on
/// a strict `d² < table[type-1]` (so exactly 100.0 is OUT). That asymmetry is the binary's own
/// (wow-re §5: `test ah,0x41; jne` vs `test ah,0x5; jp`), and it is the kind of detail a rewrite
/// silently normalizes — hence a test on the boundary itself.
#[test]
fn range_predicates_transcribe_the_verified_thresholds() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = armed();
for (d2, can_inspect, interact1) in [
(99.9_f64, true, true),
(100.0, true, false),
(100.1, false, false),
] {
s.set_unit_reach(HashMap::from([("target".to_string(), reach(d2))]));
assert_eq!(
s.eval::<bool>(r#"return CanInspect("target") ~= nil"#)
.unwrap(),
can_inspect,
"CanInspect at d²={d2}"
);
assert_eq!(
s.eval::<bool>(r#"return CheckInteractDistance("target", 1) ~= nil"#)
.unwrap(),
interact1,
"CheckInteractDistance(type 1) at d²={d2}"
);
}
// Type 4 is the 30-yard row (900.0) — the table is indexed, not hardcoded to one distance.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(899.0))]));
assert!(s
.eval::<bool>(r#"return CheckInteractDistance("target", 4) ~= nil"#)
.unwrap());
assert!(
!s.eval::<bool>(r#"return CheckInteractDistance("target", 1) ~= nil"#)
.unwrap(),
"the same distance is out of range for the 10-yard type"
);
// **A token the object manager holds no unit for answers nil — the null-object arm** (report
// B316, wow-re `dist2-null-unit-arm.md` VERIFIED). This is the party member outside the local
// area: the roster wire gives them a GUID, so the token resolves, and the object lookup then
// misses silently. It used to answer 1, which lit every distance row for exactly the member
// who was furthest away.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("party3", 1) == nil"#)
.unwrap(),
"a token with no live unit is out of range, not in"
);
assert!(
s.eval::<bool>(r#"return CanInspect("party3") == nil"#)
.unwrap(),
"…and CanInspect agrees, through its own null-`this` tail"
);
// The `type` argument's three degenerate arms, each the binary's own answer.
s.set_unit_reach(HashMap::from([("target".to_string(), reach(1.0))]));
for bad in ["0", "5", "-1", "0.5"] {
assert!(
s.eval::<bool>(&format!(
r#"return CheckInteractDistance("target", {bad}) == nil"#
))
.unwrap(),
"type {bad} is outside the table (unsigned compare on trunc(type) − 1)"
);
}
// …but a fractional type INSIDE the range truncates toward zero and answers its row.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("target", 1.9) ~= nil"#)
.unwrap(),
"1.9 chops to 1"
);
// A missing `type` is a usage ERROR, not nil — the reference's `luaL_error`, which longjmps.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("target") ~= nil"#)
.is_err(),
"no distIndex is a script error"
);
// The token is CASE-FOLDED, like every compare in the resolver both predicates reach their
// unit through (`_strnicmp`, 1247) — the same fold `UnitName("Target")` already gets. An
// addon that capitalises must not be told the unit is out of range.
assert!(
s.eval::<bool>(r#"return CheckInteractDistance("TARGET", 1) ~= nil"#)
.unwrap(),
"an upper-case token names the same unit"
);
assert!(
s.eval::<bool>(r#"return CanInspect("Target") ~= nil"#)
.unwrap(),
"…for both predicates"
);
}
/// **The doll shows THEIR gear, not ours.** Both sources are fed, with different items in the same
/// slot; the inspected token must read the foreign one and `"player"` must still read ours.
#[test]
fn inventory_bindings_route_by_unit_token() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = armed();
s.set_inventory_slots(own_slots());
assert_eq!(
s.eval::<String>(r#"return GetInventoryItemTexture("target", 1)"#)
.unwrap(),
"Interface\\Icons\\INV_Helmet_09",
"the inspected token reads the foreign visible-item view"
);
assert_eq!(
s.eval::<String>(r#"return GetInventoryItemTexture("player", 1)"#)
.unwrap(),
"Interface\\Icons\\INV_Helmet_01",
"\"player\" still reads our own equipped feed"
);
// A token nobody is inspecting has no equipment source at all.
assert!(s
.eval::<bool>(r#"return GetInventoryItemTexture("party2", 1) == nil"#)
.unwrap());
// The inspect view is keyed on the token it was built for: if the window rebinds to another
// token, the stale view must not answer for it.
s.set_inspect(Some(inspect_view("party1")));
assert!(
s.eval::<bool>(r#"return GetInventoryItemTexture("target", 1) == nil"#)
.unwrap(),
"a view built for \"party1\" must not answer for \"target\""
);
}
/// The whole window contract in one drive: open through `ShowUIPanel` with the ref's sound, the
/// name/level lines off the target snapshot, the slot icon painted from the foreign view, an empty
/// slot falling back to its paper-doll art, the `UNIT_INVENTORY_CHANGED` repaint, the rotate buttons
/// moving the booth yaw, and closing firing `ClearInspectPlayer`.
#[test]
fn shipped_inspect_frame_drives_end_to_end() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = armed();
// Hidden at load, and no sound queued (never transitions on startup).
assert!(!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
assert!(s.take_sounds().is_empty());
// The pane's OnLoad set the ref's own default facing (InspectModelFrame_OnLoad, 0.61).
assert!(
(s.model_pane_facing("InspectModelFrame") - 0.61).abs() < 0.0001,
"default facing 0.61, got {}",
s.model_pane_facing("InspectModelFrame")
);
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCharacterInfoOpen".into())],
"opening plays igCharacterInfoOpen"
);
assert_eq!(
s.eval::<String>("return InspectNameText:GetText()")
.unwrap(),
"Thargrim",
"the name line reads UnitName of the inspected token"
);
assert_eq!(
s.eval::<String>("return InspectLevelText:GetText()")
.unwrap(),
"Level 34 Dwarf Paladin",
"the level line reads the inspected unit, not the player"
);
assert!(
drawn(&mut s, "INV_Helmet_09"),
"the head slot paints the inspected item's icon"
);
assert!(
drawn(&mut s, "UI-PaperDoll-Slot-Chest"),
"an empty slot falls back to its own paper-doll art"
);
// The repaint path: the feed pushes a changed view and fires the event the ref's slot buttons
// listen for (filtered on arg1 == the inspected unit).
let mut swapped = inspect_view("target");
swapped.slots[1] = Some(foreign_slot(
7366,
"Interface\\Icons\\INV_Helmet_22",
"Better Helm",
));
s.set_inspect(Some(swapped));
s.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![benilla_ui::script::ScriptValue::Str("target".into())],
);
assert!(
drawn(&mut s, "INV_Helmet_22"),
"UNIT_INVENTORY_CHANGED for the inspected unit repaints the slot"
);
// An event for somebody else must NOT repaint (the ref's arg1 filter).
let mut other = inspect_view("target");
other.slots[1] = Some(foreign_slot(1, "Interface\\Icons\\INV_Helmet_ZZ", "Nope"));
s.set_inspect(Some(other));
s.fire_event(
"UNIT_INVENTORY_CHANGED",
vec![benilla_ui::script::ScriptValue::Str("party4".into())],
);
assert!(
drawn(&mut s, "INV_Helmet_22") && !drawn(&mut s, "INV_Helmet_ZZ"),
"an event for another unit leaves the doll alone"
);
// Rotate: the ref's sign convention (left subtracts, right adds 0.03) onto the booth yaw, with
// the kit each click.
s.run("InspectModelRotateLeftButton:Click()").unwrap();
assert!(
(s.model_pane_facing("InspectModelFrame") - 0.58).abs() < 0.0001,
"rotate-left subtracts 0.03, got {}",
s.model_pane_facing("InspectModelFrame")
);
s.run("InspectModelRotateRightButton:Click()").unwrap();
s.run("InspectModelRotateRightButton:Click()").unwrap();
assert!(
(s.model_pane_facing("InspectModelFrame") - 0.64).abs() < 0.0001,
"rotate-right adds 0.03, got {}",
s.model_pane_facing("InspectModelFrame")
);
assert_eq!(
s.take_sounds(),
vec![
SoundRequest::KitName("igInventoryRotateCharacter".into()),
SoundRequest::KitName("igInventoryRotateCharacter".into()),
SoundRequest::KitName("igInventoryRotateCharacter".into()),
],
"each rotate click plays the kit"
);
// Closing plays the close sound AND tells the app to stop inspecting — without the latter the
// feed would keep resolving a closed window's target forever.
assert!(
!s.take_inspect_clear(),
"ClearInspectPlayer not called while open"
);
s.run(r#"HideUIPanel(InspectFrame)"#).unwrap();
assert!(!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCharacterInfoClose".into())],
"closing plays igCharacterInfoClose"
);
assert!(
s.take_inspect_clear(),
"closing called ClearInspectPlayer (the ref's InspectFrame_OnHide)"
);
assert!(s.errors().is_empty(), "close errors: {:?}", s.errors());
}
/// The tab chrome, and where the ref's "toggle the showing page shut" branch actually lives.
///
/// `PanelTemplates_SelectTab` **disables** the selected tab button (`UiPanels.xml`, the ref's own
/// UIPanelTemplates), so the SELECTED tab is inert from the moment it opens — clicking it
/// cannot close anything. `ToggleInspect`'s close-when-already-showing branch
/// (`Blizzard_InspectUI.lua:67-83`) is therefore reachable only by calling `ToggleInspect` directly,
/// which is how a keybinding would. Both halves asserted, because the first one is what makes the
/// second one non-obvious.
#[test]
fn the_active_tab_is_inert_and_toggle_inspect_closes() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = armed();
s.run(r#"InspectUnit("target")"#).unwrap();
let _ = s.take_sounds();
// NB this engine's `IsEnabled` returns a plain bool, not the live API's 1/nil.
assert!(
!s.eval::<bool>("return InspectFrameTab1:IsEnabled() ~= 0")
.unwrap(),
"the selected tab is disabled (PanelTemplates_SelectTab), so its click is inert"
);
s.run("InspectFrameTab1:Click()").unwrap();
assert!(
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"clicking the disabled active tab changes nothing"
);
// The reachable path: ToggleInspect on the page already showing closes the window.
s.run(r#"ToggleInspect("InspectPaperDollFrame")"#).unwrap();
assert!(
!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"ToggleInspect on the showing page closes the window (ref ToggleInspect)"
);
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
}
/// The other branch of `ToggleInspect` — re-opening after a close — **errors, and that is the
/// reference's own behaviour** (decision 1834 settled what 1832 had to leave open).
///
/// `InspectFrame_OnHide` sets `this.unit = nil`, so a re-open runs `InspectFrame_OnShow` with no
/// token. wow-re censused the whole binding table: `SetPortraitTexture 0x519ef0` gates its unit
/// argument at `0x519fb4` with `lua_isstring` and raises `Usage: SetPortraitTexture(texture,
/// "unit")`, and `luaL_error` does not return — so the reference raises here too, on its own
/// shipped code. The widget clear sits six instructions PAST the gate, so the portrait is not even
/// blanked; it is left exactly as it was.
///
/// In the reference the raise unwinds to the handler's `lua_pcall`, abandons the rest of `OnShow`,
/// and pops the ScriptErrors dialog through `_ERRORMESSAGE`. Ours collects it into `errors` and
/// abandons the handler the same way. So: the window shows, the portrait and name do not update,
/// and there is exactly one error — which is what this now asserts.
///
/// One correction from that census worth keeping: stock 1.12 ships **no** inspect keybind (0 of 234
/// `Binding name=` entries) and no caller of `ToggleInspect` outside the addon's own tab buttons,
/// which are children of the hidden `InspectFrame`. So on the stock UI's own paths this is
/// unreachable — only an addon or a macro gets here. It is still ours to reproduce.
#[test]
fn toggle_inspect_reopens_after_a_close_and_raises_like_the_reference() {
let _data = benilla_formats::wow_data_or_skip!();
let s = armed();
s.run(r#"InspectUnit("target")"#).unwrap();
s.run(r#"ToggleInspect("InspectPaperDollFrame")"#).unwrap();
assert!(!s.eval::<bool>("return InspectFrame:IsVisible()").unwrap());
let _ = s.errors();
s.run(r#"ToggleInspect("InspectPaperDollFrame")"#).unwrap();
assert!(
s.eval::<bool>("return InspectFrame:IsVisible()").unwrap(),
"the window still shows — Show() runs before OnShow raises"
);
// TWO raises, not one: `InspectFrame` and `InspectPaperDollFrame` each have their own OnShow,
// each fired under its own pcall, so each abandons independently — the first at the portrait's
// gate, the second at `UnitLevel`'s. Both carry the reference's own `Usage:` text.
let errors = s.errors();
assert_eq!(errors.len(), 2, "one raise per OnShow handler: {errors:?}");
assert!(
errors[0].contains(r#"Usage: SetPortraitTexture(texture, "unit")"#),
"the portrait's gate, before the name line is ever reached: {errors:?}"
);
assert!(
errors[1].contains(r#"Usage: UnitLevel("unit")"#),
"and the paper doll's own handler, dying at its first getter: {errors:?}"
);
}