Files
benilla-octo/crates/benilla-app/tests/create_frame_template.rs
T
SamandClaude Fable 5.1 fb57052242 coins ride the cursor, and the trade window and combat text go stock
- coins ride the cursor: `MoneyFrame.lua`/`.xml` load off the player's chain, and the stock kit reads the cursor's coins at load, so the money cursor — payload mode 2, the copper at `[0xb4e2f0]` — is the engine's: `PickupPlayerMoney` puts coins on the cursor and the purse is never debited, and `CoinPickupFrame.xml`, the shift-click coin dialog nothing of ours had ever answered, joins; wow-re's round then set the law: the pickup raises on a non-number and refuses silently on zero, lost control and more than the purse; `AddTradeMoney` folds the coins into the offer as an absolute `CMSG_SET_TRADE_GOLD` gated on the purse; the send-mail setters and `GetCoinIcon` answer as the bytes do; the coinage-change reflex trims an offer the purse no longer covers; and the four send-mail pickup/drop verbs the stock table names do not exist in 5875, so they are not built (1962, 1965)
- the trade window is the reference's own file: `TradeFrame.xml` + `TradeFrame.lua` off the chain, ours deleted; `GameTooltip:SetTradePlayerItem`/`SetTradeTargetItem` (`0x5341e0`/`0x534410`) render the slot's item the way the loot slot's is, and `TRADE_PLAYER_ITEM_CHANGED`/`TRADE_TARGET_ITEM_CHANGED` — which the stock file repaints one slot on, and nothing had fired — fire per changed slot beside `TRADE_UPDATE`; the stock file brings the chain's own `MoneyInputFrame`, a TARGET_TRADE `SmallMoneyFrame`, `ItemButtonTemplate` slots and the enchant slot's own wording — "Item not yet modified" where ours had read "Will Not Be Traded"; the wire's enchant id is not carried into the slot yet, so the tooltip shows the item without its enchant (1966)
- the dialogs' verbs, as the bytes define them: nine of the ten stock `StaticPopup.lua` bindings 1960 had left named are built off wow-re's round with their packets, events and UIParent arms — the bare trade pair, `ForceLogout`, the instance-boot timer (whole seconds), the area spirit healer's accept/cancel/time, `AcceptBattlefieldPort` out of a three-slot queue `SMSG_BATTLEFIELD_STATUS` fills, `CancelMeetingStoneRequest`, and the pet trainer's pair as the talent wipe's twin, latch for latch — `SMSG_PET_UNLEARN_CONFIRM` latches and asks, `ConfirmPetUnlearn` sends behind the money gate, `CheckPetUntrainerDist` reads the leash; not built, named: the healer's proximity scan (`0x4923b0`: 20 yd to acquire, 22 to retain), so no healer is cached yet, and `ReplaceTradeEnchant`, which rides trade-slot enchant targeting this engine does not build (1963)
- the scrolling combat text is the reference's own `Blizzard_CombatText`, LoadOnDemand off the chain: our transcription is deleted, and the addon loads through `UIParentLoadAddOn` at the reference's own sites; the family's fourteen defaults move to the options file where the reference keeps them, because a LoadOnDemand addon cannot define the options that decide whether it loads (1964); and the Looking-for-Group pair is what the bytes define, correcting 1959's flag: `SetLookingForGroup` takes three (type, entry) pairs and a comment, packs each slot as `(type << 24) & entry` — an AND (`0x4e9713`), so every admissible input stores zero — and sends `CMSG_SET_LOOKING_FOR_GROUP` only when the commit changed something; `GetLookingForGroup` answers three nilable slot names then the comment, never 1|nil; and the stock 1.12.1 UI never calls either — both call sites sit in an XML comment (1961)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:58:15 +07:00

275 lines
13 KiB
Rust

