- 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>
1003 lines
40 KiB
Rust
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());
|
|
}
|