Files
benilla-octo/crates/benilla-app/tests/mail_frame.rs
T
gromandClaude Opus 5 81d96af35d Everwood: the HD/OctoWoW line of benilla, imported onto upstream
This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.

Today's work, which is the reason a baseline exists at all:

- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
  characters were entering the sun's shadow cascades, so every skinned-rig
  shadow specialization compiled live mid-frame; the hitch detector logged
  "main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
  the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
  tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
  an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
  and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
  measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
  frame-cost reading no longer pays 2.7 ms a frame for it.

Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-15 15:01:14 -06:00

818 lines
29 KiB
Rust

//! Drives the stock `MailFrame.xml` off the player's chain with a synthetic inbox and the app's
//! own `MAIL_SHOW`/`MAIL_INBOX_UPDATE` events, and asserts what it paints and queues.
mod common;
use benilla_ui::script::{MailInboxRow, MailInvoice, MailState, UiScript};
/// The mail window's load prefix, in the app's order.
const FILES: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
// The send tab's money entry: `MoneyInputFrameTemplate` and the `MoneyInputFrame_*` verbs.
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
"Interface\\FrameXML\\GameTooltip.xml",
r"Interface\FrameXML\ItemButtonTemplate.xml", // the send tab's attachment slot inherits it
// The tabs inherit `FriendsFrameTabTemplate` and `inherits=` resolves at load, so the social
// window and its kit come first, as in the stock toc.
"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 Linen Cloth stack in the backpack, the send tab's attachment: `GetSendMailItem` answers off
/// its bag slot, so it needs a real name and texture.
fn one_linen_backpack() -> benilla_ui::script::ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
benilla_ui::script::ContainerSlot {
item_id: 2589,
count: 20,
quality: Some(1),
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
link: Some("|cffffffff|Hitem:2589|h[Linen Cloth]|h|r".into()),
bar_placeable: true,
..Default::default()
},
);
benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
/// 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"
);
// Only the row's `$parentButton` toggles; the row frame stays shown (`MailFrame.lua: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());
}
#[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();
// 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 open letter and the centre panel exclude each other in both orders: the letter's OnShow
/// hides the centre frame (`MailFrame.xml:1903-1907`), and a frame arriving at centre hides the
/// letter. A bare stand-in carries the `CharacterFrame` row.
#[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();
// Mailbox left, character sheet pushed to centre beside it.
s.run(
r#"local c = CreateFrame("Frame", "CharacterFrame") c:SetWidth(50); c:SetHeight(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.
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: re-opening the sheet over the open letter puts the letter away.
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"
);
}
/// `OpenMailFrame_OnHide` deletes only a mail with no money, no item and `textCreated`
/// (`MailFrame.lua:256-272`).
#[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:?}"
);
}
/// A mail with no money, no item and `textCreated` is deleted on close. vmangos marks an
/// empty-body player mail COPIED, the wire's `textCreated` bit (`MailHandler.cpp:421`), so a
/// subject-only letter with nothing attached is deleted when closed.
#[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 via `GetText("DAYS_ABBR", nil, n)` and keeps a trailing space before
/// the colour close (`MailFrame.lua: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 permanent-copy letter button shows for a takeable, not yet copied body
/// (`MailFrame.lua:364-376`); a click queues `TakeInboxTextItem` (`CMSG_MAIL_CREATE_TEXT_ITEM`).
#[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.
#[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 money tooltip (`MailFrame.xml: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 inline handler, which reads `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());
}
/// `InboxCurrentPage` is declared (`MailFrame.xml:329`) but no stock Lua writes it.
#[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: `SendMailFrame` ships
/// `hidden="true"` and holds its title in its own Layers.
#[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>"
);
}
/// An auction mail opens as a receipt, in a seller and a buyer shape; its subject is rewritten
/// by `ui_mail::invoice`. It has no player sender, so the stock Lua shows `UNKNOWN`
/// (`MailFrame.lua:286-288`), and `GetInboxText` returns nil for an invoice.
#[test]
fn an_auction_invoice_renders_as_a_receipt() {
let _data = benilla_formats::wow_data_or_skip!();
/// Synthetic GlobalStrings: the test checks each reaches the right region, not the 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: 1g sale + 25c deposit back - 5c house cut. ──────────────────────────────
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: bought outright.
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, so the receipt has \
nothing to sit on top of"
);
assert!(s.take_errors().is_empty());
// ── The buyer's: one line, the seller-only rows hidden, won on a bid. ────────────────────
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; 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 invoice keeps its body and shows no receipt.
#[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 takes an item icon through `SetPortraitToTexture` (`MailFrame.lua:174`),
/// so it draws masked; the inbox ring's `Mail-Icon` stays a plain `file=` (`MailFrame.xml:258`).
#[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:?}"
);
}
/// Only `SendMailNameEditBox` has an `<OnChar>`, which puts it in the keyboard walk first. An edit
/// box's `OnChar` slot `+0x5c` is `0x77a900`, which declines (`0x77a956`) when another box has
/// focus and never chains to the base `CSimpleFrame` gate (`script::keyboard::is_editbox`).
#[test]
fn every_send_tab_box_takes_a_keystroke_not_just_the_one_with_an_onchar() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_ui(&s);
s.fire_event("MAIL_SHOW", vec![]);
s.run("MailFrameTab_OnClick(2)").unwrap();
s.resolve();
assert!(s.eval::<bool>("return SendMailFrame:IsVisible()").unwrap());
// The `<OnChar>` box types, and its OnChar fires once per character, from the insert.
s.run("SendMailNameEditBox:SetText('') SendMailNameEditBox:SetFocus()")
.unwrap();
s.run("SendMailChars = 0 \
SendMailNameEditBox:SetScript('OnChar', function() SendMailChars = SendMailChars + 1 end)")
.unwrap();
for c in ["T", "h", "r"] {
assert!(s.char_input(c), "the focused box consumes");
}
assert_eq!(
s.eval::<String>("return SendMailNameEditBox:GetText()")
.unwrap(),
"Thr"
);
assert_eq!(
s.eval::<i64>("return SendMailChars").unwrap(),
3,
"OnChar fires once per character, from the insert — not a second time from the walk"
);
// The other four boxes; the money boxes are `numeric`.
for (box_name, typed, expect) in [
("SendMailSubjectEditBox", ["H", "e", "y"], "Hey"),
("SendMailBodyEditBox", ["o", "d", "d"], "odd"),
("SendMailMoneyGold", ["1", "2", "3"], "123"),
("SendMailMoneySilver", ["4", "5", "5"], "45"), // letters="2"
] {
s.run(&format!("{box_name}:SetText('') {box_name}:SetFocus()"))
.unwrap();
for c in typed {
assert!(s.char_input(c), "{box_name} consumes the keystroke");
}
assert_eq!(
s.eval::<String>(&format!("return {box_name}:GetText()"))
.unwrap(),
expect,
"{box_name} takes what was typed into it"
);
}
assert!(s.take_errors().is_empty(), "and nothing raised on the way");
}
/// `UiScript::reset_compose_tab` is `0x4acdc0(1)`: it clears the attachment before firing the
/// events, since `SendMailFrame_Reset` ends in `SendMailFrame_Update`, reading `GetSendMailItem`.
#[test]
fn the_compose_reset_clears_the_subject_and_the_attachment_together() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_ui(&s);
s.set_container(0, Some(one_linen_backpack()));
s.fire_event("MAIL_SHOW", vec![]);
s.run("MailFrameTab_OnClick(2)").unwrap();
// Pick the item up and click the send slot; the stock tab names the letter after it.
s.run("PickupContainerItem(0, 1) ClickSendMailItemButton()")
.unwrap();
s.fire_event("MAIL_SEND_INFO_UPDATE", vec![]);
assert_eq!(
s.eval::<String>("return SendMailSubjectEditBox:GetText()")
.unwrap(),
"Linen Cloth (20)",
"the stock tab titles the letter after its attachment"
);
// The send lands.
s.reset_compose_tab();
assert_eq!(
s.eval::<String>("return SendMailSubjectEditBox:GetText()")
.unwrap(),
"",
"the subject does not come back from the attachment that just left"
);
assert!(
s.eval::<bool>("return GetSendMailItem() == nil").unwrap(),
"and the attachment is gone with it"
);
}