Files
benilla-octo/crates/benilla-app/tests/mail_frame.rs
T
SamandClaude Fable 5.1 9f204d894b a bot walks on the ground, and a stunned cast never leaves the client
- a bot floating a body-height above the ground in mid-stride (B357): cmangos's Playerbots moves its bots as Player objects under `SMSG_MONSTER_MOVE`, and our ground clamp skipped anything that was not a Unit, so a splined Player kept the wire Z verbatim — and a spline is a straight chord, which over a hollow leaves the body in the air; kind is not a question the reference asks (`0x616620` runs every registered mover once per frame, reading only its own flag word), so one rule grounds every Unit plus a Player from the moment a server spline first moves it, never the body we steer; and a re-sent identical wire pose no longer leaves a unit floating (1921); the body we STEER is a mover too — the reference hands a ride's spline displacement to the same vertical-zeroing step as walking, so the spline owns XY and facing and the ground owns Z, closing a +0.47 yd gap at mid-chord on an Elwynn slope, endpoint included (1927)
- the client refuses a stunned, silenced or pacified cast itself, in the requirement validator `0x6094f0` whose mounted and moving legs we already model, before any packet — and the three arms are not symmetric: STUNNED refuses everything, SILENCED and PACIFIED run only where `Spell.dbc PreventionType` declares them, so a silence stops casts and leaves melee abilities alone and a pacify does the reverse; the leg sits above the mounted block and below the equipped-item requirement, so stunned and missing the weapon you are told about the weapon; `PreventionType` is column 165, Auto Shot alone telling it from its look-alike neighbour; the preflight banner splits movement blockers from ability blockers, since a disarm or a pacify touches no movement gate; and the refusal sink is an explicit parameter rather than a ladder field, because a resource reachable twice from one system panics on the first live frame (1903)
- a disarm greys what needs the hidden hand: the spell equipped-item test is `0x5f0c50`, walking all 23 equipment slots, rejecting a deprecated or genuinely broken item, and taking its slot mask from `AttributesEx3` (main-hand-only, off-hand-only, else every slot) — DISARMED strips the hidden hand's bit out of that mask, so a main-hand-only ability whose one allowed hand is the one the disarm took has an empty mask and dies, Heroic Strike greyed with its tooltip line red; using a worn weapon while disarmed answers `ERR_CANT_USE_DISARMED`, the client's own refusal at rung 15 of the item-use ladder's 20, above the bind-confirm arm and never a bag click (1903); and two stale claims go — `UNIT_FLAG_STUNNED` no longer says the idle selectors bail on it (0889 had refuted that at the bytes), and the charter's `ShowPetition` arm is rung #9 of that same ladder, read rather than reasoned
- `UpdateSpells()` is twelve bytes and all of them are the event: it ends in a jump into the event signal with 0x104 = `SPELLS_CHANGED` in hand, whose repaint is FrameXML's alone, so a no-op would not have been faithful and our own repainting stand-in was wrong in both directions at once; it fires synchronously as the reference's does, at the cost of a fire-path refactor rather than a next-tick queue; `PlayerHasSpells()` is a hard-coded 1, no branch, no store read, pinned to answer 1 on an empty spellbook, where a plausible implementation diverges silently (1924); and three integration harnesses loaded every document against an empty base, so a self-sourcing chain file loaded as an empty shell with every handler missing — none of their lists could fail, so the fix lands with a guard watched failing first, `FadingFrame.xml`, one relative script reference and nothing else (1923)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:51:44 +07:00

