- the world map's arrow shows on every zone sheet, and alt-clicking a town jumps there: the stock file creates the player arrow as a `Model` child of `WorldMapFrame` and lays a zone sheet's explored-area overlays as ARTWORK textures on `WorldMapDetailFrame` — one `(strata, level)` bucket, where the draw layer outranks the frame — and benilla drew a model's scene at the frame's own layer-0 slot, so every overlay painted over it (the continent sheet has no overlays, which is why it was the only sheet with an arrow); wow-re's carve: a layer batch carries three sub-arrays — quads, the string batch, a render-callback list — that `0x76fb00` drains in that order, and `0x76d160` registers a model's callback only for layer 2 (`0x76d17f cmp ebx,2`), so a model's scene is ARTWORK content, neither a separate pass nor a layer-0 slot; a `ModelPane` quad now keys at the ARTWORK content rank, which re-orders the pet page's happiness icon (a sibling's BACKGROUND quad) under the pane's scene — the reference's own order, harmless because the bake is transparent where the model is not; a model against a LATER-linked ARTWORK font string in the same bucket is still ordered by link stamp, named not built; and the alt-click jump, which required the topmost frame under the cursor to be `WorldMapButton` itself while the stock file parents every POI icon and blip to that button, now walks up from whatever was hit to the button and takes its rect — the map's own chrome still answers nothing (1992) - `UIParent` is the reference's own: `UIParent.xml` loads off the chain at its toc's slot, right after `WorldFrame.xml`, sourcing `UIParent.lua` and `LocaleProperties.lua` itself, and our `UIParent.xml` + `UiPanels.xml` — 3086 lines of transcribed `UIParent.lua` — are deleted; the one unbound verb the stock file ticks, `CheckReadyCheckTime`, was already 1989's; back from the reference: all sixty-six registered events and their arms (`PLAYER_CONTROL_LOST`/`_GAINED`, `RAID_ROSTER_UPDATE`, the trade-request pair, `PLAYER_SKINNED`, `GOSSIP_ENTER_CODE`), the ESC ladder as written — StaticPopup, the options window's Cancel, the game menu, `CloseMenus`, `SpellStopCasting`, `SpellStopTargeting`, `CloseAllWindows`, `ClearTarget`, else open — so ESC no longer clears a held cursor item or cancels the colour picker, two arms ours had grown that the reference does not have; the fade/flash/pulse kits walked from `UIParent`'s own OnUpdate in place of three 1×1 driver frames of ours, so a cinematic's `UIParent:Hide()` stops the walks as it does there; the managed bottom-stack positions without our `UIParent_RegisterManagedPositionListener` extension, which no shipped file registered any more; `LowHealthFrame`/`OutOfControlFrame`; and the `firstTimeLoaded` close on re-show; what stayed ours moved out — the window tab template and its OnUpdate settle (1004's async-measure adapter) and `BENILLA_FALLBACK_ICON` to `ScrollTemplates.xml`, 0561's bag verbs to `ContainerFrameAdapters`, the script log's slot row to its own file, `OptionsFrameCancel = OptionsFrameCloseButton` beside the two options aliases — and the Auction/Inspect/Macro/Talent panel rows, verbatim copies of the LoadOnDemand addons' own registrations, go; the reference also decides that the area spirit healer's dialog accepts on show with one CANCEL button, that losing control closes every window bar the reference's exceptions, and that the bottom-bar clearance reads the saved globals `SHOW_MULTI_ACTIONBAR_1`/`_2` rather than the bars' shown state; three integration binaries that kept private copies of the manifest loader use the shared one now (1988) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
742 lines
29 KiB
Rust
742 lines
29 KiB
Rust
//! 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.
|
||
|
||
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", // the window tab template, ours (1004/1988)
|
||
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\\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"), "");
|
||
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:?}"
|
||
);
|
||
}
|