Files
benilla-octo/crates/benilla-app/tests/mail_frame.rs
T
SamandClaude Fable 5.1 c775181933 the cooldown pie survives a /reload, and Bagnon's bags keep their order
- the cooldown pie, the GCD sweep, minimap pings and the map arrow draw again after a logout, login or /reload; they went blank until a restart, which read as OmniCC killing the cooldowns (2113)
- an addon's own font on a button label keeps its face, size and outline instead of being undone every frame, MikScrollingBattleText's reported look (2112)
- an empty label reads as nothing rather than an empty string, so Cartographer's map label keeps its colour; a slider thumb with no authored size takes its art's own size, so Dewdrop's Look'n'Feel slider moves (2109, 2110)
- Bagnon's saved bag list comes back in the order it was written, so the keyring stops jumping to the front after a restart; hovering a Bagnon bag costs a third of what it did (2111, 2114)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 20:49:50 +07:00

748 lines
29 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 stock `Interface\FrameXML\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.
mod common;
use benilla_ui::script::{MailInboxRow, MailInvoice, MailState, UiScript};
/// 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] = &[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\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",
"Interface\\FrameXML\\GlobalStrings.lua",
r"Interface\FrameXML\UIParent.xml",
"ScrollTemplates.xml", // our scroll kit + the placeholder icon
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml", // the dialog engine (1960)
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\ItemButtonTemplate.xml", // the send tab's attachment slot inherits it
// The stock tabs inherit `FriendsFrameTabTemplate`, and `inherits=` resolves at load — so the
// social window and the kit it needs come first, as the reference's toc has them (1970).
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\FriendsFrame.xml",
"Interface\\FrameXML\\MailFrame.xml",
];
fn load_ui(script: &UiScript) {
for file in FILES {
common::load_ui(script, file);
}
}
/// 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", nil, n)`: "Day"/"Days") —
/// and carries the reference's own trailing space before the colour close (MailFrame.lua l.144).
#[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();
// The hover is the button's own inline handler (stock MailFrame.xml), reading `this`.
s.run("this = OpenMailMoneyButton OpenMailMoneyButton:GetScript(\"OnEnter\")()")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip:IsShown()").unwrap(),
"the money tooltip shows on hover"
);
assert!(
s.eval::<bool>("return GameTooltipMoneyFrame: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"),
"nil",
"the buy-mode line is blank on a bid win, and a blank FontString reads back nil — \
`FontString:GetText 0x79d690` substitutes (decision 2110); this helper `tostring`s it"
);
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:?}"
);
}