- 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>
579 lines
26 KiB
Rust
579 lines
26 KiB
Rust
//! 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:?}"
|
||
);
|
||
}
|