Files
benilla-octo/crates/benilla-app/src/ui_script/inspect_tests.rs
T
SamandClaude Fable 5 3b6975fdef the pet gets its frame, flooded rooms fill, and macros really fire
- the pet gets its own unit frame beside yours, and an Attack order no longer latches as the standing command
- five rooms that always read bone dry hold their water now, the Deeprun Tram's flooded stretches among them
- macros fire through the client's own chat door, and the macro window fits its tabs and scrolls its icon chooser
- the options window grows a Nameplates page and its first dropdown, and the name-display switches apply live in the world

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

439 lines
17 KiB
Rust

//! The shipped **inspect window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/InspectFrame.xml` loaded behind `Fonts.xml`/`UiPanels.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, UnitState,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The 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,
..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);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "InspectFrame.xml");
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_inspect_reach(HashMap::from([("target".to_string(), 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 s = UiScript::new().unwrap();
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "InspectFrame.xml");
// All 19 slots exist and carry their GetInventorySlotInfo id (1..=19, no ammo slot).
for (name, id) in [
("BenillaInspectHeadSlot", 1),
("BenillaInspectBackSlot", 15),
("BenillaInspectMainHandSlot", 16),
("BenillaInspectRangedSlot", 18),
("BenillaInspectTabardSlot", 19),
] {
assert_eq!(
s.eval::<i64>(&format!("return {name}.invSlotId")).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 mut s = armed();
// 11 yards (d² = 121) — past the verified 100.0 threshold.
s.set_inspect_reach(HashMap::from([("target".to_string(), 121.0)]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaInspectFrame: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_inspect_reach(HashMap::from([("target".to_string(), 99.9)]));
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(
s.eval::<bool>("return BenillaInspectFrame: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 mut s = armed();
for (d2, can_inspect, interact1) in [
(99.9_f64, true, true),
(100.0, true, false),
(100.1, false, false),
] {
s.set_inspect_reach(HashMap::from([("target".to_string(), 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_inspect_reach(HashMap::from([("target".to_string(), 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"
);
// An unknown token never grays a row (missing data must not gate).
assert!(s
.eval::<bool>(r#"return CheckInteractDistance("party3", 1) ~= nil"#)
.unwrap());
}
/// **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 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 mut s = armed();
// Hidden at load, and no sound queued (never transitions on startup).
assert!(!s
.eval::<bool>("return BenillaInspectFrame: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.inspect_yaw() - 0.61).abs() < 0.0001,
"default facing 0.61, got {}",
s.inspect_yaw()
);
s.run(r#"InspectUnit("target")"#).unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap());
assert_eq!(
s.take_sounds(),
vec![SoundRequest::KitName("igCharacterInfoOpen".into())],
"opening plays igCharacterInfoOpen"
);
assert_eq!(
s.eval::<String>("return BenillaInspectNameText:GetText()")
.unwrap(),
"Thargrim",
"the name line reads UnitName of the inspected token"
);
assert_eq!(
s.eval::<String>("return BenillaInspectLevelText: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("BenillaInspectModelFrameRotateLeftButton:Click()")
.unwrap();
assert!(
(s.inspect_yaw() - 0.58).abs() < 0.0001,
"rotate-left subtracts 0.03, got {}",
s.inspect_yaw()
);
s.run("BenillaInspectModelFrameRotateRightButton:Click()")
.unwrap();
s.run("BenillaInspectModelFrameRotateRightButton:Click()")
.unwrap();
assert!(
(s.inspect_yaw() - 0.64).abs() < 0.0001,
"rotate-right adds 0.03, got {}",
s.inspect_yaw()
);
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(BenillaInspectFrame)"#).unwrap();
assert!(!s
.eval::<bool>("return BenillaInspectFrame: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 on a one-tab window the 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 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 BenillaInspectFrameTab1:IsEnabled()")
.unwrap(),
"the selected tab is disabled (PanelTemplates_SelectTab), so its click is inert"
);
s.run("BenillaInspectFrameTab1:Click()").unwrap();
assert!(
s.eval::<bool>("return BenillaInspectFrame: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("BenillaInspectPaperDollFrame")"#)
.unwrap();
assert!(
!s.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap(),
"ToggleInspect on the showing page closes the window (ref ToggleInspect)"
);
// And again re-opens it, the other branch of the same function.
s.run(r#"ToggleInspect("BenillaInspectPaperDollFrame")"#)
.unwrap();
assert!(s
.eval::<bool>("return BenillaInspectFrame:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
}