//! 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; 6] = [ "Fonts.xml", "MoneyFrame.xml", "UiPanels.xml", "GameTooltip.xml", "MerchantFrame.xml", "MailFrame.xml", ]; fn load_ui(script: &UiScript) { let dir = std::path::Path::new(UI_DIR); let provider = |req: &str| -> Option> { let norm = req.replace('\\', "/"); let base = norm.rsplit('/').next().unwrap_or(&norm); std::fs::read(dir.join(&norm)) .or_else(|_| std::fs::read(dir.join(base))) .ok() }; for file in FILES { let text = std::fs::read_to_string(dir.join(file)).unwrap_or_else(|e| { panic!("reading {file}: {e}"); }); let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| { panic!("parsing {file}: {e}"); }); let report = benilla_ui::loader::load(script, &doc, &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 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::(&format!("return getglobal('{name}') ~= nil")) .unwrap(), "region {name} should exist" ); } // The window is hidden until MAIL_SHOW. assert!(!s.eval::("return MailFrame:IsShown()").unwrap()); } #[test] fn mail_show_opens_and_inbox_populates() { let mut s = UiScript::new().unwrap(); load_ui(&s); s.set_mail(Some(small_inbox())); s.fire_event("MAIL_SHOW", vec![]); assert!( s.eval::("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::("return GetInboxNumItems()").unwrap(), 2); // Row 1 painted its sender + subject from the fed state. assert_eq!( s.eval::("return MailItem1Sender:GetText()") .unwrap(), "Thrall" ); assert_eq!( s.eval::("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::("return MailItem1Button:IsShown()").unwrap()); assert!(!s.eval::("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 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::("return InboxPrevPageButton:IsEnabled()") .unwrap()); assert!(s .eval::("return InboxNextPageButton:IsEnabled()") .unwrap()); // Turn the page: prev enabled, next disabled (only 2 mails on page 2). s.run("InboxNextPage()").unwrap(); assert!(s .eval::("return InboxPrevPageButton:IsEnabled()") .unwrap()); assert!(!s .eval::("return InboxNextPageButton:IsEnabled()") .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 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::("return MailItem1ButtonCOD:IsShown()") .unwrap(), "the COD mail shows its coin tag" ); assert!( !s.eval::("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 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::("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::("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 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::( "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::("return OpenMailFrame:IsShown()").unwrap(), "the letter opened" ); assert!( !s.eval::("return CharacterFrame:IsShown()").unwrap(), "the centre occupant was evicted, not covered (the reported bug)" ); assert!( s.eval::("return GetCenterFrame() == nil").unwrap(), "the eviction is a plain vacate — nothing slides" ); assert!( s.eval::("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::("return CharacterFrame:IsShown()").unwrap() && !s.eval::("return OpenMailFrame:IsShown()").unwrap(), "a frame arriving at centre hides the letter" ); } #[test] fn reply_switches_to_send_tab_prefilled() { 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::("return SendMailFrame:IsShown()").unwrap()); assert_eq!( s.eval::("return SendMailNameEditBox:GetText()") .unwrap(), "Thrall" ); assert_eq!( s.eval::("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 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::("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 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 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::("return MailItem1ExpireTime:GetText()") .unwrap(), "|cff20ff2029 Days|r" ); assert_eq!( s.eval::("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 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::("return OpenMailLetterButton:IsShown()") .unwrap(), "a takeable, not-yet-copied body shows the letter button" ); assert!(s .eval::("return OpenMailMoneyButton:IsShown()") .unwrap()); assert_eq!( s.eval::("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 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::("return OpenMailLetterButton:IsShown()") .unwrap()); assert!(s .eval::("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 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::("return GameTooltip:IsShown()").unwrap(), "the money tooltip shows on hover" ); assert!( s.eval::("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 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::>("return InboxCurrentPage:GetText()") .unwrap(); assert!( text.as_deref().unwrap_or("").is_empty(), "InboxCurrentPage must stay unwritten (got {text:?})" ); } /// A runtime-shown child `` renders its own `` 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 s = UiScript::new().unwrap(); load_ui(&s); s.eval::<()>("MailFrame:Show() SendMailFrame:Show()") .unwrap(); assert!( s.eval::("return SendMailFrame:IsVisible()").unwrap(), "the pane itself" ); assert!( s.eval::("return SendMailTitleText:IsVisible()") .unwrap(), "a FontString declared inside a runtime-shown child frame's own " ); } /// 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() { /// 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::(&format!("return tostring({region}:GetText())")) .unwrap() }; let money = |s: &UiScript, frame: &str| { s.eval::(&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::("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::("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::("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::(&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 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::("return OpenMailInvoiceFrame:IsShown()") .unwrap()); assert_eq!( s.eval::("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() { 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 { 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:?}" ); }