Files
benilla-octo/crates/benilla-app/tests/auction_frame.rs
T
SamandClaude Fable 5.1 7a825111ab the world map's arrow shows on zone sheets, and UIParent goes stock
- 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>
2026-09-06 19:30:54 +07:00

796 lines
31 KiB
Rust

//! Drives the REAL `assets/ui/AuctionFrame.xml` through the engine (decision 1511) — 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",
"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 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());
}