Files
benilla-octo/crates/benilla-app/tests/auction_frame.rs
T
SamandClaude Fable 5.1 d0e29635d0 the NPC, loot and quest windows register their handlers
- the six NPC windows — gossip, vendor, bank, trainer, stable master, taxi — register their own packet handlers and net/apply/npc.rs leaves the drain; a handler call measures ~25 ns, so the table can carry the busy families as it is (2318)
- the loot window, the group rolls and the inventory refusal register theirs (2319), then the questgiver panels, the quest log and the party share (2320): 273 → 149 kinds in the dispatch match since the table opened
- the show cascade is pinned against the reference's: ours notifies a parent before its children and snapshots the list, theirs is post-order and re-reads — a recorded gap, held by a test on the auction window (2317)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 17:11:59 +07:00

1003 lines
40 KiB
Rust

//! Drives the reference's own `Blizzard_AuctionUI` addon through the engine (decision 1511; 1971
//! put it on the player's chain) — the auction
//! twin of `mail_frame.rs`: it loads the same file chain the app does (cut to the auction window's
//! dependency prefix), pushes a synthetic `AuctionState`, opens the window with the app's own
//! `AUCTION_HOUSE_SHOW`/`AUCTION_ITEM_LIST_UPDATE` events, and asserts the transcribed Lua actually
//! paints — the named regions exist, the Browse rows populate from the fed snapshot, a row past the
//! batch is hidden, and the bid/buyout gates open for an affordable row and stay shut for one the
//! player cannot pay for.
use benilla_ui::script::{
AuctionCategory, AuctionItemRow, AuctionListState, AuctionState, AuctionSubCategory, UiScript,
UnitState, BIDDER, LIST, OWNER,
};
mod common;
/// The auction window's load prefix — the app's own order (`assets/ui/benilla.toc`), members only.
/// Every one of these is a real dependency: UiPanels for the panel manager + tab kit + StaticPopup
/// engine, ScrollTemplates for the faux lists, UIPanelTemplates for the button/input/checkbox
/// templates, UIDropDownMenu for the rarity capsule, MoneyFrame for `SmallMoneyFrameTemplate` +
/// the `MoneyTypeInfo` table this window registers `AUCTION_DEPOSIT` into, The `MoneyInputFrame_*` money-entry
/// helpers used to mean loading MerchantFrame.xml as well; 1751 moved that kit to MoneyFrame.xml
/// on its way to the chain, so the dependency is gone.
const FILES: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
// The four money-entry frames come off the chain since 1882 — `MoneyInputFrameTemplate` and
// the `MoneyInputFrame_*` verbs, replacing our own verbatim copy of both.
r"Interface\FrameXML\MoneyInputFrame.lua",
r"Interface\FrameXML\MoneyInputFrame.xml",
r"Interface\FrameXML\UIParent.xml",
// `auctionRowName` colours each row from ITEM_QUALITY_COLORS, whose declarer is
// UIParent (ref UIParent.lua:65) since 1888 put the font registry on the chain.
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
// `AuctionTabTemplate` inherits `CharacterFrameTabButtonTemplate`, and `inherits=`
// resolves at LOAD (1993).
r"Interface\FrameXML\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml", // the dialog engine (1960)
// The reference's own addon (1971), its toc order: the window (which <Include>s its
// templates) and the embedded dress-up pane. An integration test has no addon registry, so
// the files load as chain files, the way the manifest's own entries do.
// The embedded dress-up pane's OnLoad calls the dressing room's own `DressUpTexturePath`
// (stock DressUpFrame.lua, on the chain since 1969) — the manifest seats that window above.
"Interface\\FrameXML\\DressUpFrame.xml",
"Interface\\AddOns\\Blizzard_AuctionUI\\Blizzard_AuctionUI.xml",
"Interface\\AddOns\\Blizzard_AuctionUI\\Blizzard_AuctionDressUp.xml",
];
/// [`load_ui`] with the Browse tab's class tree seated first: the stock addon reads
/// `GetAuctionItemClasses()` at its LOAD (`AuctionFrameBrowse_OnLoad`), before any session, so
/// the classes are login-scoped and must be there before the files are (1971).
fn load_ui_with_classes(s: &mut UiScript) {
s.set_auction_item_classes(vec![AuctionCategory {
class_id: 4,
name: "Armor".into(),
subclasses: vec![AuctionSubCategory {
sub_id: 1,
name: "Cloth".into(),
has_inv_types: true,
}],
}]);
load_ui(s);
}
fn load_ui(script: &UiScript) {
// `common::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and has to come off the player's chain, which `std::fs::read` under
// `assets/ui` cannot do. This kit gained such an entry when the dropdown migrated (1751).
for file in FILES {
common::load_ui(script, file);
}
}
/// One browse row with the fields the window paints.
fn row(name: &str, min_bid: u32, buyout: u32, bid: u32, owner: &str) -> AuctionItemRow {
AuctionItemRow {
auction_id: 1,
item_id: 2589,
name: Some(name.to_string()),
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
count: 5,
quality: Some(1),
level: 10,
min_bid,
min_increment: if bid > 0 { 100 } else { 0 },
buyout_price: buyout,
bid_amount: bid,
high_bidder: false,
owner: Some(owner.to_string()),
time_left: 4,
link: Some("|cffffffff|Hitem:2589:0:0:0|h[Linen Cloth]|h|r".into()),
random_property_id: 0,
}
}
/// A session snapshot: `rows` on the Browse list, nothing on the other two, one category.
///
/// Each row gets a DISTINCT `auction_id` here, standing in for the app's own resolve: the engine
/// remembers a selection by wire id rather than by row position (auction.rs), so rows sharing an id
/// would all resolve back to the first of them.
fn state(mut rows: Vec<AuctionItemRow>) -> AuctionState {
for (i, r) in rows.iter_mut().enumerate() {
r.auction_id = i as u32 + 1;
}
let mut lists: [AuctionListState; 3] = Default::default();
let total = rows.len() as u32;
lists[LIST] = AuctionListState {
rows,
total,
sort: vec![("bid".into(), true)],
};
lists[BIDDER] = AuctionListState::default();
lists[OWNER] = AuctionListState::default();
AuctionState {
lists,
deposit_percent: 5,
}
}
/// The player: a name (the Browse bid gate compares it against the row's owner), a level (the row
/// level colours off it) and a purse.
fn seat_player(s: &mut UiScript, money: u64) {
s.set_unit(
"player",
Some(UnitState {
exists: true,
name: Some("Buyer".into()),
level: 60,
..UnitState::default()
}),
);
s.set_money(money);
}
#[test]
fn auction_frame_loads_and_key_regions_exist() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
for name in [
// The window, its three panes and its three tabs.
"AuctionFrame",
"AuctionFrameBrowse",
"AuctionFrameBid",
"AuctionFrameAuctions",
"AuctionFrameTab1",
"AuctionFrameTab2",
"AuctionFrameTab3",
// One row of each list, and the parts the repaint addresses by name.
"BrowseButton1",
"BrowseButton1Name",
"BrowseButton1MoneyFrameGoldButton",
"BrowseButton1MoneyFrameCopperButton",
"BidButton1",
"AuctionsButton1",
// The create form: the sell slot and both money inputs.
"AuctionsItemButton",
"StartPrice",
"StartPriceGold",
"BuyoutPrice",
"BuyoutPriceGold",
// The filter column and the action buttons the gates drive.
"AuctionFilterButton1",
"AuctionFilterButton15",
"BrowseBidButton",
"BrowseBuyoutButton",
"AuctionsCreateAuctionButton",
] {
assert!(
s.eval::<bool>(&format!("return getglobal('{name}') ~= nil"))
.unwrap(),
"region {name} should exist"
);
}
// The window is hidden until its show event.
assert!(!s.eval::<bool>("return AuctionFrame:IsShown()").unwrap());
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
}
/// The window is registered `doublewide`, which is the one row of its kind in all of 1.12 — without
/// it ShowUIPanel takes its unregistered branch and the window flips `IsShown()` without landing.
#[test]
fn the_window_is_registered_doublewide() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
assert_eq!(
s.eval::<String>("return UIPanelWindows['AuctionFrame'].area")
.unwrap(),
"doublewide"
);
}
#[test]
fn auction_house_show_opens_the_window_on_the_browse_tab() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 500_000);
s.set_auction(Some(state(vec![row(
"Linen Cloth",
1000,
5000,
0,
"Seller",
)])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert!(
s.eval::<bool>("return AuctionFrame:IsShown()").unwrap(),
"the window opens on AUCTION_HOUSE_SHOW"
);
assert!(
s.eval::<bool>("return AuctionFrameBrowse:IsShown()")
.unwrap(),
"and lands on the Browse tab"
);
assert!(!s.eval::<bool>("return AuctionFrameBid:IsShown()").unwrap());
assert!(!s
.eval::<bool>("return AuctionFrameAuctions:IsShown()")
.unwrap());
// The skin followed the tab.
assert_eq!(
s.eval::<String>("return AuctionFrameTopLeft:GetTexture()")
.unwrap()
.to_ascii_lowercase(),
"interface\\auctionframe\\ui-auctionframe-browse-topleft"
);
// Tab 3 re-skins and swaps the pane.
s.run("AuctionFrameTab_OnClick(3)").unwrap();
assert!(s
.eval::<bool>("return AuctionFrameAuctions:IsShown()")
.unwrap());
assert_eq!(
s.eval::<String>("return AuctionFrameTopLeft:GetTexture()")
.unwrap()
.to_ascii_lowercase(),
"interface\\auctionframe\\ui-auctionframe-auction-topleft"
);
// Opening the Auctions tab asks the server for the owned list, once per window session.
assert_eq!(
s.take_auction_owner_query(),
Some(0),
"the Auctions pane fetches the owned list on its first show"
);
assert!(s.errors().is_empty(), "clean open: {:?}", s.errors());
}
/// **The bug the per-list fires were made for** (decision 2308), in the stock addon's own Lua.
///
/// `AuctionFrameAuctions_Update` computes `offset + i + (NUM_AUCTION_ITEMS_PER_PAGE *
/// AuctionFrameAuctions.page)` on its very first line of loop body, and `AuctionFrameAuctions.page`
/// is assigned in exactly one place in the whole addon: that tab's `OnShow`, three lines below its
/// `GetOwnerAuctionItems()`. So the event is only safe to fire *after* the Auctions tab has been
/// shown at least once — which the reference guarantees structurally, because every caller of
/// `GetOwnerAuctionItems` lives *inside* that pane (the `OnShow`, and the two page-turner buttons,
/// which pass `AuctionFrameAuctions.page` as their argument).
///
/// The feed used to fire all three list events on any change, so the first browse result of the
/// session ran this repaint on a tab nobody had opened and put
/// `attempt to perform arithmetic on field 'page' (a nil value)` on the player's screen. This test
/// is the hazard itself, pinned: it fails the day the stock Lua stops caring, and `ui_auction`'s
/// own `a_result_owes_only_its_own_lists_event` is the guarantee that we never hand it that event.
#[test]
fn the_auctions_tab_cannot_repaint_before_it_has_been_shown() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 500_000);
s.set_auction(Some(state(vec![row(
"Linen Cloth",
1000,
5000,
0,
"Seller",
)])));
// Open, and land a browse result — the whole of what a search does. Clean.
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
assert!(
s.take_errors().is_empty(),
"a browse result on the Browse tab is clean"
);
// The same result also announcing the *owned* list is what crashed: the Auctions tab has never
// been shown, so its `page` is nil and the repaint does arithmetic on it.
s.fire_event("AUCTION_OWNED_LIST_UPDATE", vec![]);
let errors = s.take_errors();
assert!(
errors.iter().any(|e| e.contains("page")),
"the never-shown Auctions tab raises the nil-page error: {errors:?}"
);
// And the same event is harmless the moment the tab has been shown once — which is the only
// state the app can now produce it in, since the owned list is only ever asked for from there.
s.run("AuctionFrameTab_OnClick(3)").unwrap();
assert!(s.take_errors().is_empty(), "opening the tab is clean");
s.fire_event("AUCTION_OWNED_LIST_UPDATE", vec![]);
assert!(
s.take_errors().is_empty(),
"and now the owned list may announce itself"
);
}
/// Why only the Auctions tab was in the screenshot — and why that is an accident of the XML.
///
/// All three panes read their `page` field in `_Update`, and each assigns it in exactly one place:
/// its own `OnShow`. What differs is *when* that `OnShow` first runs, and it is decided by one
/// attribute in the stock file — `AuctionFrameBrowse` and `AuctionFrameAuctions` are declared
/// `hidden="true"`, `AuctionFrameBid` is declared **`hidden="false"`**. So the Bids pane becomes
/// visible with the window itself, its `OnShow` runs on the very first `AUCTION_HOUSE_SHOW` —
/// before `AuctionFrameTab_OnClick(1)` hides it again — and `AuctionFrameBid.page` is `0` while
/// the player is still looking at Browse. The Auctions pane gets nothing at all until tab 3.
///
/// That is the whole reason a mis-aimed `AUCTION_OWNED_LIST_UPDATE` crashed and a mis-aimed
/// `AUCTION_BIDDER_LIST_UPDATE` did not — and the reason the fix is the rule (an event names one
/// list) rather than a guard on the one tab that happened to be reported.
#[test]
fn only_the_bids_pane_gets_its_page_without_being_opened() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
let page = |s: &UiScript, pane: &str| {
s.eval::<String>(&format!("return tostring({pane}.page)"))
.unwrap()
};
// At load, with no session: no pane has been shown, so none has a page.
for pane in [
"AuctionFrameBrowse",
"AuctionFrameBid",
"AuctionFrameAuctions",
] {
assert_eq!(page(&s, pane), "nil", "{pane} before the window opens");
}
// The window opens on tab 1. Browse is shown by the tab click; Bid is shown by the window
// itself and hidden again a moment later, keeping the page its OnShow assigned.
seat_player(&mut s, 500_000);
s.set_auction(Some(state(vec![row(
"Linen Cloth",
1000,
5000,
0,
"Seller",
)])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(page(&s, "AuctionFrameBrowse"), "0");
assert_eq!(
page(&s, "AuctionFrameBid"),
"0",
"hidden=\"false\": the Bids pane rode the window up and back down"
);
assert!(
!s.eval::<bool>("return AuctionFrameBid:IsShown()").unwrap(),
"and it is not the visible tab"
);
assert_eq!(
page(&s, "AuctionFrameAuctions"),
"nil",
"the Auctions pane is the one an unowed event can still kill"
);
assert!(s.take_errors().is_empty());
}
/// The show cascade, against the reference's own order — the mechanism the Bids pane's `page`
/// rests on, checked rather than assumed. **Every outcome matches; the order does not** (2317).
///
/// wow-re (`system/ui/scratch/propagation.md`, "The show/hide visibility cascade") reads
/// `0x76ae10` as **post-order**: a frame marks itself visible (`0x76ae7b`), walks its children, and
/// fires its **own** `OnShow` last (`0x76aef5`, past both child loops), with no snapshot anywhere —
/// each loop re-reads the live links, so a `Hide()` issued from a sibling's handler suppresses a
/// later sibling by clearing its shown flag before the walk reaches it.
///
/// benilla fires the parent's own handler **first** and still notifies the descendant the parent's
/// handler just hid. For this window the two routes land on the same state, which is why the
/// assertions below are the reference's outcomes and only the order line is ours: the
/// `hidden="false"` Bids pane gets exactly one `OnShow`, on the first open, taking the `page` its
/// repaint needs and sending the one `GetBidderAuctionItems()` that pane's handler owes — and the
/// reopen notifies neither, because the tab click left its own shown flag clear.
///
/// If the cascade is ever made post-order, the `ShowOrder` assertions are what should change here;
/// nothing else in this test should have to.
#[test]
fn the_show_cascade_notifies_the_bids_pane_once_and_keeps_its_page() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 500_000);
s.set_auction(Some(state(vec![row(
"Linen Cloth",
1000,
5000,
0,
"Seller",
)])));
// Both handlers are called by global name from their `<OnShow>`, so wrapping the globals
// records the order the cascade actually fired them in.
s.run(
"ShowOrder = {} local pane, window = AuctionFrameBid_OnShow, AuctionFrame_OnShow AuctionFrameBid_OnShow = function() table.insert(ShowOrder, \"pane\") pane() end AuctionFrame_OnShow = function() table.insert(ShowOrder, \"window\") window() end",
)
.unwrap();
let order = |s: &UiScript| {
s.eval::<String>("return table.concat(ShowOrder, \",\")")
.unwrap()
};
let page = |s: &UiScript| {
s.eval::<String>("return tostring(AuctionFrameBid.page)")
.unwrap()
};
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane",
"OURS, and a known deviation (2317): the reference's cascade is post-order, \"pane,window\""
);
assert_eq!(
page(&s),
"0",
"the pane the XML leaves shown takes its page riding the window up — this is what keeps a \
mis-aimed AUCTION_BIDDER_LIST_UPDATE off the nil-page path"
);
assert_eq!(
s.take_auction_bidder_query(),
Some(0),
"and that OnShow is what asks for the bids list"
);
// Close and reopen. The tab click left the pane's own shown flag clear, so it is skipped:
// the page is kept from the first open rather than re-assigned, and nothing new goes out.
s.run("HideUIPanel(AuctionFrame)").unwrap();
let _ = s.take_auction_close();
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert_eq!(
order(&s),
"window,pane,window",
"the reopen notifies the window and not the pane it left hidden"
);
assert_eq!(page(&s), "0", "kept from the first open, not re-assigned");
assert_eq!(
s.take_auction_bidder_query(),
None,
"and the reopen puts no second bids query on the wire"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The part that proves it works: a fed snapshot paints the Browse rows.
#[test]
fn the_browse_list_populates_from_the_fed_snapshot() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 500_000);
s.set_auction(Some(state(vec![
// No bids yet: the row shows the seller's opening price as the current bid.
row("Linen Cloth", 1000, 5000, 0, "Seller"),
// Bid on: the row shows the live bid instead.
row("Wool Cloth", 1000, 0, 2500, "Someone"),
])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
assert_eq!(
s.eval::<(i64, i64)>("return GetNumAuctionItems('list')")
.unwrap(),
(2, 2)
);
assert_eq!(
s.eval::<String>("return BrowseButton1Name:GetText()")
.unwrap(),
"Linen Cloth"
);
assert_eq!(
s.eval::<String>("return BrowseButton2Name:GetText()")
.unwrap(),
"Wool Cloth"
);
// The seller column shows the OWNER (the reference's `HighBidder` region name notwithstanding).
assert_eq!(
s.eval::<String>("return BrowseButton1HighBidder:GetText()")
.unwrap(),
"Seller"
);
// Row 1: no bids, so the money line reads the 10s opening price.
assert_eq!(
s.eval::<String>("return tostring(BrowseButton1MoneyFrameSilverButton:GetText())")
.unwrap(),
"10",
"1000c with no bids paints the minimum bid"
);
// Row 2: bid on, so the money line reads the live 25s bid instead.
assert_eq!(
s.eval::<String>("return tostring(BrowseButton2MoneyFrameSilverButton:GetText())")
.unwrap(),
"25",
"a bid-on row paints the live bid, not the minimum"
);
// …and the ZERO coins below it stay on. This is the `MoneyTypeInfo["AUCTION"]` collapse rule
// — `showSmallerCoins`, which drops only the LEADING zeros — and it is the whole reason this
// window is laid out on `SmallMoneyFrameTemplate` rather than the three-slot kit the rest of
// benilla uses: that kit drops every zero denomination, so 10s 0c read as a lone "10 🥈" and a
// 1g buyout as a lone "1 🥇" (director's report, 2026-08-22).
assert!(
!s.eval::<bool>("return BrowseButton1MoneyFrameGoldButton:IsShown()")
.unwrap(),
"no gold in 1000c, and a LEADING zero is the one thing that does collapse"
);
assert!(
s.eval::<bool>("return BrowseButton1MoneyFrameCopperButton:IsShown()")
.unwrap(),
"the trailing zero copper stays on under showSmallerCoins"
);
assert_eq!(
s.eval::<String>("return tostring(BrowseButton1MoneyFrameCopperButton:GetText())")
.unwrap(),
"0"
);
// Row 1 has a buyout, so its buyout line shows; row 2 has none, so it hides.
assert!(s
.eval::<bool>("return BrowseButton1BuyoutMoneyFrame:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return BrowseButton2BuyoutMoneyFrame:IsShown()")
.unwrap());
// The stack count shows for a stack of 5.
assert!(s
.eval::<bool>("return BrowseButton1ItemCount:IsShown()")
.unwrap());
// The closing-time bucket resolved to a row word plus a hover detail (both GlobalStrings, so
// both are blank without the string table — what matters is that the tooltip field is set).
assert!(s
.eval::<bool>("return BrowseButton1ClosingTime.tooltip ~= nil")
.unwrap());
// A row past the batch is hidden.
assert!(s.eval::<bool>("return BrowseButton2:IsShown()").unwrap());
assert!(
!s.eval::<bool>("return BrowseButton3:IsShown()").unwrap(),
"row 3 is past the 2-row batch and must be hidden"
);
assert!(s.errors().is_empty(), "clean repaint: {:?}", s.errors());
}
/// The bid/buyout gates: both start shut on every repaint and open only for the SELECTED row, and
/// only when the purse can actually cover it.
#[test]
fn the_bid_and_buyout_gates_read_the_purse() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
// 2 gold. Row 1 costs 10s to bid and 50s to buy out — affordable. Row 2 wants 5 gold.
seat_player(&mut s, 20_000);
s.set_auction(Some(state(vec![
row("Linen Cloth", 1000, 5000, 0, "Seller"),
row("Arcanite Bar", 50_000, 60_000, 0, "Seller"),
])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
// Nothing selected: both shut.
assert!(!s
.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap());
assert!(!s
.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap());
// Select the affordable row: both open, and the bid box is seated at the required bid.
s.run("BrowseButton1:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return GetSelectedAuctionItem('list')")
.unwrap(),
1
);
assert!(
s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"10s is affordable on 2g"
);
assert!(
s.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap(),
"50s is affordable on 2g"
);
assert_eq!(
s.eval::<i64>("return MoneyInputFrame_GetCopper(BrowseBidPrice)")
.unwrap(),
1000,
"with no bids the required bid IS the minimum bid"
);
// Select the unaffordable row: both shut again.
s.run("BrowseButton2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return GetSelectedAuctionItem('list')")
.unwrap(),
2
);
assert!(
!s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"5g is not affordable on 2g"
);
assert!(
!s.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap(),
"6g is not affordable on 2g"
);
// Back to row 1 and press Bid: the intent reaches the app with the seated amount.
s.run("BrowseButton1:Click()").unwrap();
let _ = s.take_auction_bids();
s.run("BrowseBidButton:Click()").unwrap();
let bids = s.take_auction_bids();
assert_eq!(bids.len(), 1, "one bid queued, got {bids:?}");
assert_eq!(bids[0].list, LIST);
assert_eq!(bids[0].index, 1);
assert_eq!(bids[0].amount, 1000);
assert!(
!s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"the button disables itself so a second click cannot outrun the answer"
);
assert!(s.errors().is_empty(), "clean gating: {:?}", s.errors());
}
/// You cannot bid on your own auction, however much money you have — the one leg of the Browse gate
/// that is not about affordability.
#[test]
fn you_cannot_bid_on_your_own_auction() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 10_000_000);
s.set_auction(Some(state(vec![row(
"Linen Cloth",
1000,
5000,
0,
"Buyer",
)])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
s.run("BrowseButton1:Click()").unwrap();
assert!(
!s.eval::<bool>("return BrowseBidButton:IsEnabled() ~= 0")
.unwrap(),
"the row's owner is the player"
);
// The buyout gate has no such leg — the reference lets you buy out your own listing.
assert!(s
.eval::<bool>("return BrowseBuyoutButton:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty());
}
/// A search reads every filter at once, and nothing before the button is pressed: typing a name,
/// setting a level band and clicking a class row queue no query at all until Search fires.
#[test]
fn search_reads_the_filters_and_nothing_queries_before_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 0);
s.set_auction(Some(state(vec![])));
s.set_auction_can_query(true);
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
let _ = s.take_auction_query();
// The class tree paints from the pushed categories.
s.run("AuctionFrameFilters_Update()").unwrap();
assert_eq!(
s.eval::<String>("return AuctionFilterButton1:GetText()")
.unwrap(),
"Armor"
);
// Clicking it expands the subclass beneath it and queries NOTHING.
s.run("AuctionFilterButton1:Click()").unwrap();
assert!(
s.eval::<String>("return AuctionFilterButton2:GetText()")
.unwrap()
.contains("Cloth"),
"the selected class expands its subclasses in place"
);
assert!(
s.take_auction_query().is_none(),
"a filter click must not query — the selection is read when Search is pressed"
);
s.run("BrowseName:SetText('linen')").unwrap();
s.run("BrowseMinLevel:SetText('10')").unwrap();
s.run("BrowseMaxLevel:SetText('20')").unwrap();
s.run("IsUsableCheckButton:SetChecked(1)").unwrap();
assert!(s.take_auction_query().is_none(), "still nothing queued");
s.run("AuctionFrameBrowse_Search()").unwrap();
let query = s.take_auction_query().expect("Search queues one query");
assert_eq!(query.name, "linen");
assert_eq!(query.min_level, 10);
assert_eq!(query.max_level, 20);
assert_eq!(query.class, Some(1), "the selected class row, 1-based");
assert!(query.usable_only);
assert_eq!(query.page, 0);
assert!(s.errors().is_empty(), "clean search: {:?}", s.errors());
}
/// The create form's gate, and the two triggers that move the deposit. The deposit is the client's
/// own arithmetic over the stack's vendor value and the RUN TIME — never over what you ask for.
#[test]
fn the_create_gate_and_the_deposit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 100_000);
s.set_auction(Some(state(vec![])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.run("AuctionFrameTab_OnClick(3)").unwrap();
// Empty slot: shut, whatever the prices say.
assert!(
!s.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap(),
"no item in the sell slot"
);
// The default run time is the middle one, 8 hours.
assert_eq!(
s.eval::<i64>("return AuctionFrameAuctions.duration")
.unwrap(),
480
);
s.run("AuctionsShortAuctionButton:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return AuctionFrameAuctions.duration")
.unwrap(),
120
);
s.run("AuctionsLongAuctionButton:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return AuctionFrameAuctions.duration")
.unwrap(),
1440
);
// With no item, the form does not even reach the price checks — the buyout error stays hidden.
s.run("MoneyInputFrame_SetCopper(StartPrice, 10000)")
.unwrap();
s.run("MoneyInputFrame_SetCopper(BuyoutPrice, 5000)")
.unwrap();
s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap();
assert!(!s
.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap());
assert!(!s
.eval::<bool>("return AuctionsBuyoutErrorText:IsShown()")
.unwrap());
// Now put something in the slot, through the real cursor path: pick a bag item up and click
// the slot, which is `ClickAuctionSellItemButton` exactly as a drag or a drop would be.
s.set_container(
0,
Some(benilla_ui::script::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::from([(
1,
benilla_ui::script::ContainerSlot {
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
count: 4,
quality: Some(1),
item_id: 2589,
link: Some("|cffffffff|Hitem:2589:0:0:0|h[Linen Cloth]|h|r".into()),
..Default::default()
},
)]),
}),
);
s.set_item_template(
2589,
benilla_ui::script::ItemTemplateView {
name: "Linen Cloth".into(),
sell_price: 25,
..Default::default()
},
);
// A SPLIT pickup, deliberately. A whole-stack `PickupContainerItem` records `count: None`
// ("None = the whole stack", cursor.rs), and `GetAuctionSellItemInfo` then answers a count of
// ONE for it — so the slot would show no stack count and the deposit would be computed on a
// single unit. That is an ENGINE gap in `script/auction.rs`'s `sell_item_info`, not this
// window's: the window shows the count whenever the API reports more than one, which is what
// this exercises.
s.run("SplitContainerItem(0, 1, 2)").unwrap();
s.run("AuctionsItemButton:Click()").unwrap();
// The stock create pane paints the slot on NEW_AUCTION_UPDATE, which the app fires the frame
// the sell slot changes (`ui_auction`'s feed); a harness with no app fires it itself.
s.fire_event("NEW_AUCTION_UPDATE", vec![]);
assert_eq!(
s.eval::<String>("return AuctionsItemButtonName:GetText()")
.unwrap(),
"Linen Cloth",
"the slot paints the item it took from the cursor"
);
assert_eq!(
s.eval::<String>("return AuctionsItemButtonCount:GetText()")
.unwrap(),
"2"
);
assert!(s
.eval::<bool>("return AuctionsItemButtonCount:IsShown()")
.unwrap());
// Dropping an item RESETS the form — the stock handler seeds the start price from the item's
// sell price and zeroes the buyout (`AuctionSellItemButton_OnEvent`) — so the prices are typed
// after the drop, as a player types them.
s.run("MoneyInputFrame_SetCopper(StartPrice, 10000)")
.unwrap();
s.run("MoneyInputFrame_SetCopper(BuyoutPrice, 5000)")
.unwrap();
// With an item in the slot, the buyout-under-start error is the one the form explains out loud.
s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap();
assert!(
s.eval::<bool>("return AuctionsBuyoutErrorText:IsShown()")
.unwrap(),
"a 50s buyout under a 1g start price is an error, and it is shown"
);
assert!(!s
.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap());
// Clear the buyout and the form opens; pressing Create sends exactly what is on screen.
s.run("MoneyInputFrame_SetCopper(BuyoutPrice, 0)").unwrap();
s.run("AuctionsFrameAuctions_ValidateAuction()").unwrap();
assert!(
s.eval::<bool>("return AuctionsCreateAuctionButton:IsEnabled() ~= 0")
.unwrap(),
"an item, a 1g start price and no buyout is a valid auction"
);
// The deposit is the CLIENT's own arithmetic (auction.rs §7): 5% of the carried stack's vendor
// value (2 x 25c = 50c) floors to 2c, and 24h is twelve two-hour units, so 24c. Two hours would
// be 2c — the duration is what moves this, never the asking price.
assert_eq!(
s.eval::<i64>("return CalculateAuctionDeposit(1440)")
.unwrap(),
24
);
assert_eq!(
s.eval::<i64>("return CalculateAuctionDeposit(120)")
.unwrap(),
2
);
let _ = s.take_auction_start();
s.run("AuctionsCreateAuctionButton:Click()").unwrap();
let start = s.take_auction_start().expect("Create queues one auction");
assert_eq!(start.min_bid, 10000);
assert_eq!(start.buyout, 0);
assert_eq!(start.duration, 1440, "the long run time is still selected");
assert!(s.errors().is_empty(), "clean create form: {:?}", s.errors());
}
/// Closing the window ends the session client-side and takes both confirmations with it.
#[test]
fn hiding_the_window_closes_the_session() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 0);
s.set_auction(Some(state(vec![])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
let _ = s.take_auction_close();
s.run("HideUIPanel(AuctionFrame)").unwrap();
assert!(!s.eval::<bool>("return AuctionFrame:IsShown()").unwrap());
assert!(
s.take_auction_close(),
"OnHide queues CloseAuctionHouse (the session is client-side only)"
);
assert!(s.errors().is_empty());
// And the server-side close hides it again from the other direction.
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
assert!(s.eval::<bool>("return AuctionFrame:IsShown()").unwrap());
s.fire_event("AUCTION_HOUSE_CLOSED", vec![]);
assert!(!s.eval::<bool>("return AuctionFrame:IsShown()").unwrap());
assert!(s.errors().is_empty(), "clean close: {:?}", s.errors());
}
/// Paging: with more matches than the server's 50-row page, the turners appear only when the bar is
/// at the very bottom, and the list is fed one extra row so the bar can travel there. A 50-row
/// batch out of 120 fills all 8 slots, so the turners stay hidden until the list is scrolled down.
#[test]
fn paging_shows_the_turners_only_at_the_end_of_the_list() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
seat_player(&mut s, 0);
let mut rows = Vec::new();
for i in 0..50 {
rows.push(row(&format!("Item {i}"), 100, 0, 0, "Seller"));
}
let mut st = state(rows);
st.lists[LIST].total = 120;
s.set_auction(Some(st));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
assert!(
!s.eval::<bool>("return BrowseNextPageButton:IsShown()")
.unwrap(),
"at the top of a full page the turners stay out of the way"
);
// Scroll to the very bottom. The list was fed 51 rows for 8 slots, so the last offset is 43 —
// which leaves row slot 8 empty and reveals the turners.
s.run("FauxScrollFrame_SetOffset(BrowseScrollFrame, 43)")
.unwrap();
s.run("AuctionFrameBrowse_Update()").unwrap();
assert!(
!s.eval::<bool>("return BrowseButton8:IsShown()").unwrap(),
"the extra row slot is empty at the bottom — that is what reveals the turners"
);
assert!(
s.eval::<bool>("return BrowseNextPageButton:IsShown()")
.unwrap(),
"120 matches over a 50-row page, scrolled to the end: the turners appear"
);
assert!(s
.eval::<bool>("return BrowseSearchCountText:IsShown()")
.unwrap());
// Turning the page re-searches at the next offset rather than scrolling.
s.set_auction_can_query(true);
let _ = s.take_auction_query();
s.run("BrowseNextPageButton:Click()").unwrap();
let query = s.take_auction_query().expect("the turner re-queries");
assert_eq!(query.page, 1, "the next page, 0-based");
assert!(s.errors().is_empty(), "clean paging: {:?}", s.errors());
}
/// The row and sell-slot hovers go through the reference's own tooltip verbs (decision 1511).
///
/// Both used `SetHyperlink` while `GameTooltip:SetAuctionItem` / `SetAuctionSellItem` had no
/// bindings. The rendered tooltip was the same either way — what changes is that an addon hooking
/// either verb now sees the call it expects, which is the whole point of shipping the reference's
/// API surface rather than an equivalent one.
#[test]
fn a_row_hover_goes_through_the_reference_tooltip_verb() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
load_ui_with_classes(&mut s);
s.set_auction(Some(state(vec![row("Copper Bar", 100, 500, 0, "Someone")])));
s.fire_event("AUCTION_HOUSE_SHOW", vec![]);
s.fire_event("AUCTION_ITEM_LIST_UPDATE", vec![]);
// The verbs exist as tooltip methods at all — the thing that was missing.
assert!(
s.eval::<bool>("return type(GameTooltip.SetAuctionItem) == 'function'")
.unwrap(),
"SetAuctionItem is bound"
);
assert!(
s.eval::<bool>("return type(GameTooltip.SetAuctionSellItem) == 'function'")
.unwrap(),
"SetAuctionSellItem is bound"
);
// And the row hover drives one without erroring. The tooltip body itself depends on the item
// template store, which a bare harness has not been fed — the assertion here is the call path,
// not the rendered text.
// `this` is what the event dispatcher binds before it calls a handler; a direct call from a
// chunk has to bind it the same way or `SetOwner(this, ...)` gets a nil frame.
s.run("this = BrowseButton1Item AuctionFrameItem_OnEnter('list', 1)")
.unwrap();
assert!(
s.take_errors().is_empty(),
"the row hover raised no script error"
);
// An empty sell slot is a no-op rather than a throw, which is how the window's own OnEnter
// gate calls it.
s.run("GameTooltip:SetAuctionSellItem()").unwrap();
assert!(s.take_errors().is_empty());
}