//! `CreateFrame`'s fourth argument against the **real** `assets/ui` templates — the corpus idiom,
//! end to end.
//!
//! The unit tests for the runtime template path live in
//! `benilla-ui/src/script/tests/create_frame_template.rs` and drive synthetic templates. This one
//! exists because the thing an addon actually types is
//!
//! ```lua
//! local tab = CreateFrame("Button", "MyTab", UIParent, "TabButtonTemplate")
//! getglobal("MyTab".."Text"):SetText("Hi")
//! ```
//!
//! against a template *we* wrote, loaded the way the client loads it — six hundred lines of real
//! FrameXML above it, a font registry, an anchor graph — not a four-line fixture. Every assertion
//! below is a global an addon would reach for.
use benilla_ui::script::UiScript;
const UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/assets/ui");
/// The prefix of `benilla.toc`'s load order these templates need: the font registry, the panel kit,
/// UIParent (the parent every addon passes), and the faux-scroll kit.
///
/// **Two of the templates under test are the REFERENCE's since 1860** — `FauxScrollFrameTemplate`
/// and `TabButtonTemplate` were ours until the dead-copy sweep, and both now come off the player's
/// chain from `Interface\FrameXML\UIPanelTemplates.xml`, seated below `UiPanels.xml` exactly as
/// the manifest seats it. So this list carries the chain pair and the loader below has to be able
/// to READ a chain entry, which a disk-only provider under `assets/ui` cannot.
const FILES: &[&str] = &[
"Interface\\FrameXML\\Fonts.xml",
// A REGRESSION GUARD, not a dependency (1923). FadingFrame.xml's entire body is a single
// `<Script file="FadingFrame.lua"/>`, so it loads correctly ONLY if that relative reference
// resolves against the document's own directory. Load it through the path-less `loader::load`
// and the base is "", the ref stays bare, the provider misses, and the assert below fires —
// which is exactly what it did before this file passed the path.
//
// It is self-contained on BOTH axes, and both had to be checked: zero `inherits=`, AND its Lua
// has no file-scope statements at all — only function definitions. The first guard tried here
// was CombatFeedback.xml, which also has zero `inherits=` but whose Lua calls `TEXT()` at file
// scope (l.7); that global is BasicControls.xml's, so it needed a dependency after all.
// `UnitFrame.lua` fails the same way (`TEXT(MANA)`). Structural dependencies are not the only
// kind.
"Interface\\FrameXML\\FadingFrame.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which StaticPopup.lua reads at file scope
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml", // the dialog engine (1960)
"UIParent.xml",
"ScrollTemplates.xml",
];
fn load_ui(script: &UiScript) {
let dir = std::path::Path::new(UI_DIR);
// A manifest entry carrying a path separator is the PLAYER's own file and has to come off the
// patch chain; a bare name is ours, under `assets/ui`. The shipped loader draws the same line
// (`reference_ui::is_chain_entry`), and this test grew the chain half when 1860 moved two of
// the templates it exercises onto it.
let chain = benilla_formats::wow_data().and_then(|d| benilla_formats::open_chain(&d).ok());
let read = |req: &str| -> Option<Vec<u8>> {
let norm = req.replace('\\', "/");
if norm.contains('/') {
if let Some(c) = chain.as_ref() {
if let Ok(b) = c.read(&norm) {
return Some(b);
}
}
}
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read(dir.join(&norm))
.or_else(|_| std::fs::read(dir.join(base)))
.ok()
};
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
for file in FILES {
let bytes = read(file).unwrap_or_else(|| panic!("reading {file}"));
// A `.lua` manifest entry is a CHUNK, not a document — the shipped loader draws the same
// line, and `UIPanelTemplates` is split that way because the reference splits it that way.
if file.to_ascii_lowercase().ends_with(".lua") {
script
.run_chunk_named(&bytes, &format!("@{file}"))
.unwrap_or_else(|e| panic!("{file}: {e}"));
continue;
}
let text = benilla_ui::source::decode(&bytes);
let doc =
benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("parsing {file}: {e}"));
// The document's OWN path, not the path-less `load` (1923). The base is what every
// relative `<Script file=>` / `<Include file=>` inside resolves against (1186); with
// an empty base they stay bare and miss the provider entirely, so a self-sourcing
// stock file loads as an empty shell and every handler in it goes missing — silently,
// because nothing in these lists used to be self-sourcing.
let report = benilla_ui::loader::load_in(script, &doc, &file.replace('\\', "/"), &provider);
assert!(
report.errors.is_empty(),
"{file} loaded with errors: {:#?}",
report.errors
);
}
}
/// The line an addon writes, and the globals it reads on the next one.
///
/// `TabButtonTemplate` (UiPanels.xml) is a `<Button>` carrying a `<Size>`, six `<Layers>` slices,
/// a `<ButtonText name="$parentText">`, a `<HighlightTexture name="$parentHighlightTexture">`, the
/// three state fonts and an `<OnUpdate>` — i.e. every decoration pass at once. Instantiating it as
/// `BenillaTemplateProbeTab` must publish `BenillaTemplateProbeTab*`, and must publish nothing
/// named after the template.
#[test]
fn a_real_template_reaches_an_addon_through_create_frame() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_ui(&s);
// Clear anything the UI load itself had to say; what follows is this call's alone.
let _ = s.take_warnings();
let _ = s.take_errors();
s.run(
r#"ProbeTab = CreateFrame("Button", "BenillaTemplateProbeTab", UIParent, "TabButtonTemplate")"#,
)
.expect("the corpus idiom must not error");
// The template's authored `<Size>` is 115x32 in BOTH our retired copy and the reference's, but
// the reference's `<OnLoad>` calls `PanelTemplates_TabResize(0)` — it FITS the tab to its text
// on load, and a freshly created tab has none, so what survives is the two end caps. Ours left
// the fit to an OnUpdate settle (1004), which is why this used to read the pre-fit 115. The
// height is untouched by the fit and still the template's (1860).
let (w, h) = s
.eval::<(f32, f32)>("return ProbeTab:GetWidth(), ProbeTab:GetHeight()")
.unwrap();
assert_eq!(h, 32.0, "the template's own <Size> height");
let caps = s
.eval::<f32>("return 2 * BenillaTemplateProbeTabLeft:GetWidth()")
.unwrap();
// `caps` plus the empty label's one-unit floor (`FONTSTRING_MIN_SPAN`) — the point is that it
// collapsed to its end caps, nowhere near the authored 115.
assert!(
w >= caps && w <= caps + 1.5,
"an unlabelled tab fits down to its two end caps: {w} vs {caps}"
);
assert_eq!(
s.eval::<String>("return ProbeTab:GetParent():GetName()")
.unwrap(),
"UIParent",
"the parent argument, not the template's idea of one"
);
// The globals an addon addresses the parts by — every one named against the INSTANCE. The
// reference's own kit does exactly this (`getglobal(tabName.."Text")` in PanelTemplates).
for suffix in [
"Text", // <ButtonText name="$parentText">
"HighlightTexture", // <HighlightTexture name="$parentHighlightTexture">
"Left", // <Layers> slices
"Middle",
"Right",
"LeftDisabled",
] {
assert!(
s.eval::<bool>(&format!(
r#"return getglobal("BenillaTemplateProbeTab{suffix}") ~= nil"#
))
.unwrap(),
"BenillaTemplateProbeTab{suffix} must exist"
);
assert!(
s.eval::<bool>(&format!(
r#"return getglobal("TabButtonTemplate{suffix}") == nil"#
))
.unwrap(),
"nothing may be published under the TEMPLATE's name (TabButtonTemplate{suffix})"
);
}
// The label is a real FontString the addon can drive.
s.run(r#"getglobal("BenillaTemplateProbeTabText"):SetText("Hi")"#)
.unwrap();
assert_eq!(
s.eval::<String>(r#"return getglobal("BenillaTemplateProbeTabText"):GetText()"#)
.unwrap(),
"Hi"
);
// The template's <Scripts> reached the frame. **`OnLoad`, not `OnUpdate`, since 1860** — the
// OnUpdate was our own text-fit settle (1004), and the reference's `TabButtonTemplate` declares
// exactly one handler: an `<OnLoad>` that calls `PanelTemplates_TabResize(0)` and sizes the
// highlight. That it ran is what the collapsed width above already proves.
assert!(
s.eval::<bool>(r#"return ProbeTab:GetScript("OnLoad") ~= nil"#)
.unwrap(),
"the template's OnLoad is installed"
);
assert!(s.take_errors().is_empty());
assert_eq!(
s.take_warnings(),
Vec::<String>::new(),
"a template that resolves cleanly has nothing to report"
);
}
/// The template the corpus actually asks for, and the handler that proves the naming rule.
///
/// `FauxScrollFrameTemplate` is the most-instantiated template in the 218-addon vanilla corpus that
/// benilla declares at all — 15 `CreateFrame` call sites across AckisRecipeList, FonzAppraiser,
/// Leader, Optional and oRA2. Its `<OnLoad>` is `ScrollFrame_OnLoad`, whose very first line is
///
/// ```lua
/// getglobal(this:GetName() .. "ScrollBarScrollDownButton"):Disable()
/// ```
///
/// so the handler **dies on a nil index** unless the caller's own name won all the way down: the
/// template's nested `<Slider name="$parentScrollBar">` had to become `<caller>ScrollBar`, and
/// *that* slider's own inherited `$parentScrollDownButton` had to become
/// `<caller>ScrollBarScrollDownButton`. Two levels of `$parent`, composed through a runtime
/// template, checked by shipped code rather than by an assertion we wrote to match.
#[test]
fn the_corpus_favourite_template_composes_parent_two_levels_deep() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_ui(&s);
let _ = s.take_warnings();
let _ = s.take_errors();
s.run(
// **A `ScrollFrame`, not a `Frame`, since 1860.** `FauxScrollFrameTemplate` is now the
// reference's own and the reference declares it as a `<ScrollFrame>` carrying a
// `<ScrollChild>`; `framexml::merge` takes the OVERRIDING node's tag, so asking for a
// "Frame" keeps the frame a Frame and the ScrollFrame-only parts — the scroll child among
// them — cannot apply. Our retired copy was a plain `<Frame>`, which is why the corpus
// idiom used to read that way here.
r#"Scroller = CreateFrame("ScrollFrame", "BenillaTemplateProbeScroll", UIParent, "FauxScrollFrameTemplate")"#,
)
.expect("the corpus's most-used template must not error");
// `ScrollFrame_OnLoad` ran to completion — it set `this.offset`, its last statement, which it
// cannot reach if either getglobal above returned nil.
assert_eq!(
s.eval::<f32>("return Scroller.offset").unwrap(),
0.0,
"the template's OnLoad ran to its last line"
);
for suffix in [
"ScrollBar",
"ScrollBarScrollUpButton",
"ScrollBarScrollDownButton",
] {
assert!(
s.eval::<bool>(&format!(
r#"return getglobal("BenillaTemplateProbeScroll{suffix}") ~= nil"#
))
.unwrap(),
"BenillaTemplateProbeScroll{suffix} must exist"
);
}
assert_eq!(
s.eval::<String>("return BenillaTemplateProbeScrollScrollBar:GetParent():GetName()")
.unwrap(),
"BenillaTemplateProbeScroll"
);
assert!(
s.eval::<bool>(r#"return getglobal("FauxScrollFrameTemplateScrollBar") == nil"#)
.unwrap(),
"nothing may be published under the template's own name"
);
assert!(s.take_errors().is_empty());
assert_eq!(s.take_warnings(), Vec::<String>::new());
}