the main action bar pages, and <Include> runs Lua like the client

- the main bar's page arrows page for real, with the reference's own rule that a page a shown multibar displays is not also reachable by paging
- `<Include file="X.lua">` executes the file the way the client does — dispatch on the extension — so an addon pulling its libraries through XML stops losing them
- UIParentLoadAddOn lands, the on-demand loader behind every "open the panel" path
- nine constants take the reference's names back (NUM_GROUP_LOOT_FRAMES and friends — an addon reads the real name, not our prefix), and UnitCreatureType answers through the client's own three-stage resolver

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-14 18:54:39 +07:00
co-authored by Claude Fable 5
parent ae70c69443
commit a0fce18079
15 changed files with 592 additions and 39 deletions
@@ -1068,10 +1068,18 @@ fn the_reference_action_bar_constants_are_defined() {
"zBar.lua:40's numeric for must have a limit"
);
assert!(
s.eval::<bool>("return CURRENT_ACTIONBAR_PAGE == nil").unwrap(),
"CURRENT_ACTIONBAR_PAGE is mutable page state we do not keep — nil fails loudly, a frozen 1 lies"
// `CURRENT_ACTIONBAR_PAGE` was asserted ABSENT here, on the grounds that a frozen 1 lies where
// nil fails loudly. That objection is discharged, not overruled: the bar pages now, so the
// global is live state the paged-id formula reads rather than a frozen number. It is therefore
// asserted as state — present, and MOVING — instead of as one of the constants above.
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
2,
"a frozen 1 would still be a lie — this one has to move"
);
s.run("ActionBar_PageDown()").unwrap();
}
/// **The reference's two-level action-button split, both halves inheritable by name.**
@@ -1155,3 +1163,102 @@ fn both_reference_action_button_templates_are_inheritable() {
"BenillaActionButtonTemplate's 48 inherits= sites must be untouched by the split"
);
}
/// Main-bar paging — `CURRENT_ACTIONBAR_PAGE` and the three verbs around it.
///
/// The data was always there (the app owns all 120 action slots); only the selector was missing,
/// and its absence was visible on screen as page arrows with no `OnClick`. `Bartender2.lua:686`
/// died on the nil `ChangeActionBarPage` at session start.
///
/// Two things are asserted that a reconstruction would get wrong. **A bonus page outranks the
/// paged one** — the reference's own `ActionButton_GetPagedID` takes the bonus branch first, so
/// paging must be the `else` arm and not an addition. And **page-up wraps to the literal page 1**
/// while page-down rescans for the last viewable page: that asymmetry is the reference's, and it
/// is observable the moment a page is blanked from `VIEWABLE_ACTION_BAR_PAGES`.
#[test]
fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in ["Cooldown.xml", "ActionBar.xml"] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
1,
"page 1 button 1 is action 1"
);
// Page up walks to 2, so button 1 shows action 13.
s.run("ActionBar_PageUp()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 2);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
13
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton12)")
.unwrap(),
24
);
// Down again, and below page 1 it rescans to the LAST viewable page.
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(), 1);
s.run("ActionBar_PageDown()").unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
4,
"page-down off the bottom rescans for the last VIEWABLE page — 4, not 6, because the \
always-on bottom multibars own pages 5 and 6"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
37
);
// The pages the bottom bars already display are unreachable from the main bar, which is what
// MultiActionBar_Update does on the reference the moment those bars are shown. Without this,
// paging up lands on a duplicate of the twelve actions already on screen below.
assert!(s
.eval::<bool>(
"return VIEWABLE_ACTION_BAR_PAGES[5] == nil and VIEWABLE_ACTION_BAR_PAGES[6] == nil"
)
.unwrap());
s.run("CURRENT_ACTIONBAR_PAGE = 4 ActionBar_PageUp()")
.unwrap();
assert_eq!(
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
1,
"walking up from the last viewable page skips 5 and 6 and wraps to the LITERAL 1 — the \
reference's own asymmetry with page-down, which rescans instead"
);
// A bonus page outranks the paged one entirely: with an offset up, the page is ignored.
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
s.set_bonus_bar_offset(1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
73,
"bonus offset 1 is action 73, whatever page the main bar is on"
);
s.set_bonus_bar_offset(0);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
25,
"and the page comes back when the form drops"
);
}
@@ -415,3 +415,66 @@ fn the_list_reports_an_out_of_date_interface_like_the_char_select_twin() {
"no `## Interface` at all is silent, not out of date"
);
}
/// `UIParentLoadAddOn(name)` — the on-demand loader 5 corpus addons name (FuBar_MCPFu,
/// Fubar_RareTrackerFu, MCP, MikScrollingBattleText, SpecialTalent), and which raised as a nil
/// global until every piece it needs was joined up in `BasicControls.xml`.
///
/// Two things are asserted, and the second is the one a reconstruction from the name would miss:
/// it answers `LoadAddOn`'s own falsey result so a caller can branch, and `FailedAddOnLoad` shows
/// the failure **once per addon name**, not once per attempt (ref `UIParent.lua` l.641-643).
#[test]
fn ui_parent_load_addon_reports_once_and_returns_the_load_result() {
let s = UiScript::new().unwrap();
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/BasicControls.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
// The two GlobalStrings this leans on. The real client has them from FrameXML's first file and
// the corpus survey runs the real `GlobalStrings.lua`; a bare VM does not, so they are seated
// here rather than left to make `format` raise on a nil.
s.run(
r#"
ADDON_LOAD_FAILED = "Couldn't load %s: %s"
ADDON_MISSING = "Missing"
shown = {}
message = function(t) table.insert(shown, t) end
"#,
)
.unwrap();
// A name no registry has: LoadAddOn answers (nil, "MISSING"), so this answers falsey.
assert!(
s.eval::<bool>(r#"return UIParentLoadAddOn("NoSuchAddonAnywhere") == nil"#)
.unwrap(),
"must hand back LoadAddOn's own falsey result for a caller to branch on"
);
assert_eq!(
s.eval::<String>("return shown[1]").unwrap(),
"Couldn't load NoSuchAddonAnywhere: Missing",
"the reason word resolves through ADDON_..reason"
);
// The latch: a retrying addon gets one modal, not one per attempt.
for _ in 0..3 {
s.run(r#"UIParentLoadAddOn("NoSuchAddonAnywhere")"#)
.unwrap();
}
assert_eq!(
s.eval::<i64>("return table.getn(shown)").unwrap(),
1,
"FailedAddOnLoad must suppress every repeat for the same name"
);
// A DIFFERENT name is a different latch entry, so it still reports.
s.run(r#"UIParentLoadAddOn("AlsoNotThere")"#).unwrap();
assert_eq!(s.eval::<i64>("return table.getn(shown)").unwrap(), 2);
}