//! Drives the REAL `assets/ui/TradeFrame.xml` through the engine (decision 0592 P1) — the trade twin //! of `mail_frame.rs`: it loads the same file chain the app does (cut to the trade window's dependency //! prefix), pushes a synthetic two-sided offer, opens the window with the app's own `TRADE_SHOW` //! event, and asserts the transcribed Lua actually paints — the named regions exist, both columns //! populate from a fed `TradeState`, the money coin trios render, and the accept glow tracks the //! `TRADE_ACCEPT_UPDATE(my, his)` args. This is the machine gate for the XML (a Lua error / missing //! global / wrong region name fails here); the director's eye judges only the *look*. use benilla_ui::script::{ScriptValue, TradeSideState, TradeSlotItem, TradeState, UiScript}; const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui"); /// The trade window's load prefix — the app's own order (`ui_script/mod.rs`), members only. /// MerchantFrame.xml rides along because TradeFrame.xml reuses its global `BenillaMoney_*` coin /// helpers (the two gold displays), so a load error in either fails here. const FILES: [&str; 5] = [ "Fonts.xml", "UiPanels.xml", "GameTooltip.xml", "MerchantFrame.xml", "TradeFrame.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_to_string(dir.join(&norm)) .or_else(|_| std::fs::read_to_string(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 ); } } fn item(item_id: u32, name: &str, count: u32, quality: u32) -> TradeSlotItem { TradeSlotItem { item_id, name: Some(name.to_string()), texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".to_string()), count, quality: Some(quality), enchantment: None, } } /// A two-sided offer: we offer Linen Cloth ×5 + 1g23s45c; the partner offers Silk Cloth + 5s. fn state() -> TradeState { let mut player = TradeSideState { gold: 12_345, ..Default::default() }; player.slots[0] = Some(item(2589, "Linen Cloth", 5, 1)); let mut target = TradeSideState { gold: 500, ..Default::default() }; target.slots[0] = Some(item(4306, "Silk Cloth", 1, 2)); TradeState { player, target, partner_name: Some("Thrall".into()), } } #[test] fn trade_frame_loads_and_key_regions_exist() { let s = UiScript::new().unwrap(); load_ui(&s); for name in [ "BenillaTradeFrame", "BenillaTradePlayerItem1", "BenillaTradePlayerItem1ItemButton", "BenillaTradePlayerItem7", // the enchant slot "BenillaTradeRecipientItem1", "BenillaTradeRecipientItem7", "BenillaTradeFrameTradeButton", "BenillaTradeFrameCancelButton", "BenillaTradePlayerInputMoneyGold", // our gold is now the editable input (P2) "BenillaTradeRecipientMoneyFrameCoin1", "BenillaTradeHighlightPlayer", "BenillaTradeHighlightRecipientEnchant", ] { assert!( s.eval::(&format!("return getglobal('{name}') ~= nil")) .unwrap(), "region {name} should exist" ); } // The window is hidden until TRADE_SHOW. assert!(!s .eval::("return BenillaTradeFrame:IsShown()") .unwrap()); } #[test] fn trade_show_opens_and_both_columns_populate() { let mut s = UiScript::new().unwrap(); load_ui(&s); s.set_trade(Some(state())); s.fire_event("TRADE_SHOW", vec![]); assert!( s.eval::("return BenillaTradeFrame:IsShown()") .unwrap(), "the window opens on TRADE_SHOW" ); // ...and it is SLOTTED into the left panel, not merely Show()n unpositioned. Without the // UIPanelWindows["BenillaTradeFrame"] row, ShowUIPanel takes its unregistered branch — a bare // frame:Show() with no SetLeftFrame placement — which still flips IsShown() but leaves the // window off its panel slot, so it never lands on screen (the live "OPEN_WINDOW arrived on both // clients, yet no window" bug). This is the assertion that catches that missing registration. assert_eq!( s.eval::("return GetLeftFrame() and GetLeftFrame():GetName() or ''") .unwrap(), "BenillaTradeFrame", "TRADE_SHOW slots the window into the left panel" ); // Our slot 1 and the partner's slot 1 painted their names + icons from the fed state. assert_eq!( s.eval::("return BenillaTradePlayerItem1Name:GetText()") .unwrap(), "Linen Cloth" ); assert_eq!( s.eval::("return BenillaTradeRecipientItem1Name:GetText()") .unwrap(), "Silk Cloth" ); assert!( s.eval::("return BenillaTradePlayerItem1ItemButtonIcon:IsShown()") .unwrap(), "a filled slot shows its icon" ); // An empty slot clears its name + hides its icon. assert_eq!( s.eval::("return BenillaTradePlayerItem2Name:GetText()") .unwrap(), "" ); assert!(!s .eval::("return BenillaTradePlayerItem2ItemButtonIcon:IsShown()") .unwrap()); // The partner's name paints from GetTradePartnerName(). assert_eq!( s.eval::("return BenillaTradeFrameRecipientNameText:GetText()") .unwrap(), "Thrall" ); // The partner's read-only gold rendered a coin (5s → 1 coin); our own gold is the editable input, // exercised in `player_money_input_reflects_then_offers`. assert!(s .eval::("return BenillaTradeRecipientMoneyFrameCoin1:IsShown()") .unwrap()); assert!(s.take_errors().is_empty(), "clean repaint"); } #[test] fn enchant_slot_shows_the_not_traded_note() { let mut s = UiScript::new().unwrap(); load_ui(&s); let mut st = state(); // Park an item in our enchant slot (index 6 = slot 7). st.player.slots[6] = Some(item(6217, "Copper Rod", 1, 1)); s.set_trade(Some(st)); s.fire_event("TRADE_SHOW", vec![]); // Slot 7 with an item but no enchant shows the coloured "will not be traded" note. assert_eq!( s.eval::("return BenillaTradePlayerItem7Name:GetText()") .unwrap(), "|cffffffffWill Not Be Traded|r" ); assert!(s.take_errors().is_empty()); } #[test] fn accept_update_drives_the_column_glows() { let mut s = UiScript::new().unwrap(); load_ui(&s); s.set_trade(Some(state())); s.fire_event("TRADE_SHOW", vec![]); // Highlights start hidden (TradeFrame_Update hides all four). assert!(!s .eval::("return BenillaTradeHighlightRecipient:IsShown()") .unwrap()); // The partner accepts: their column + enchant glow show, ours stay hidden. s.fire_event( "TRADE_ACCEPT_UPDATE", vec![ScriptValue::Int(0), ScriptValue::Int(1)], ); assert!(s .eval::("return BenillaTradeHighlightRecipient:IsShown()") .unwrap()); assert!(s .eval::("return BenillaTradeHighlightRecipientEnchant:IsShown()") .unwrap()); assert!(!s .eval::("return BenillaTradeHighlightPlayer:IsShown()") .unwrap()); // We accept: our glow shows and the Trade button disables (the reference's own-accept lock). s.fire_event( "TRADE_ACCEPT_UPDATE", vec![ScriptValue::Int(1), ScriptValue::Int(0)], ); assert!(s .eval::("return BenillaTradeHighlightPlayer:IsShown()") .unwrap()); assert!(!s .eval::("return BenillaTradeFrameTradeButton:IsEnabled()") .unwrap()); assert!(s.take_errors().is_empty()); } #[test] fn closing_the_window_queues_the_cancel() { let mut s = UiScript::new().unwrap(); load_ui(&s); s.set_trade(Some(state())); s.fire_event("TRADE_SHOW", vec![]); let _ = s.take_trade_close(); // The X button hides the window → OnHide → CloseTrade queues the local close/cancel verb. s.run("BenillaTradeFrameCloseButton:Click()").unwrap(); assert!(!s .eval::("return BenillaTradeFrame:IsShown()") .unwrap()); assert!(s.take_trade_close(), "closing queued the CloseTrade intent"); assert!(s.take_errors().is_empty()); // TRADE_CLOSED from the server-driven path also hides it. s.fire_event("TRADE_SHOW", vec![]); assert!(s .eval::("return BenillaTradeFrame:IsShown()") .unwrap()); s.fire_event("TRADE_CLOSED", vec![]); assert!(!s .eval::("return BenillaTradeFrame:IsShown()") .unwrap()); } #[test] fn trade_button_click_queues_accept() { let mut s = UiScript::new().unwrap(); load_ui(&s); s.set_trade(Some(state())); s.fire_event("TRADE_SHOW", vec![]); let _ = s.take_trade_accept(); s.run("BenillaTradeFrameTradeButton:Click()").unwrap(); assert!(s.take_trade_accept(), "the Trade button queues AcceptTrade"); assert!(s.take_errors().is_empty()); } /// The editable player money input (decision 0592 P2): the server's PLAYER_TRADE_MONEY echo reflects /// the accepted gold into the three boxes without re-offering (the diff-guarded SetCopper), and a /// keystroke offers the running copper total through SetTradeMoney → the app's SET_TRADE_GOLD. #[test] fn player_money_input_reflects_then_offers() { let mut s = UiScript::new().unwrap(); load_ui(&s); s.set_trade(Some(state())); // state().player.gold == 12345 (1g 23s 45c) s.fire_event("TRADE_SHOW", vec![]); // The three numeric boxes exist and start empty. for box_ in ["Gold", "Silver", "Copper"] { assert!( s.eval::(&format!( "return getglobal('BenillaTradePlayerInputMoney{box_}') ~= nil" )) .unwrap(), "money box {box_} exists" ); } // Reflection: PLAYER_TRADE_MONEY fills the boxes from GetPlayerTradeMoney(), guarded so the // programmatic fill does NOT re-offer (no spurious SET_TRADE_GOLD). let _ = s.take_trade_money(); s.fire_event("PLAYER_TRADE_MONEY", vec![]); assert_eq!( s.eval::<(String, String, String)>( "return BenillaTradePlayerInputMoneyGold:GetText(), \ BenillaTradePlayerInputMoneySilver:GetText(), \ BenillaTradePlayerInputMoneyCopper:GetText()" ) .unwrap(), ("1".into(), "23".into(), "45".into()), "the echo reflects 1g 23s 45c into the boxes" ); assert_eq!( s.take_trade_money(), None, "the guarded reflect does not re-offer" ); // A genuine keystroke offers the running total (bypass the affordability clamp — a bare harness // has no purse). s.run("GetMoney = function() return 100000000 end").unwrap(); s.run("BenillaTradePlayerInputMoneyGold:SetText('2')") .unwrap(); assert_eq!( s.take_trade_money(), Some(2 * 10000 + 23 * 100 + 45), "typing offers the copper total via SetTradeMoney" ); assert!(s.take_errors().is_empty()); } /// The trade slot buttons route through the shared drop handler (decision 0592 P2): clicking OUR /// filled slot clears it (ClickTradeButton → CLEAR_TRADE_ITEM), while the partner's column is inert /// (ClickTargetTradeButton). Exercises the real button OnClick → BenillaTradeSlot_OnClick → /// player/recipient name-dispatch, without needing a cursor payload (the empty-cursor clear path). #[test] fn slot_click_routes_player_to_clear_and_recipient_to_inert() { let mut s = UiScript::new().unwrap(); load_ui(&s); s.set_trade(Some(state())); // player slot 1 + recipient slot 1 are both filled s.fire_event("TRADE_SHOW", vec![]); let _ = s.take_trade_clear_items(); // Our filled slot 1 clicked with an empty cursor → a clear routed through ClickTradeButton. s.run("BenillaTradePlayerItem1ItemButton:Click()").unwrap(); assert_eq!( s.take_trade_clear_items(), vec![1], "clicking our filled slot clears it (ClickTradeButton)" ); // The partner's filled slot is read-only — the click hits ClickTargetTradeButton, which queues // nothing on either channel. s.run("BenillaTradeRecipientItem1ItemButton:Click()") .unwrap(); assert!( s.take_trade_clear_items().is_empty() && s.take_trade_set_items().is_empty(), "the partner slot is inert (ClickTargetTradeButton)" ); assert!(s.take_errors().is_empty()); }