- a comment that justifies code by an engine difference is a claim with an expiry date, and the tree had ~96 expired ones across `assets/ui`, `ui_script/` and `benilla-ui` — a hygiene pass, no behaviour change: dangling `Benilla*` names with zero definitions (`BenillaMoney_*` at thirteen sites after 1937/1962 converged the coin cluster onto the stock `SmallMoneyFrameTemplate`; `BenillaGossipRow_Resize`, `BenillaErrorsFrame_AddMessage`, the digit feed 1285 deleted, `BenillaMerchantItem_OnClick` and three more), "the shipped `assets/ui/<X>.xml`" for thirty-odd files the directory no longer holds — it holds nine — each site now naming the chain file that actually loads, `UiPanels.xml` in the present tense after 1988 took the panel manager and the dialog engine stock, and expired self-deprecation: SimpleHTML "a widget kind we do not have" (1337 built it), "benilla has no console" (1948 bound `ConsoleExec`), `Model`'s "seven verbs not published yet" and "`ClearFog` is a guess" (all 23 are published, the carve sits 500 lines below, `UNBUILT` has been `[&str; 0]` since 2027), `frame_flag_gate`'s doc describing four groups of accepted mouse divergences over a `KNOWN` list that is empty (the empty array and its enforcement stay), five comments saying key delivery is not gated on `keyboard_enabled` where `script::keyboard`'s walk filters on exactly that field (1319), `SetRaidTarget` called a verb this house lacks beside an assert that its intent IS queued (1820), two questlog tests deferring to a screenshot for `GetPoint`/`IsVisible` the regions already answer, `benilla.toc`'s "trade still needs four engine verbs" (all four bound, 1966) and its ping `Model`s "this engine draws no pixels for" (2013 renders `MinimapPing.mdx` itself), and `rig_anim`'s `set_paused` naming a booth caller it does not have; and the code the prose was propping up goes with it: `GameMenuFrame.xml`'s six `<OnLoad>` label setters, because `text=` on a `<Button>` is a global-string lookup at the loader's attribute step (0995, built 61 seconds before the file was written) — the buttons carry `text="LOGOUT"`/`"EXIT_GAME"`/`"MACROS"`/`"RETURN_TO_GAME"`/`"MAIN_MENU"` as the reference's own do; `ScriptLogFrame`'s five authored close-button lines, now `inherits="UIPanelCloseButton"` off the chain's `UIPanelTemplates.xml` seated 757 lines above it (1846); and the editbox's 22-line "gap" doc claiming only the self-acquire `autoFocus` path is built and its `false` default unverified — path 1 is 1686's `visibility_focus`, and the default is `true`, byte-read off `flags = 1` at `0x779a29`/`0x779a2e`, so the test named `autofocus_does_not_focus_on_show…` is renamed for what it actually pins (2044) - the `text=` divergence never existed: `resolve_text`'s doc had claimed since 0068 that falling back to the raw attribute on a GlobalStrings miss was "one deliberate divergence" from a reference that draws the empty string, and read at the byte it is the reference's own behaviour — `FrameScript_GetText 0x703bf0` does return a pre-seeded empty string (`0x882748`) when the global is not a string, but that return never reaches a label, because all three `text=` readers image-wide test it and substitute the raw attribute: `Button::LoadXML` at `0x778c07` (`0x778c31 mov eax,esi`), `CSimpleFontString::LoadXML` at `0x771006` byte-identically, and `0x7292a6`, which pushes `edi` on the empty arm; so the loader changes nothing, the two test comments that had called `PetLevelText`'s "Level level race class" placeholder and `CLOSE` our divergence now name the reference's arm (a real 1.12 client draws them too), and what IS ours is the extra warning on a key-shaped miss (0983 → 0991), a strict addition; beside it, `GetEffectiveScale`'s root factor is 1 not because "benilla has no `uiScale` CVar" — 1804 registered one, default 0.9 — but because the dial is applied at the raster seam, `768/uiScale` UI units of virtual-screen height rather than a scale on `UIParent`, so every coordinate the VM hands Lua is already in those units and the reference's `GetCursorPosition()/GetEffectiveScale()` transcriptions convert correctly with a root of 1 (2044) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
370 lines
14 KiB
Rust
370 lines
14 KiB
Rust
//! Drives the stock `Interface\FrameXML\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*.
|
||
|
||
mod common;
|
||
|
||
use benilla_ui::script::{ScriptValue, TradeSideState, TradeSlotItem, TradeState, UiScript};
|
||
|
||
/// 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] = &[
|
||
"Interface\\FrameXML\\Fonts.xml",
|
||
r"Interface\FrameXML\MoneyFrame.lua",
|
||
r"Interface\FrameXML\MoneyFrame.xml",
|
||
// The money entry comes off the chain since 1882 — `MoneyInputFrameTemplate` and the
|
||
// `MoneyInputFrame_*` verbs the window's OnLoad calls. Seated straight after MoneyFrame.xml,
|
||
// which is benilla.toc's own order.
|
||
r"Interface\FrameXML\MoneyInputFrame.lua",
|
||
r"Interface\FrameXML\MoneyInputFrame.xml",
|
||
r"Interface\FrameXML\UIParent.xml",
|
||
r"Interface\FrameXML\UIPanelTemplates.lua",
|
||
r"Interface\FrameXML\UIPanelTemplates.xml",
|
||
"Interface\\FrameXML\\GlobalStrings.lua",
|
||
"Interface\\FrameXML\\BasicControls.xml",
|
||
"Interface\\FrameXML\\LocaleProperties.lua",
|
||
"Interface\\FrameXML\\StaticPopup.xml", // the dialog engine (1960)
|
||
"Interface\\FrameXML\\GameTooltip.xml",
|
||
// The stock slot updates go through ItemButtonTemplate.lua's SetItemButton* helpers (1966).
|
||
r"Interface\FrameXML\ItemButtonTemplate.xml",
|
||
"Interface\\FrameXML\\TradeFrame.xml",
|
||
];
|
||
|
||
fn load_ui(script: &UiScript) {
|
||
for file in FILES {
|
||
common::load_ui(script, file);
|
||
}
|
||
}
|
||
|
||
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,
|
||
link: Some(format!("|cffffffff|Hitem:{item_id}:0:0:0|h[{name}]|h|r")),
|
||
}
|
||
}
|
||
|
||
/// 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 _data = benilla_formats::wow_data_or_skip!();
|
||
let s = UiScript::new().unwrap();
|
||
load_ui(&s);
|
||
for name in [
|
||
"TradeFrame",
|
||
"TradePlayerItem1",
|
||
"TradePlayerItem1ItemButton",
|
||
"TradePlayerItem7", // the enchant slot
|
||
"TradeRecipientItem1",
|
||
"TradeRecipientItem7",
|
||
"TradeFrameTradeButton",
|
||
"TradeFrameCancelButton",
|
||
"TradePlayerInputMoneyFrameGold", // our gold is now the editable input (P2)
|
||
"TradeRecipientMoneyFrameCopperButton",
|
||
"TradeHighlightPlayer",
|
||
"TradeHighlightRecipientEnchant",
|
||
] {
|
||
assert!(
|
||
s.eval::<bool>(&format!("return getglobal('{name}') ~= nil"))
|
||
.unwrap(),
|
||
"region {name} should exist"
|
||
);
|
||
}
|
||
// The window is hidden until TRADE_SHOW.
|
||
assert!(!s.eval::<bool>("return TradeFrame:IsShown()").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn trade_show_opens_and_both_columns_populate() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
load_ui(&s);
|
||
s.set_trade(Some(state()));
|
||
// The stock header reads `UnitName("NPC")` — the partner rides the shared "npc" booth the
|
||
// app's session feed points at them (1966).
|
||
s.set_unit(
|
||
"npc",
|
||
Some(benilla_ui::script::UnitState {
|
||
exists: true,
|
||
name: Some("Thrall".into()),
|
||
..Default::default()
|
||
}),
|
||
);
|
||
|
||
s.fire_event("TRADE_SHOW", vec![]);
|
||
assert!(
|
||
s.eval::<bool>("return TradeFrame:IsShown()").unwrap(),
|
||
"the window opens on TRADE_SHOW"
|
||
);
|
||
// ...and it is SLOTTED into the left panel, not merely Show()n unpositioned. Without the
|
||
// UIPanelWindows["TradeFrame"] 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::<String>("return GetLeftFrame() and GetLeftFrame():GetName() or ''")
|
||
.unwrap(),
|
||
"TradeFrame",
|
||
"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::<String>("return TradePlayerItem1Name:GetText()")
|
||
.unwrap(),
|
||
"Linen Cloth"
|
||
);
|
||
assert_eq!(
|
||
s.eval::<String>("return TradeRecipientItem1Name:GetText()")
|
||
.unwrap(),
|
||
"Silk Cloth"
|
||
);
|
||
assert!(
|
||
s.eval::<bool>("return TradePlayerItem1ItemButtonIconTexture:IsShown()")
|
||
.unwrap(),
|
||
"a filled slot shows its icon"
|
||
);
|
||
// An empty slot clears its name (the stock's `SetText(nil)`) + hides its icon.
|
||
assert!(s
|
||
.eval::<Option<String>>("return TradePlayerItem2Name:GetText()")
|
||
.unwrap()
|
||
.unwrap_or_default()
|
||
.is_empty());
|
||
assert!(!s
|
||
.eval::<bool>("return TradePlayerItem2ItemButtonIconTexture:IsShown()")
|
||
.unwrap());
|
||
|
||
// The partner's name paints from GetTradePartnerName().
|
||
assert_eq!(
|
||
s.eval::<String>("return TradeFrameRecipientNameText:GetText()")
|
||
.unwrap(),
|
||
"Thrall"
|
||
);
|
||
|
||
// The partner's read-only money rendered exactly one coin for 5s: TARGET_TRADE is a COLLAPSING
|
||
// type (MoneyTypeInfo.collapse), so gold and copper hide and silver alone stands — the
|
||
// reference's own rule, and the one this fixture was written to show. Our own gold is the
|
||
// editable input, exercised in `player_money_input_reflects_then_offers`.
|
||
assert!(s
|
||
.eval::<bool>(
|
||
"return TradeRecipientMoneyFrameSilverButton:IsShown() \
|
||
and not TradeRecipientMoneyFrameGoldButton:IsShown() \
|
||
and not TradeRecipientMoneyFrameCopperButton:IsShown()"
|
||
)
|
||
.unwrap());
|
||
|
||
assert!(s.take_errors().is_empty(), "clean repaint");
|
||
}
|
||
|
||
#[test]
|
||
fn enchant_slot_shows_the_not_traded_note() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
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::<String>("return TradePlayerItem7Name:GetText()")
|
||
.unwrap(),
|
||
// The stock wording, TRADEFRAME_NOT_MODIFIED_TEXT (ours had read "Will Not Be Traded").
|
||
"|cffffffffItem not yet modified|r"
|
||
);
|
||
assert!(s.take_errors().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn accept_update_drives_the_column_glows() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
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::<bool>("return TradeHighlightRecipient: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::<bool>("return TradeHighlightRecipient:IsShown()")
|
||
.unwrap());
|
||
assert!(s
|
||
.eval::<bool>("return TradeHighlightRecipientEnchant:IsShown()")
|
||
.unwrap());
|
||
assert!(!s
|
||
.eval::<bool>("return TradeHighlightPlayer: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::<bool>("return TradeHighlightPlayer:IsShown()")
|
||
.unwrap());
|
||
assert!(!s
|
||
.eval::<bool>("return TradeFrameTradeButton:IsEnabled() ~= 0")
|
||
.unwrap());
|
||
assert!(s.take_errors().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn closing_the_window_queues_the_cancel() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
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("TradeFrameCloseButton:Click()").unwrap();
|
||
assert!(!s.eval::<bool>("return TradeFrame: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::<bool>("return TradeFrame:IsShown()").unwrap());
|
||
s.fire_event("TRADE_CLOSED", vec![]);
|
||
assert!(!s.eval::<bool>("return TradeFrame:IsShown()").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn trade_button_click_queues_accept() {
|
||
let _data = benilla_formats::wow_data_or_skip!();
|
||
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("TradeFrameTradeButton: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 _data = benilla_formats::wow_data_or_skip!();
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_money(1_000_000); // SetTradeMoney is purse-gated (1965)
|
||
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::<bool>(&format!(
|
||
"return getglobal('TradePlayerInputMoneyFrame{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 TradePlayerInputMoneyFrameGold:GetText(), \
|
||
TradePlayerInputMoneyFrameSilver:GetText(), \
|
||
TradePlayerInputMoneyFrameCopper: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("TradePlayerInputMoneyFrameGold:SetText('2')")
|
||
.unwrap();
|
||
s.tick(0.0); // the deferred OnTextChanged drains here (decision 1831)
|
||
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 _data = benilla_formats::wow_data_or_skip!();
|
||
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("TradePlayerItem1ItemButton: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("TradeRecipientItem1ItemButton: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());
|
||
}
|