769 lines
30 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.
//! Drives the REAL `assets/ui/MailFrame.xml` through the engine (decision 0544 P1/P2) — the mail
//! twin of `tradeskill_frame.rs`: it loads the same file chain the app does (cut to the mail
//! window's dependency prefix), pushes a synthetic inbox, opens the window with the app's own
//! `MAIL_SHOW`/`MAIL_INBOX_UPDATE` events, and asserts the transcribed Lua actually paints — the
//! named regions exist, the rows populate from a fed `MailState`, the paging math is right, and the
//! unread/read row state tracks the wire `wasRead` flag.
use benilla_ui::script::{MailInboxRow, MailInvoice, MailState, UiScript};
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The mail window's load prefix — the app's own order (`ui_script/mod.rs`), members only.
/// MerchantFrame.xml rides along because MailFrame.xml reuses its global `BenillaMoney_*` coin
/// helpers (postage display), so a load error in either fails here.
const FILES: [&str; 9] = [
"Interface\\FrameXML\\Fonts.xml",
"MoneyFrame.xml",
// The send tab's money entry comes off the chain since 1882 — `MoneyInputFrameTemplate` and
// the `MoneyInputFrame_*` verbs. Seated straight after MoneyFrame.xml, benilla.toc's order.
r"Interface\FrameXML\MoneyInputFrame.lua",
r"Interface\FrameXML\MoneyInputFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
"MailFrame.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
// A manifest entry carrying a path separator is the PLAYER's own file and comes off the patch
// chain; a bare name is ours, under `assets/ui`. `tests/common` already draws this line — this
// binary grew it when 1860 moved `PanelTemplates_*` onto the chain.
let chain = benilla_formats::wow_data().and_then(|d| benilla_formats::open_chain(&d).ok());
let read = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
if norm.contains('/') {
if let Some(b) = chain.as_ref().and_then(|c| c.read(&norm).ok()) {
return Some(b);
}
}
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
for file in FILES {
let bytes = read(file).unwrap_or_else(|| panic!("reading {file}"));
// A `.lua` entry is a CHUNK, not a document.
if file.to_ascii_lowercase().ends_with(".lua") {
script
.run_chunk_named(&bytes, &format!("@{file}"))
.unwrap_or_else(|e| panic!("{file}: {e}"));
continue;
}
let text = benilla_ui::source::decode(&bytes);
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| {
panic!("parsing {file}: {e}");
});
// The document's OWN path, not the path-less `load` (1923). The base is what every
// relative `<Script file=>` / `<Include file=>` inside resolves against (1186); with
// an empty base they stay bare and miss the provider entirely, so a self-sourcing
// stock file loads as an empty shell and every handler in it goes missing — silently,
// because nothing in these lists used to be self-sourcing.
let report = benilla_ui::loader::load_in(script, &doc, &file.replace('\\', "/"), &provider);
assert!(
report.errors.is_empty(),
"{file} loaded with errors: {:#?}",
report.errors
);
}
}
/// One inbox row with the fields the window paints.
fn row(sender: &str, subject: &str, was_read: bool, item_id: u32, cod: u32) -> MailInboxRow {
MailInboxRow {
package_icon: (item_id != 0).then(|| "Interface\\Icons\\INV_Misc_Bag_08".to_string()),
stationery_icon: Some("Interface\\Icons\\INV_Misc_Note_01".to_string()),
sender: Some(sender.to_string()),
subject: subject.to_string(),
money: 0,
cod,
days_left: 29.0,
item_count: if item_id != 0 { 2 } else { 0 },
was_read,
was_returned: false,
text_created: false,
can_reply: true,
is_gm: false,
body: Some("body".into()),
stationery_texture: "STATIONERYTEST".into(),
is_invoice: false,
invoice: None,
has_body: true,
item_id,
item_name: (item_id != 0).then(|| "Linen Cloth".to_string()),
item_texture: (item_id != 0).then(|| "Interface\\Icons\\INV_Fabric_Linen_01".to_string()),
item_quality: (item_id != 0).then_some(1),
can_delete: false,
item_random_property_id: 0,
}
}
/// A one-page inbox (2 mails).
fn small_inbox() -> MailState {
MailState {
inbox: vec![
row("Thrall", "Warchief's orders", false, 2589, 0),
row("Jaina", "A letter", true, 0, 0),
],
}
}
#[test]
fn mail_frame_loads_and_key_regions_exist() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_ui(&s);
// The window, both tabs' bodies, a row, the open-letter toplevel, and the send-tab widgets.
for name in [
"MailFrame",
"InboxFrame",
"MailItem1",
"MailItem1Button",
"MailItem7",
"SendMailFrame",
"SendMailNameEditBox",
"SendMailMailButton",
"OpenMailFrame",
"OpenMailReplyButton",
] {
assert!(
s.eval::<bool>(&format!("return getglobal('{name}') ~= nil"))
.unwrap(),
"region {name} should exist"
);
}
// The window is hidden until MAIL_SHOW.
assert!(!s.eval::<bool>("return MailFrame:IsShown()").unwrap());
}
#[test]
fn mail_show_opens_and_inbox_populates() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
s.fire_event("MAIL_SHOW", vec![]);
assert!(
s.eval::<bool>("return MailFrame:IsShown()").unwrap(),
"the window opens on MAIL_SHOW"
);
// MAIL_SHOW's CheckInbox() queued the inbox-refresh intent.
assert!(s.take_mail_check_inbox(), "MAIL_SHOW fires CheckInbox");
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
assert_eq!(s.eval::<i64>("return GetInboxNumItems()").unwrap(), 2);
// Row 1 painted its sender + subject from the fed state.
assert_eq!(
s.eval::<String>("return MailItem1Sender:GetText()")
.unwrap(),
"Thrall"
);
assert_eq!(
s.eval::<String>("return MailItem1Subject:GetText()")
.unwrap(),
"Warchief's orders"
);
// The row's clickable child button is shown for a populated row, hidden past the 2 mails (the
// reference row FRAME stays shown — only $parentButton toggles; ref InboxFrame_Update l.120/180).
assert!(s.eval::<bool>("return MailItem1Button:IsShown()").unwrap());
assert!(!s.eval::<bool>("return MailItem3Button:IsShown()").unwrap());
// No script errors escaped the event-driven repaints.
assert!(s.take_errors().is_empty(), "clean repaint");
}
#[test]
fn paging_math_enables_next_only_when_overflowing() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
// 9 mails → 2 pages of 7.
let mut inbox = Vec::new();
for i in 0..9 {
inbox.push(row(&format!("S{i}"), &format!("subj{i}"), false, 0, 0));
}
s.set_mail(Some(MailState { inbox }));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
// Page 1: prev disabled, next enabled.
assert!(!s
.eval::<bool>("return InboxPrevPageButton:IsEnabled() ~= 0")
.unwrap());
assert!(s
.eval::<bool>("return InboxNextPageButton:IsEnabled() ~= 0")
.unwrap());
// Turn the page: prev enabled, next disabled (only 2 mails on page 2).
s.run("InboxNextPage()").unwrap();
assert!(s
.eval::<bool>("return InboxPrevPageButton:IsEnabled() ~= 0")
.unwrap());
assert!(!s
.eval::<bool>("return InboxNextPageButton:IsEnabled() ~= 0")
.unwrap());
}
/// The unread/read row *coloring* (SetTextColor / SetVertexColor) has no engine getter to assert
/// against, so the harness verifies the other observable per-row state the same Update paints: the
/// COD tag shows on a COD mail and hides on a plain one, and the plain row's money field is nil.
#[test]
fn cod_tag_shows_on_a_cod_mail() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(MailState {
inbox: vec![
row("Auctioneer", "COD parcel", false, 2589, 5000), // COD 50s
row("Jaina", "A letter", true, 0, 0), // no COD
],
}));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
assert!(
s.eval::<bool>("return MailItem1ButtonCOD:IsShown()")
.unwrap(),
"the COD mail shows its coin tag"
);
assert!(
!s.eval::<bool>("return MailItem2ButtonCOD:IsShown()")
.unwrap(),
"the plain mail hides the COD tag"
);
assert!(s.take_errors().is_empty());
}
#[test]
fn opening_a_letter_shows_the_open_frame_and_queues_the_body() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
let _ = s.take_mail_opens();
// Click row 1: the check button toggles on → open the letter.
// A programmatic click toggles the check button on, then fires OnClick (this = the button).
s.run("MailItem1Button:Click()").unwrap();
assert!(
s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap(),
"the open-letter frame shows"
);
// The sender/subject/body painted; GetInboxText queued the open (mark-read + body ask).
assert_eq!(
s.eval::<String>("return OpenMailSender:GetText()").unwrap(),
"Thrall"
);
assert!(s.take_mail_opens().contains(&1), "opening queued the row");
assert!(s.take_errors().is_empty());
}
/// **The letter and the centre seat exclude each other, in BOTH arrival orders** (decision 1520,
/// director-reported and ref-checked): with the mailbox at the left slot and the character sheet
/// pushed to centre beside it (its pushable=2 row), clicking a mail item must EVICT the sheet —
/// the letter's OnShow is the ref's own (`if GetCenterFrame() then HideUIPanel(...)`, ref
/// MailFrame.xml l.1903-1907) — not open underneath it, which is what a bare Show did. The
/// reverse order is 1507's child-window loop: a frame arriving at centre puts the letter away.
/// Whichever comes second wins the space; they can never stack. A bare stand-in carries the
/// CharacterFrame row (the real file isn't in this harness's chain).
#[test]
fn a_letter_and_the_centre_occupant_evict_each_other() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
let _ = s.take_mail_opens();
// The director's setup: mailbox left, character sheet pushed to centre beside it.
s.run(
r#"local c = CreateFrame("Frame", "CharacterFrame") c:SetSize(50, 50) c:Hide()
ShowUIPanel(CharacterFrame)"#,
)
.unwrap();
assert!(
s.eval::<bool>(
"return GetLeftFrame():GetName() == 'MailFrame' \
and GetCenterFrame():GetName() == 'CharacterFrame'"
)
.unwrap(),
"mail holds left, the sheet was pushed to centre"
);
// Click a mail item: the letter opens AND the sheet is evicted — one thing beside the mailbox.
s.run("MailItem1Button:Click()").unwrap();
assert!(s.take_errors().is_empty());
assert!(
s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap(),
"the letter opened"
);
assert!(
!s.eval::<bool>("return CharacterFrame:IsShown()").unwrap(),
"the centre occupant was evicted, not covered (the reported bug)"
);
assert!(
s.eval::<bool>("return GetCenterFrame() == nil").unwrap(),
"the eviction is a plain vacate — nothing slides"
);
assert!(
s.eval::<bool>("return MailFrame:IsShown() and GetLeftFrame():GetName() == 'MailFrame'")
.unwrap(),
"the mailbox itself is untouched"
);
// The reverse arrival: re-opening the sheet over the open letter puts the LETTER away
// (1507's child-window loop) — the same exclusion, other direction.
s.run("ShowUIPanel(CharacterFrame)").unwrap();
assert!(s.take_errors().is_empty());
assert!(
s.eval::<bool>("return CharacterFrame:IsShown()").unwrap()
&& !s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap(),
"a frame arriving at centre hides the letter"
);
}
#[test]
fn reply_switches_to_send_tab_prefilled() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
// A programmatic click toggles the check button on, then fires OnClick (this = the button).
s.run("MailItem1Button:Click()").unwrap();
s.run("OpenMail_Reply()").unwrap();
// The send tab is now shown, the recipient prefilled, the subject "RE: "-prefixed.
assert!(s.eval::<bool>("return SendMailFrame:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return SendMailNameEditBox:GetText()")
.unwrap(),
"Thrall"
);
assert_eq!(
s.eval::<String>("return SendMailSubjectEditBox:GetText()")
.unwrap(),
"RE: Warchief's orders"
);
}
/// Closing an ordinary letter (no money, no item, textCreated=false) must NOT delete it — the
/// reference OnHide rule (MailFrame.lua l.256-272) only purges a fully-taken husk.
#[test]
fn closing_a_plain_letter_does_not_delete_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(MailState {
inbox: vec![row("One", "test", false, 0, 0)],
}));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
let _ = s.take_mail_opens();
let _ = s.take_mail_deletes();
s.run("MailItem1Button:Click()").unwrap();
assert!(s.eval::<bool>("return OpenMailFrame:IsShown()").unwrap());
s.run("OpenMailCancelButton:Click()").unwrap();
assert!(s.take_errors().is_empty(), "no Lua errors on close");
let deletes = s.take_mail_deletes();
assert!(
deletes.is_empty(),
"closing a plain read letter must NOT delete it, got deletes: {deletes:?}"
);
}
/// Closing a fully-taken husk (no money, no item, textCreated=TRUE) deletes it — the reference
/// OnHide purge. This is also the live "mail vanished on close" moment against vmangos: the server
/// stamps an EMPTY-BODY player mail MAIL_CHECK_MASK_COPIED (`MailHandler.cpp` l.421,
/// `req->body.empty() ? MAIL_CHECK_MASK_COPIED : MAIL_CHECK_MASK_HAS_BODY`), which IS the wire's
/// textCreated bit — so a subject-only letter with nothing attached auto-purges when closed, in
/// the real 1.12 client exactly as here.
#[test]
fn closing_a_taken_husk_deletes_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut husk = row("One", "test", false, 0, 0);
husk.text_created = true;
s.set_mail(Some(MailState { inbox: vec![husk] }));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
let _ = s.take_mail_opens();
let _ = s.take_mail_deletes();
s.run("MailItem1Button:Click()").unwrap();
s.run("OpenMailCancelButton:Click()").unwrap();
assert!(s.take_errors().is_empty(), "no Lua errors on close");
assert_eq!(
s.take_mail_deletes(),
vec![1],
"the husk purges on close (reference OnHide, MailFrame.lua l.256-272)"
);
}
/// The expiry text pluralizes like the reference (GetText("DAYS_ABBR"): "Day"/"Days").
#[test]
fn expiry_text_pluralizes_days() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut one_day = row("Two", "b", false, 0, 0);
one_day.days_left = 1.7;
s.set_mail(Some(MailState {
inbox: vec![row("One", "a", false, 0, 0), one_day],
}));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
assert_eq!(
s.eval::<String>("return MailItem1ExpireTime:GetText()")
.unwrap(),
"|cff20ff2029 Days|r"
);
assert_eq!(
s.eval::<String>("return MailItem2ExpireTime:GetText()")
.unwrap(),
"|cff20ff201 Day|r"
);
assert!(s.take_errors().is_empty());
}
/// The letter button — "make a permanent copy" (ref OpenMail_Update l.364-376): a mail whose body
/// is takeable (`item_text_id != 0`) and not yet copied shows it; clicking queues the
/// `TakeInboxTextItem` intent (→ `CMSG_MAIL_CREATE_TEXT_ITEM`). With money enclosed too, both
/// buttons show and the caption reads "Take Attachments:".
#[test]
fn letter_button_shows_for_a_body_letter_and_click_queues_the_copy() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut mail = row("One", "asd", false, 0, 0);
mail.money = 10000;
s.set_mail(Some(MailState { inbox: vec![mail] }));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
s.run("MailItem1Button:Click()").unwrap();
assert!(
s.eval::<bool>("return OpenMailLetterButton:IsShown()")
.unwrap(),
"a takeable, not-yet-copied body shows the letter button"
);
assert!(s
.eval::<bool>("return OpenMailMoneyButton:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return OpenMailAttachmentText:GetText()")
.unwrap(),
"Take Attachments:"
);
s.run("OpenMailLetterButton:Click()").unwrap();
assert_eq!(
s.take_mail_take_texts(),
vec![1],
"the click queues the permanent-copy intent"
);
assert!(s.take_errors().is_empty());
}
/// Once the body is copied (`textCreated`, the wire COPIED bit) the letter button hides — the same
/// flag whose OnHide purge then deletes the husk on close.
#[test]
fn letter_button_hides_once_copied() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut mail = row("One", "asd", false, 0, 0);
mail.money = 10000;
mail.text_created = true;
s.set_mail(Some(MailState { inbox: vec![mail] }));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
s.run("MailItem1Button:Click()").unwrap();
assert!(!s
.eval::<bool>("return OpenMailLetterButton:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return OpenMailMoneyButton:IsShown()")
.unwrap());
assert!(s.take_errors().is_empty());
}
/// Hovering the coins shows the plain money tooltip (ref OpenMailMoneyButton OnEnter l.1823-1829).
#[test]
fn money_button_hover_shows_the_amount_tooltip() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
let mut mail = row("One", "asd", false, 0, 0);
mail.money = 10000;
s.set_mail(Some(MailState { inbox: vec![mail] }));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
s.run("MailItem1Button:Click()").unwrap();
s.run("BenillaOpenMailMoneyButton_OnEnter(OpenMailMoneyButton)")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip:IsShown()").unwrap(),
"the money tooltip shows on hover"
);
assert!(
s.eval::<bool>("return GameTooltipMoneyCoin1:IsShown()")
.unwrap(),
"the coin row rendered (SetTooltipMoney path)"
);
assert!(s.take_errors().is_empty());
}
/// The inbox page label stays EMPTY: the ref XML declares InboxCurrentPage (l.329) but no
/// reference Lua ever writes it — the real 1.12 window shows nothing there ("Page 1" was our
/// invention, now removed).
#[test]
fn inbox_page_label_stays_empty_like_the_reference() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
let text = s
.eval::<Option<String>>("return InboxCurrentPage:GetText()")
.unwrap();
assert!(
text.as_deref().unwrap_or("").is_empty(),
"InboxCurrentPage must stay unwritten (got {text:?})"
);
}
/// A runtime-shown child `<Frame>` renders its own `<Layers>` FontStrings (decision 1517).
///
/// This pins a fact three window files spent months asserting the opposite of. `SendMailFrame`
/// ships `hidden="true"` and is shown by the tab click; its title lives in its OWN Layers, not on
/// the window root. The "flat layout" those windows adopted was a workaround for a constraint that
/// never existed — and it produced no symptom precisely because it was always obeyed, which is why
/// the claim needed a test rather than a header comment.
#[test]
fn a_runtime_shown_pane_renders_its_own_layers() {
let _data = benilla_formats::wow_data_or_skip!();
let s = UiScript::new().unwrap();
load_ui(&s);
s.eval::<()>("MailFrame:Show() SendMailFrame:Show()")
.unwrap();
assert!(
s.eval::<bool>("return SendMailFrame:IsVisible()").unwrap(),
"the pane itself"
);
assert!(
s.eval::<bool>("return SendMailTitleText:IsVisible()")
.unwrap(),
"a FontString declared inside a runtime-shown child frame's own <Layers>"
);
}
/// The auction house's mail is a RECEIPT, not a letter (decision 1522). Before this the window
/// showed exactly what the server wrote — `From: Unknown / Subject: 5529:0:2` over a body of
/// `6C:10000:10000:25:500` — because nothing parsed it. The subject rewrite is the engine's
/// (`ui_mail::invoice`); this is the pane, and it comes in two shapes off one set of seven values.
///
/// (`From: Unknown` is NOT part of the bug and is not fixed here: an auction mail carries no
/// player sender guid, so the reference's own `if ( not sender ) then sender = UNKNOWN` is what
/// puts that word there — MailFrame.lua l.286-288.)
///
/// The body text assertion is the 1527 fold-back: `GetInboxText` returns **nil** for an invoice by
/// an explicit carve-out in the reference, so `SetText(nil)` leaves the page genuinely empty.
#[test]
fn an_auction_invoice_renders_as_a_receipt() {
let _data = benilla_formats::wow_data_or_skip!();
/// The pane reads the player's own GlobalStrings, which a bare-XML harness has none of. Stand
/// in synthetic ones: what is under test is that each reaches the right region, never their text.
fn strings(s: &UiScript) {
s.run(concat!(
"ITEM_SOLD_COLON = 'SOLD:' PURCHASED_BY_COLON = 'BY:' AMOUNT_RECEIVED_COLON = 'GOT:' ",
"ITEM_PURCHASED_COLON = 'BOUGHT:' SOLD_BY_COLON = 'FROM:' AMOUNT_PAID_COLON = 'PAID:' ",
"BUYOUT = 'Buyout' HIGH_BIDDER = 'High Bidder'",
))
.unwrap();
}
fn open_with(invoice: MailInvoice) -> UiScript {
let mut s = UiScript::new().unwrap();
load_ui(&s);
strings(&s);
let mut inbox = small_inbox();
inbox.inbox[0].is_invoice = true;
inbox.inbox[0].invoice = Some(invoice);
s.set_mail(Some(inbox));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
s.run("MailItem1Button:Click()").unwrap();
s
}
let text = |s: &UiScript, region: &str| {
s.eval::<String>(&format!("return tostring({region}:GetText())"))
.unwrap()
};
let money = |s: &UiScript, frame: &str| {
s.eval::<String>(&format!("return tostring({frame}.staticMoney)"))
.unwrap()
};
// ── The seller's: the full sum. 1g sale + 25c deposit back − 5c the house takes. ──────────
let mut s = open_with(MailInvoice {
seller: true,
item_name: "Linen Cloth".into(),
player_name: "Twowarrior".into(),
bid: 10_000,
buyout: 10_000,
deposit: 25,
consignment: 500,
});
assert!(
s.eval::<bool>("return OpenMailInvoiceFrame:IsShown()")
.unwrap(),
"a sold-auction mail shows the receipt"
);
assert_eq!(text(&s, "OpenMailInvoiceItemLabel"), "SOLD: Linen Cloth");
assert_eq!(text(&s, "OpenMailInvoicePurchaser"), "BY: Twowarrior");
// bid == buyout, so it was bought outright rather than won on a bid.
assert_eq!(text(&s, "OpenMailInvoiceBuyMode"), "(Buyout)");
assert_eq!(money(&s, "OpenMailSalePriceMoneyFrame"), "10000");
assert_eq!(money(&s, "OpenMailDepositMoneyFrame"), "25");
assert_eq!(money(&s, "OpenMailHouseCutMoneyFrame"), "500");
assert_eq!(
money(&s, "OpenMailTransactionAmountMoneyFrame"),
"9525",
"sale + deposit - cut, which is the whole point of the four lines"
);
assert!(s
.eval::<bool>("return OpenMailInvoiceHouseCut:IsShown()")
.unwrap());
assert_eq!(
text(&s, "OpenMailBodyText"),
"nil",
"an invoice has no letter body at all — `GetInboxText` nils it (1527), so the receipt has \
nothing to sit on top of"
);
assert!(s.take_errors().is_empty());
// ── The buyer's: one line, and the seller-only rows gone. Won on a bid, not bought out. ───
let mut s = open_with(MailInvoice {
seller: false,
item_name: "Small Blue Pouch".into(),
player_name: "Onewarrior".into(),
bid: 9_000,
buyout: 10_000,
deposit: 0,
consignment: 0,
});
assert!(s
.eval::<bool>("return OpenMailInvoiceFrame:IsShown()")
.unwrap());
assert_eq!(
text(&s, "OpenMailInvoiceItemLabel"),
"BOUGHT: Small Blue Pouch (High Bidder)",
"the buy mode rides the item line here, not the purchaser line"
);
assert_eq!(text(&s, "OpenMailInvoicePurchaser"), "FROM: Onewarrior");
assert_eq!(text(&s, "OpenMailInvoiceBuyMode"), "");
assert_eq!(money(&s, "OpenMailTransactionAmountMoneyFrame"), "9000");
for gone in [
"OpenMailInvoiceSalePrice",
"OpenMailInvoiceDeposit",
"OpenMailInvoiceHouseCut",
"OpenMailSalePriceMoneyFrame",
"OpenMailDepositMoneyFrame",
"OpenMailHouseCutMoneyFrame",
] {
assert!(
!s.eval::<bool>(&format!("return {gone}:IsShown()")).unwrap(),
"{gone} is a seller-only line"
);
}
assert!(s.take_errors().is_empty());
}
/// A mail that is NOT an auction invoice keeps its letter: the pane stays hidden and the body text
/// survives. The blanking above is aimed at one kind of mail and must not reach any other.
#[test]
fn a_plain_letter_keeps_its_body_and_shows_no_receipt() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_mail(Some(small_inbox()));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
s.run("MailItem1Button:Click()").unwrap();
assert!(!s
.eval::<bool>("return OpenMailInvoiceFrame:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return OpenMailBodyText:GetText()")
.unwrap(),
"body"
);
assert!(s.take_errors().is_empty());
}
/// The open letter's ring holds an ordinary **item** icon (`INV_Misc_Note_01`), so it has to go
/// through the PORTRAIT verb and not a bare `SetTexture` — which is exactly what the reference
/// reaches for here, and nowhere else in the file (`MailFrame.lua` l.174).
///
/// Set raw, the icon's square dark border shows through the ring's transparent corners as four
/// little squares around the circle (director's report, 2026-08-22). The INBOX window's ring is the
/// control: it holds `Mail-Icon`, purpose-drawn art that needs no mask, and the reference leaves
/// that one at its `file=` (ref l.258) — so a blanket "mask every ring" would be wrong too.
#[test]
fn the_open_letters_ring_icon_is_masked_but_the_inboxs_is_not() {
let _data = benilla_formats::wow_data_or_skip!();
use benilla_ui::script::QuadContent;
let mut s = UiScript::new().unwrap();
load_ui(&s);
s.set_screen_size(1024.0, 768.0);
s.set_mail(Some(small_inbox()));
s.fire_event("MAIL_SHOW", vec![]);
s.fire_event("MAIL_INBOX_UPDATE", vec![]);
s.run("MailItem1Button:Click()").unwrap();
s.resolve();
let masked = |needle: &str| -> Vec<bool> {
s.extract()
.into_iter()
.filter_map(|q| match q.content {
QuadContent::Texture {
path: Some(p),
circular,
..
} if p.contains(needle) => Some(circular),
_ => None,
})
.collect()
};
let stationery = masked("INV_Misc_Note_01");
assert!(
!stationery.is_empty(),
"the stationery icon should be drawn somewhere"
);
assert!(
stationery.contains(&true),
"the open letter's ring icon draws masked to its inscribed circle; got {stationery:?}"
);
let mail_icon = masked("Mail-Icon");
assert!(
!mail_icon.contains(&true),
"the inbox window's own ring is purpose-drawn art and stays raw, as in the reference; \
got {mail_icon:?}"
);
}