Files
benilla-octo/crates/benilla-app/src/ui_script/options_tests.rs
T
pkuzicandClaude Opus 5.5 a74a46594c MONKEY: enhanced water module (Water Quality: Classic/Enhanced/High) + lava glow
Water (Enhanced/High only; Classic stays byte-identical to upstream):
- scene depth resolved after the main opaque pass and bound to liquids
  (liquid/scene_depth.rs, water_depth.rs)
- procedural multi-band waves with analytic normals; long ocean swell moves
  vertices, mirrored on the CPU (liquid/waves.rs)
- separate ocean and inland profiles; depth-driven colour and soft edge
- Fresnel sky reflection, sun path, moon path, point-light glints
- beach surf driven by the shore wave phase: continuous front, dissolving
  lace, swash sheet; only where the authored sea bed is shallow, so sunken
  objects never foam; thin bed clutter (grass, reeds) ignored for contact foam
- swimmers: wake foam, treading rings, visual-only bob with the swell
- river flow effect tried and removed at the owner's request

Lava: magma surfaces emit warm point lights through the lighting module
(lighting/lava_light.rs, lavaLightGain).

Options: Water Quality + Lava Glow rows (EN, RU strings prepared).
Docs: WATER.md. Credits: design reference WarcraftXL wxl-experimental-water,
author iThorgrim (reuse permitted with attribution).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:15:57 +01:00

5474 lines
236 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The shipped `assets/ui/OptionsFrame.xml` — the era-shaped, 1.12-skinned options window
//! (0950: the shell; 0957: the Audio page; 0959: the Graphics page; 0978: the 1.12-native
//! skin — no era extraction, every texture from the MPQ chain; 0981: the 1.14 System-window
//! dialog chrome — translucent dark ground, outline boxes, hairline dividers; 0984: the 1.14
//! select/hover wash mechanism, the working era search; 0985: the provenance split those
//! cite; 0989: the directed cuts — steppers and the corner X gone, the whole bar live via
//! the engine's track-press law, the search box at the era's verbatim seat; 0992: the
//! dropdown row shape on the 1.12 kit — Camera Following Style — and the Nameplates page's
//! three UnitName* rows; 1476: the ground dim — a black fill over the 0.6 tile, seated clear
//! of the rope's ink, because a 60% veil is not a page you can read over bright terrain).
//!
//! What these guard: the file loads clean inside the real neighbourhood (Fonts + UiPanels +
//! GameMenuFrame); the menu's Options button is the door in (menu down, options up, on the ref's
//! own kit); Controls is the default category and the page title follows the selection; both
//! close spellings put the window away; the selected row wears the LOCKED GOLD additive wash
//! and hover runs the steel-blue one (the 1.14 pair); and the search reflows the live rows
//! under category heads and restores the authored page exactly.
//!
//! The page tests (0957 Audio, 0959 Graphics) run against the REAL registered CVar set
//! (`crate::cvars`): rows read the table on select, writes land on the change queue the host
//! drains, the snap grids and readouts hold (the era 5% volumes; the 1.12 uiscale 0.01 and
//! farclip min-anchored 60), the 1.12 master→ambience dependency greys, and Defaults walks the
//! visible page back to the registered defaults.
use benilla_ui::script::{QuadContent, SoundRequest, UiScript, WornDisplay};
/// The window's real neighbourhood, in the manifest's own order (options before the menu — the
/// game_menu_tests::harness_with idiom, minus the extras this file never needs).
fn harness() -> UiScript {
harness_on(UiScript::new().unwrap())
}
/// Load the manifest slice onto a prepared script — split out so page tests can seed CVars
/// BEFORE the XML loads, the way the app does (ui_script::setup_script). The dropdown kit
/// rides along since 0992 (the dropdown rows inherit its capsule template), and GameTooltip
/// before it for the kit's TOOLTIP_DEFAULT_COLOR — the app's own order.
fn harness_on(mut s: UiScript) -> UiScript {
s.set_screen_size(1024.0, 768.0);
// A file loads ONCE, as in the client: the definer harnesses below load some of these ahead
// of this list to capture file-scope values, and a second load of the stock money kit's
// frames trips its own global-named update (`MoneyFrame_Update` writes the amount onto
// `getglobal(name)` — the first instance — while the second instance reads its own).
let loaded = |s: &UiScript, file: &str| -> bool {
s.eval::<bool>(&format!(
"return (BENILLA_TEST_LOADED or {{}})[{file:?}] == true"
))
.unwrap_or(false)
};
for file in [
"Interface\\FrameXML\\Fonts.xml",
// Every panel window declares `parent="UIParent"`, resolved at LOAD — so UIParent has to
// exist by the time they are read, exactly as it does in the manifest (decision 1734).
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.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",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
// The reference's own Interface Options window, hidden — this kit's source of truth for
// `UIOptionsFrameCheckButtons`/`UIOptionsFrameSliders`, which our own file used to
// transcribe and 2115 deleted. Its widget kit and the video window's shared helpers come
// with it, in the manifest's own order.
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"ScrollTemplates.xml", // the Keybindings page's faux-scroll kit
"KeyBindingsPage.xml", // the Keybindings body's templates + script (1008)
"OptionsFrame.xml",
"GameMenuFrame.xml",
] {
// `test_ui::load_ui`, not a local read: a manifest entry carrying a path separator is the
// REFERENCE's own file and must come off the player's chain, which
// `std::fs::read_to_string` under `assets/ui` cannot do — it goes looking for
// `assets/ui/Interface/FrameXML/...` and fails. The shared loader resolves both shapes, and
// its own doc already records this consolidation happening once before. Hand-rolling it
// here is what made this kit break the moment a file it loads migrated (1751).
// The dialect announces DROPPED subtrees as warnings, not errors — for the new file,
// a warning is a silently-missing piece of chrome, so it fails there.
if loaded(&s, file) {
continue;
}
if file == "OptionsFrame.xml" {
super::test_ui::load_ui_strict(&s, file);
} else {
super::test_ui::load_ui(&s, file);
}
}
// The app's own post-load pass, at the app's own moment: everything is loaded, so
// `SHOW_BUFF_DURATIONS` (declared by OptionsFrame.xml, just above) is real by the time the
// buff bar's row pitch is settled from it. `BuffFrame_OnLoad` does not do this and 1.12 leaves
// it to UIOptionsFrame.lua, which we have no counterpart to (`manifest::apply_buff_durations`).
super::manifest::apply_buff_durations(&s).unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// The door in: the game menu's Options button (its ref OnClick + the explicit hide) swaps the
/// two native-center frames — menu down, options up holding the center slot — on the ref's own
/// igMainMenuOption kit.
#[test]
fn the_menu_options_button_swaps_the_menu_for_the_options_window() {
let mut s = harness();
s.run("ShowUIPanel(GameMenuFrame)").unwrap();
let _ = s.take_sounds();
s.run("GameMenuButtonOptions:Click()").unwrap();
assert!(
s.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap(),
"the options window opened"
);
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and the menu went away first"
);
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == \"BenillaOptionsFrame\"")
.unwrap(),
"the window holds the native-center slot the menu vacated"
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOption".into())));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Controls is the default page (the OnShow seat), and the page title is the selected row's own
/// label — including the one key whose label differs from it (ActionBars → "Action Bars").
#[test]
fn controls_is_the_default_category_and_the_title_reads_it() {
let s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert_eq!(
s.eval::<String>("return BenillaOptionsFrame.selectedCategory")
.unwrap(),
"Controls"
);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Controls"
);
// The row labels are the era category tree's.
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameCategoryListRowActionBars:GetText()")
.unwrap(),
"Action Bars"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Clicking a category row moves the selection and the page title with it — and the selection
/// survives a close/reopen (the OnShow re-applies the last seat, not the default).
#[test]
fn clicking_a_row_moves_the_selection_and_the_page_title() {
let s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return BenillaOptionsFrame.selectedCategory")
.unwrap(),
"Graphics"
);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Graphics"
);
// Close and reopen: still Graphics, not Controls.
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Graphics"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The red Close hides the window on the igMainMenuClose kit, and the corner X does not EXIST
/// (0989's directed cut).
///
/// This test used to ride a rowless page here to watch Defaults stay disabled, and the stand-in
/// kept moving as the arc filled pages in — Controls until 0961, Interface until 1136, Social
/// (now Chat) until 1139. **There is no rowless category left**, which is the milestone rather than a gap;
/// the guard itself is pinned by `the_defaults_button_is_armed_by_rows_not_by_a_category` below.
#[test]
fn the_close_button_hides_the_window() {
let mut s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let _ = s.take_sounds();
s.run("BenillaOptionsFrameCloseButton:Click()").unwrap();
assert!(!s
.eval::<bool>("return BenillaOptionsFrame:IsVisible()")
.unwrap());
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuClose".into())));
// No corner X (0989's directed cut — the era HAS one; the red button and ESC are the
// window's exits).
assert!(s
.eval::<bool>("return BenillaOptionsFrameClosePanelButton == nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The row wash (0984: the 1.14 OptionsListButtonTemplate mechanism, verbatim — ONE
/// UI-QuestLogTitleHighlight quad in ADD blend, two tints): the selected row's wash draws
/// additive in the LOCKED GOLD (1,1,0) at the era plate seat (187x21 — sized once in
/// OptionsCategoryRow_OnLoad); a moved selection reseats the single gold quad lower; and
/// hovering an UNSELECTED row lights the same texture in the steel-blue hover tint
/// (.196,.388,.8) while the locked gold stands — 1.14's LockHighlight guard.
#[test]
fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
let mut s = harness();
// Pin the window at scale 1 so row rects and the pointer share coordinates (the fit
// clamp stays out of the way at 1024x768: both ratios sit above 1).
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
// The plate seat: the era's 187x21, authored in the row OnLoad now that no
// SetAtlas(name, true) sizes it.
assert_eq!(
s.eval::<f64>("return BenillaOptionsFrameCategoryListRowControlsBg:GetWidth()")
.unwrap(),
187.0
);
assert_eq!(
s.eval::<f64>("return BenillaOptionsFrameCategoryListRowControlsBg:GetHeight()")
.unwrap(),
21.0
);
// Every visible category wash: rect + tint + blend.
let washes = |s: &mut UiScript| -> Vec<(benilla_ui::layout::Rect, [f32; 4], bool)> {
s.resolve();
s.extract()
.into_iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
additive,
..
} if p.contains("UI-QuestLogTitleHighlight") => {
q.rect.map(|r| (r, color.unwrap_or([1.0; 4]), *additive))
}
_ => None,
})
.collect()
};
let gold =
|c: &[f32; 4]| (c[0] - 1.0).abs() < 1e-3 && (c[1] - 1.0).abs() < 1e-3 && c[2].abs() < 1e-3;
let blue = |c: &[f32; 4]| {
(c[0] - 0.196).abs() < 1e-3 && (c[1] - 0.388).abs() < 1e-3 && (c[2] - 0.8).abs() < 1e-3
};
let before = washes(&mut s);
assert_eq!(
before.len(),
1,
"exactly one wash shows: the locked selection"
);
assert!(before[0].2, "the wash draws ADD — 1.14's alphaMode");
assert!(gold(&before[0].1), "…in the locked gold: {:?}", before[0].1);
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
let after = washes(&mut s);
assert_eq!(after.len(), 1, "the wash moved, not multiplied");
assert!(gold(&after[0].1), "still the gold tint: {:?}", after[0].1);
assert!(
after[0].0.top < before[0].0.top,
"Audio sits below Controls, so the wash reseats lower (top {} -> {})",
before[0].0.top,
after[0].0.top
);
// Hover the now-unselected Controls row: its wash lights BLUE beside Audio's gold.
let (cx, cy) = {
let l: f32 = s
.eval("return BenillaOptionsFrameCategoryListRowControls:GetLeft()")
.unwrap();
let r: f32 = s
.eval("return BenillaOptionsFrameCategoryListRowControls:GetRight()")
.unwrap();
let t: f32 = s
.eval("return BenillaOptionsFrameCategoryListRowControls:GetTop()")
.unwrap();
let b: f32 = s
.eval("return BenillaOptionsFrameCategoryListRowControls:GetBottom()")
.unwrap();
((l + r) * 0.5, (t + b) * 0.5)
};
s.mouse_move(cx, cy);
let hovered = washes(&mut s);
assert_eq!(
hovered.len(),
2,
"hover adds its wash beside the locked gold"
);
assert_eq!(hovered.iter().filter(|w| gold(&w.1)).count(), 1);
assert_eq!(
hovered.iter().filter(|w| blue(&w.1)).count(),
1,
"the hover tint is 1.14's steel-blue: {:?}",
hovered.iter().map(|w| w.1).collect::<Vec<_>>()
);
// Leaving puts the hover wash away; the lock stays.
s.mouse_move(5.0, 5.0);
let left = washes(&mut s);
assert_eq!(left.len(), 1);
assert!(gold(&left[0].1));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The GROUND DIM (1476) — the plain black fill that turns 0.6 of veil into a page you can
/// read. Two laws here, both silently breakable by hand, and neither about the taste number:
/// the fill must draw **above** the backdrop's tiled ground (a region sorts after its frame's
/// own draw slot, which is the whole reason a region *can* dim it), and it must sit clear of
/// the rope's **ink** — the border's four edge slices are dead from texel 16 of 32 and the
/// corners' ink ends by 14 (decoded from `UI-DialogBox-Border`), so anything inset 14 or more
/// darkens the page without touching the frame. At the backdrop's own 11/12/12/11 bg insets it
/// would smear across the rope's inner half, which is exactly the mistake this pins.
#[test]
fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
let mut s = harness();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.resolve();
let edge = |frame: &str, side: &str| -> f32 {
s.eval(&format!("return {frame}:Get{side}()")).unwrap()
};
let (fl, fr, ft, fb) = (
edge("BenillaOptionsFrame", "Left"),
edge("BenillaOptionsFrame", "Right"),
edge("BenillaOptionsFrame", "Top"),
edge("BenillaOptionsFrame", "Bottom"),
);
let (dl, dr, dt, db) = (
edge("BenillaOptionsFrameGroundDim", "Left"),
edge("BenillaOptionsFrameGroundDim", "Right"),
edge("BenillaOptionsFrameGroundDim", "Top"),
edge("BenillaOptionsFrameGroundDim", "Bottom"),
);
for (name, inset) in [
("left", dl - fl),
("right", fr - dr),
("top", ft - dt),
("bottom", db - fb),
] {
assert!(
inset >= 14.0,
"the dim must clear the rope's ink on the {name} — inset {inset}"
);
}
// Draw order: `extract` returns ascending z, so a later index draws later. The dim has to
// land after the tiled ground or it dims nothing.
let quads = s.extract();
let ground = quads
.iter()
.position(|q| match &q.content {
QuadContent::Backdrop { path, .. } => path.contains("UI-DialogBox-Background"),
_ => false,
})
.expect("the window still wears the 1.14 dialog ground");
let dim = quads
.iter()
.position(|q| {
let Some(r) = q.rect else { return false };
matches!(
&q.content,
QuadContent::Texture { path: None, color: Some(c), .. }
if c[0] == 0.0 && c[1] == 0.0 && c[2] == 0.0 && c[3] > 0.0 && c[3] < 1.0
) && (r.left - dl).abs() < 0.5
&& (r.top - dt).abs() < 0.5
})
.expect("the black ground fill is drawn");
assert!(
dim > ground,
"the dim draws over the tile (ground #{ground}, dim #{dim})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Search (0984: the era SettingsPanel mechanism transcribed): typing reflows the LIVE
/// matching rows under a clickable category head — a matched CHILD pulls its parent volume
/// row in above it (the era parentInitializer rule) — the title reads "Search Results" and
/// Defaults hides; clearing the box lands every row back on its authored XML chain exactly
/// (the one-layout-law pin) with the page view restored.
#[test]
fn search_reflows_live_rows_and_restores_the_page() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
s.resolve();
let authored_top: f32 = s
.eval("return BenillaOptionsFrameContainerBodyAudioRowMaster:GetTop()")
.unwrap();
s.run("BenillaOptionsFrameSearchBox:SetText(\"music\")")
.unwrap();
// The search reflows off the box's `OnTextChanged`, which is deferred to the drain (1831).
s.tick(0.0);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Search Results"
);
assert!(
!s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsVisible()")
.unwrap(),
"Defaults hides while the search holds the page (the era SetShown(not hasText))"
);
// One head — Audio's; the matches plus their pulled-in parent; nothing else.
for (frame, shown) in [
("BenillaOptionsFrameContainerBodySearchHeadAudio", true),
("BenillaOptionsFrameContainerBodySearchHeadControls", false),
("BenillaOptionsFrameContainerBodySearchHeadGraphics", false),
("BenillaOptionsFrameContainerBodyAudioRowMusic", true),
("BenillaOptionsFrameContainerBodyAudioRowEnableMusic", true),
("BenillaOptionsFrameContainerBodyAudioRowMaster", true), // the parent, pulled in unmatched
("BenillaOptionsFrameContainerBodyAudioRowSound", false),
("BenillaOptionsFrameContainerBodyAudioRowEnableAll", false),
("BenillaOptionsFrameContainerBodyControlsRowAutoLoot", false),
("BenillaOptionsFrameContainerBodyGraphicsRowUiScale", false),
("BenillaOptionsFrameContainerBodyNoResults", false),
] {
assert_eq!(
s.eval::<bool>(&format!("return {frame}:IsVisible()"))
.unwrap(),
shown,
"{frame} shown={shown}"
);
}
// The chain reads head → parent → the two matches, downward.
s.resolve();
let tops: Vec<f32> = [
"BenillaOptionsFrameContainerBodySearchHeadAudio",
"BenillaOptionsFrameContainerBodyAudioRowMaster",
"BenillaOptionsFrameContainerBodyAudioRowMusic",
"BenillaOptionsFrameContainerBodyAudioRowEnableMusic",
]
.iter()
.map(|f| s.eval::<f32>(&format!("return {f}:GetTop()")).unwrap())
.collect();
assert!(
tops[0] > tops[1] && tops[1] > tops[2] && tops[2] > tops[3],
"head, parent, then the matches chain downward: {tops:?}"
);
// The reflowed row is LIVE: moving the music slider writes its CVar mid-search.
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyAudioRowMusicControlSlider:SetValue(0.25)")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("MusicVolume".to_string(), "0.25".to_string())]
);
// Clearing restores the page view and the authored chain EXACTLY.
s.run("BenillaOptionsFrameSearchBox:SetText(\"\")").unwrap();
s.tick(0.0); // the clear reflows on the drain, like the search itself (1831)
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Audio"
);
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsVisible()")
.unwrap());
s.resolve();
let restored_top: f32 = s
.eval("return BenillaOptionsFrameContainerBodyAudioRowMaster:GetTop()")
.unwrap();
assert!(
(restored_top - authored_top).abs() < 0.01,
"the restore law equals the authored XML chain ({restored_top} vs {authored_top})"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The era's word scoring (Blizzard_Settings.lua MatchesSearchTags + the words list): the
/// WHOLE query is the first word, so a phrase match outscores its own tokens and chains
/// first within the group — "master volume" seats Master above the token-matched children.
#[test]
fn the_phrase_match_outranks_its_words() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameSearchBox:SetText(\"master volume\")")
.unwrap();
s.resolve();
let tops: Vec<f32> = [
"BenillaOptionsFrameContainerBodyAudioRowMaster", // phrase hit, score 12
"BenillaOptionsFrameContainerBodyAudioRowSound", // "VOLUME" hits, score 5, page order
"BenillaOptionsFrameContainerBodyAudioRowMusic",
"BenillaOptionsFrameContainerBodyAudioRowAmbience",
]
.iter()
.map(|f| s.eval::<f32>(&format!("return {f}:GetTop()")).unwrap())
.collect();
assert!(
tops[0] > tops[1] && tops[1] > tops[2] && tops[2] > tops[3],
"phrase first, then the word matches in page order: {tops:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The era's search exits: clicking a results head (the era's jump — SelectCategory clears
/// the box) and a no-match query (the section-header "no results" line, nothing chained).
#[test]
fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
let mut s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameSearchBox:SetText(\"volume\")")
.unwrap();
s.tick(0.0); // the head being clicked below is built by the search reflow, so drain first (1831)
s.run("BenillaOptionsFrameContainerBodySearchHeadAudio:Click()")
.unwrap();
// The click clears the box synchronously; restoring the page rides that clear's
// `OnTextChanged`, which the drain owes (1831).
s.tick(0.0);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameSearchBox:GetText()")
.unwrap(),
"",
"the head click cleared the search (the era SelectCategory law)"
);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrame.selectedCategory")
.unwrap(),
"Audio"
);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Audio"
);
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
s.run("BenillaOptionsFrameSearchBox:SetText(\"flibbertigibbet\")")
.unwrap();
s.tick(0.0); // the miss renders on the drain too (1831)
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
for head in ["Controls", "Audio", "Graphics"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodySearchHead{head}:IsVisible()"
))
.unwrap(),
"no head on a miss"
);
}
s.run("BenillaOptionsFrameSearchBox:SetText(\"\")").unwrap();
s.tick(0.0); // and so does clearing it (1831)
assert!(!s
.eval::<bool>("return BenillaOptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bar IS the control (0989: steppers cut by the director's call; the engine's
/// track-press law): a LeftButton press on the slider's track — off the thumb — seats the
/// thumb under the cursor (the value jumps, the CVar write queues), the SAME press keeps
/// dragging (0250 §5's capture began), and the stepper buttons no longer exist.
#[test]
fn a_track_press_seats_the_thumb_and_keeps_dragging() {
let mut s = harness_on(audio_harness());
s.set_cvar_host("MasterVolume", "0.1");
s.run("ERA_WINDOW_SCALE = 1").unwrap(); // pointer and rects share coordinates
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
let _ = s.take_cvar_changes();
// The steppers are gone.
for btn in ["Back", "Forward"] {
assert!(s
.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyAudioRowMasterControl{btn} == nil"
))
.unwrap());
}
let slider = "BenillaOptionsFrameContainerBodyAudioRowMasterControlSlider";
s.resolve(); // seat the rects before reading them (the wash test's idiom)
let (l, r, t, b) = (
s.eval::<f32>(&format!("return {slider}:GetLeft()"))
.unwrap(),
s.eval::<f32>(&format!("return {slider}:GetRight()"))
.unwrap(),
s.eval::<f32>(&format!("return {slider}:GetTop()")).unwrap(),
s.eval::<f32>(&format!("return {slider}:GetBottom()"))
.unwrap(),
);
let cy = (t + b) * 0.5;
// Press 1 unit inside the RIGHT end of the track — far from the 0.1-seated thumb. The
// center-grab seat clamps the fraction to 1.0 there (the cursor sits inside the thumb's
// half-width end zone), so the value lands exactly at the max.
s.mouse_button(r - 1.0, cy, "LeftButton", true);
assert!(
s.eval::<bool>(&format!(
"return math.abs({slider}:GetValue() - 1.0) < 0.0001"
))
.unwrap(),
"the press itself seats the thumb"
);
assert_eq!(
s.take_cvar_changes(),
vec![("MasterVolume".to_string(), "1".to_string())]
);
// Still held: the same gesture drags on. The slider's midpoint is exactly fraction 0.5
// ((mid − thumb/2 − left) / (width − thumb)), on the 0.05 grid, so no snap correction.
s.mouse_move((l + r) * 0.5, cy);
assert!(
s.eval::<bool>(&format!(
"return math.abs({slider}:GetValue() - 0.5) < 0.0001"
))
.unwrap(),
"the capture began at the press — the drag follows without re-grabbing"
);
assert_eq!(
s.take_cvar_changes(),
vec![("MasterVolume".to_string(), "0.5".to_string())]
);
s.mouse_button((l + r) * 0.5, cy, "LeftButton", false);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A slider row's readout sits on its label's line.** The row shows one setting as two
/// `GameFontNormal` strings — the name at the left, the value at the right — and a reader takes
/// them as a single line; ours were **3 units apart** at every UI scale (≈4 px on a maximised
/// 1440p window, which is where B232 photographed them).
///
/// The cause is a nudge that leaked from the art onto the text: the readout hangs off
/// `$parentControl`, and the control was seated at the row's `CENTER(-80, +3)` — the +3 lifting
/// the 17-tall groove and its 32 px thumb inside the 26-tall row. Anything anchored to the
/// control inherited a seat that exists for a texture. The nudge now lives on the two art frames
/// it was for (`$parentGroove`, `$parentSlider`), so the bar does not move by a pixel and the
/// number falls onto the label's line — asserted here on both halves, over every slider row the
/// window has: a label/value line, and the groove still riding 3 units high of the row.
#[test]
fn a_slider_rows_readout_sits_on_its_labels_line() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
for (page, rows) in [
("Controls", &["RowMouseSpeed", "RowMaxCameraDistance"][..]),
(
"Audio",
&["RowMaster", "RowSound", "RowMusic", "RowAmbience"][..],
),
("Graphics", &["RowUiScale", "RowFarclip"][..]),
] {
s.run(&format!("BenillaOptionsFrameCategoryListRow{page}:Click()"))
.unwrap();
s.resolve(); // seat the rects before reading them
let mid = |frame: &str| -> f32 {
let top: f32 = s.eval(&format!("return {frame}:GetTop()")).unwrap();
let bottom: f32 = s.eval(&format!("return {frame}:GetBottom()")).unwrap();
(top + bottom) * 0.5
};
for row in rows {
let base = format!("BenillaOptionsFrameContainerBody{page}{row}");
// The two strings are the assertion: same font, same row, one line. (Both are
// regions, so both rects arrive in the same space — the comparison needs no scale.)
let (label, value) = (
mid(&format!("{base}Label")),
mid(&format!("{base}ControlValue")),
);
assert!(
(label - value).abs() < 0.01,
"{base}: the readout sits {:.2} off its label's line",
value - label
);
// …and the art it used to ride is exactly where it was: 3 units high of the row.
assert!(
(mid(&format!("{base}ControlGroove")) - mid(&base) - 3.0).abs() < 0.01,
"{base}: the groove left its seat"
);
}
}
}
/// The Audio harness: the real registered CVar set on the table before the XML loads, exactly
/// the app's boot order (register → seed → load → select).
fn audio_harness() -> UiScript {
let s = UiScript::new().unwrap();
s.register_cvars(crate::cvars::registered_pairs());
s
}
/// The Combat page's harness (decision 1134, 1964): the family's defaults are the window's own
/// file-scope block (the reference's UIOptionsFrame.lua l.135-152), and the addon they drive is
/// `Blizzard_CombatText` off the chain, loaded on demand by the master row's apply.
fn combat_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(&s, &[r"Interface\FrameXML\UIParent.xml"]);
// The family's definers are the options window's own since 1964 (the reference's
// UIOptionsFrame.lua block); the addon itself is LoadOnDemand off the chain, seated so the
// master row's apply can load it the way the client does.
super::test_ui::seat_chain_addon(&mut s, "Blizzard_CombatText");
harness_on(s)
}
/// The files a saved-variable page's DEFINERS live in, loaded ahead of the window (see
/// `combat_harness`). Every page over the second store needs this: a row captures its default from
/// the global's own file-scope assignment at OnLoad, so that file has to have run first — which is
/// exactly the ordering the real manifest guarantees.
fn load_definers(s: &UiScript, files: &[&str]) {
for file in files {
super::test_ui::load_ui(s, file);
// Remembered so `harness_on` loads each file once (see there).
s.run(&format!(
"BENILLA_TEST_LOADED = BENILLA_TEST_LOADED or {{}} BENILLA_TEST_LOADED[{file:?}] = true"
))
.unwrap();
}
}
/// The Interface page's harness (decision 1136), the same posture as `combat_harness` above: the
/// **real** definers ahead of the window, in the manifest's own order, so each row captures the
/// same file-scope value it captures in the client. `SHOW_NEWBIE_TIPS` is set by our own
/// `assets/ui/OptionsFrame.xml` (it lived in our `GameTooltip.xml` until the tooltip went stock,
/// 1968; the reference keeps it in `UIOptionsFrame.lua`), which `harness_on` already loads; the two
/// quest globals need their own windows — `MerchantFrame.xml` for the coin helpers both quest files
/// reuse and `ScrollTemplates.xml` for the kit `QuestFrame.xml` inherits from (the same chain
/// `quest_tests`/`questlog_tests` load). `SHOW_BUFF_DURATIONS` arrives with the bar it re-anchors
/// (1139), behind the two files `buff_tests` loads ahead of it — `Cooldown.xml` (every button's
/// child) and `ActionBar.xml` (`BENILLA_FALLBACK_ICON`), themselves behind `UIParent.xml`.
/// `TextStatusBar.xml` rides in ahead of them for the Status Bar Text row's consumer — the XP bar's numerals (1140).
fn interface_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(
&s,
&[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
// The target-of-target pair's definer (1576) and the three files ahead of it, all in
// their manifest seats. The chain is a real load-ORDER requirement rather than
// tidiness: `UnitFrames`' three menu hosts initialize into the dropdown kit at load
// (`UIDropDownMenu_Initialize` is nil without it and the OnLoad raises), and the kit's
// own backdrop reads `GameTooltip`'s TOOLTIP_DEFAULT_COLOR. `harness_on` loads two of
// these again after these — re-running a UI file is what `/reload` does, and the
// loader takes it.
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua reads at file scope
"Interface\\FrameXML\\UnitPopup.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
"Interface\\FrameXML\\UnitFrame.xml",
"Interface\\FrameXML\\CombatFeedback.xml",
"Interface\\FrameXML\\PlayerFrame.xml",
"Interface\\FrameXML\\PartyFrame.xml",
"Interface\\FrameXML\\TargetFrame.xml",
"Interface\\FrameXML\\PetFrame.xml",
// `PartyMemberBackground`'s OnEvent sets `OpacityFrameSlider` on VARIABLES_LOADED,
// and that slider is declared in ColorPickerFrame.xml. The reference loads them in
// this same order (its toc: PartyFrame 45, ColorPickerFrame 84) — it works there
// because the reader is an EVENT handler, not a load-time one, so by the time
// VARIABLES_LOADED fires the slider exists. A test that fires the event has to have
// loaded it too.
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"ScrollTemplates.xml",
"Interface\\FrameXML\\CharacterFrameTemplates.xml",
"Interface\\FrameXML\\MerchantFrame.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\QuestFrame.xml",
r"Interface\FrameXML\MainMenuBarMicroButtons.xml",
"Interface\\FrameXML\\QuestLogFrame.xml",
// The Show Tutorials row's setter is the reference's own Save arm, which reaches
// `TutorialFrameCheckButton` and `TutorialFrame_HideAllAlerts` UNGUARDED (2077) — so
// the page's harness loads the window that declares them, as the client does.
"Interface\\FrameXML\\TutorialFrame.xml",
],
);
harness_on(s)
}
/// The Chat page's harness (decision 1589), the same posture as `combat_harness`: the **real**
/// `ChatFrame.xml` ahead of the window, so the *Remove Chat Hover Delay* row captures the same
/// file-scope `REMOVE_CHAT_DELAY = "0"` and the same `ChatFrame_ApplyMouseOverDelay` it captures in
/// the client. Its own chain is the manifest's: `UIParent.xml` for the managed bottom stack the
/// dock sits in, and `GameTooltip.xml` + `UIDropDownMenu.xml` because the tabs' options menus are
/// dropdown capsules and a tab click reaches `CloseDropDownMenus`.
fn chat_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(
&s,
&[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
r"Interface\FrameXML\UIParent.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\StaticPopup.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\ChatFrame.xml",
"Interface\\FrameXML\\UIPanelTemplates.lua",
"Interface\\FrameXML\\UIPanelTemplates.xml",
"Interface\\FrameXML\\FloatingChatFrame.xml",
],
);
harness_on(s)
}
/// The Action Bars page's harness (1136's lock row, 1500's five switches): `ActionBar.xml` declares
/// `LOCK_ACTIONBAR` and `MultiBars.xml` the four `SHOW_MULTI_ACTIONBAR_*` globals plus
/// `ALWAYS_SHOW_MULTIBARS` (whose file-scope "0" IS the row's registered default), and both need
/// `UIParent.xml` (the managed bottom stack the bars move) and `Cooldown.xml`
/// (`CooldownFrame_SetTimer`, every button's child) ahead of them — the manifest's own order.
fn actionbars_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(
&s,
&[
"Interface\\FrameXML\\Fonts.xml",
r"Interface\FrameXML\UIParent.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
r"Interface\FrameXML\MoneyFrame.lua",
r"Interface\FrameXML\MoneyFrame.xml",
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
// two template files its check boxes inherit through (1875).
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"Interface\\FrameXML\\BasicControls.xml",
"Interface\\FrameXML\\LocaleProperties.lua",
"Interface\\FrameXML\\StaticPopup.xml",
"KeyBindingsPage.xml",
// The reference's own Interface Options window BEFORE ours, as the manifest has it
// (2115) — `UIOptionsFrame_Init` is what assigns `LOCK_ACTIONBAR` and
// `ALWAYS_SHOW_MULTIBARS`, and this page's rows capture those at their own OnLoad as
// the Defaults value. It is also the home of `UIOptionsFrameCheckButtons`, which
// `MultiActionBars.xml` below writes into at its load.
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
],
);
harness_on(s)
}
/// Selecting Audio shows the page body, arms Defaults, and every row reads the CVar table:
/// sliders take the stored value with the era rounded-percent readout, checkboxes take the flag.
/// Leaving the page hides it and puts Defaults back to sleep.
#[test]
fn the_audio_page_reads_the_cvar_table_on_select() {
let mut s = audio_harness();
s.set_cvar_host("MusicVolume", "0.7");
s.set_cvar_host("EnableMusic", "0");
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The music slider holds the stored 0.7 (f32 wobble tolerated), readout "70%".
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyAudioRowMusicControlSlider:GetValue() - 0.7) < 0.0001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyAudioRowMusicControlValue:GetText()"
)
.unwrap(),
"70%"
);
// Checkboxes: EnableMusic off, the master (default "1") on.
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowEnableMusicCheck:GetChecked()"
)
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyAudioRowEnableAllCheck:GetChecked()")
.unwrap());
// Off to another page: the Audio body goes away with the selection (the swap is what the
// page loop does, and it is the reason a stale row can never be read from the wrong page).
s.run("BenillaOptionsFrameCategoryListRowChat:Click()")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaOptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyChat:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A user move snaps to the era 5% grid (obeyStepOnDrag transcribed) and writes the CVar as a
/// clean short string — the change queue carries what config.toml will store. A refresh write
/// (the page reading the table) queues nothing.
#[test]
fn a_slider_move_snaps_and_writes_the_cvar() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
// An off-grid move (what a drag delivers) snaps to 0.45 and queues exactly that.
s.run("BenillaOptionsFrameContainerBodyAudioRowMasterControlSlider:SetValue(0.43)")
.unwrap();
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyAudioRowMasterControlSlider:GetValue() - 0.45) < 0.0001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyAudioRowMasterControlValue:GetText()"
)
.unwrap(),
"45%"
);
assert_eq!(
s.take_cvar_changes(),
vec![("MasterVolume".to_string(), "0.45".to_string())]
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The checkbox rows write their flag CVar on the 1.12 panel's own (quirky) click kits, and the
/// 1.12 dependency holds: Enable All Sound off greys exactly the Enable Ambience row.
#[test]
fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
let _ = s.take_cvar_changes();
let _ = s.take_sounds();
// Uncheck the master: flag "0" queued, ambience greyed, music left alive (the 1.12 quirk),
// and the just-UNchecked box plays the CheckBoxOn kit (SoundOptionsFrame.lua verbatim).
s.run("BenillaOptionsFrameContainerBodyAudioRowEnableAllCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("MasterSoundEffects".to_string(), "0".to_string())]
);
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled() ~= 0"
)
.unwrap());
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowEnableMusicCheck:IsEnabled() ~= 0"
)
.unwrap());
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Re-check: flag "1", ambience live again.
s.run("BenillaOptionsFrameContainerBodyAudioRowEnableAllCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("MasterSoundEffects".to_string(), "1".to_string())]
);
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowEnableAmbienceCheck:IsEnabled() ~= 0"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The background-sound row (decision 1847): the one Audio row 1.12 has no checkbox for. It
/// boots UNCHECKED — the reference's own behaviour, which is to go quiet in the background — and
/// its click writes the era CVar. It rides OUTSIDE the master's dependency rule, which names
/// exactly two rows in `SoundOptionsFrame_UpdateDependencies` and never grew a third.
#[test]
fn the_background_sound_row_boots_off_and_writes_the_era_cvar() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
let _ = s.take_cvar_changes();
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowBackgroundSoundCheck:GetChecked()"
)
.unwrap(),
"the reference goes quiet in the background, so the box ships unticked"
);
s.run("BenillaOptionsFrameContainerBodyAudioRowBackgroundSoundCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(
"Sound_EnableSoundWhenGameIsInBG".to_string(),
"1".to_string()
)]
);
// The master's rule greys Ambience and Error Speech and nothing else — this row stays live,
// because "should the game be audible at all" is not the question it answers.
s.run("BenillaOptionsFrameContainerBodyAudioRowEnableAllCheck:Click()")
.unwrap();
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowBackgroundSoundCheck:IsEnabled() ~= 0"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults walks every Audio row's CVar back to its registered default and the rows follow —
/// the era per-page reset, on the one page with rows.
#[test]
fn defaults_resets_the_audio_page_to_registered_defaults() {
let mut s = audio_harness();
s.set_cvar_host("MusicVolume", "0.9");
s.set_cvar_host("EnableMusic", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
let changes = s.take_cvar_changes();
assert!(
changes.contains(&("MusicVolume".to_string(), "0.4".to_string())),
"music back to its 1.12 registration default: {changes:?}"
);
assert!(
changes.contains(&("EnableMusic".to_string(), "1".to_string())),
"the flag back on: {changes:?}"
);
// Only the MOVED values queue — the rows already at default write nothing.
assert_eq!(changes.len(), 2, "{changes:?}");
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowEnableMusicCheck:GetChecked()"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyAudioRowMusicControlValue:GetText()"
)
.unwrap(),
"40%"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Selecting Graphics shows ITS page body (0959) with both 1.12 sliders reading the table:
/// uiScale on the 0.64..1.0 panel range with the percent readout, farclip (Terrain Distance —
/// retired 0961, back 1513) on 177..777 with the raw-yards readout. The swap works both ways —
/// Audio's body takes over when clicked.
#[test]
fn the_graphics_page_reads_the_cvar_table_on_select() {
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyGraphics:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:GetValue() - 0.8) < 0.0001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"80%"
);
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"297"
);
// The labels are the 1.12 GlobalStrings' own.
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowUiScaleLabel:GetText()"
)
.unwrap(),
"UI Scale"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowFarclipLabel:GetText()"
)
.unwrap(),
"Terrain Distance"
);
// The swap, the other way: Audio in, Graphics out.
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaOptionsFrameContainerBodyGraphics:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyAudio:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Display Mode** row (1650) — modern Classic's own control, which 1627 had already given us
/// the two states for. Blizzard's `Blizzard_SettingsDefinitions_Shared/Graphics.lua` (identical on
/// live, classic and classic_era) builds a boolean proxy as a dropdown with exactly two entries,
/// `VIDEO_OPTIONS_WINDOWED_FULLSCREEN` and `VIDEO_OPTIONS_WINDOWED`, and no Fullscreen entry at all.
///
/// **The polarity is the thing worth pinning.** `gxWindow` is 1.12's CVar and keeps 1.12's sense —
/// `"1"` is WINDOWED — so the FIRST entry, the borderless one this client defaults to, is the CVar's
/// `"0"`. A row whose label and value run opposite ways is exactly the kind that gets silently
/// inverted by a later edit, and until this test there was no coverage of the row at all.
#[test]
fn the_display_mode_dropdown_maps_its_entries_to_the_gx_window_polarity() {
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowDisplayMode";
let mut s = audio_harness();
s.set_cvar_host("gxWindow", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
.unwrap(),
"Display Mode"
);
// "0" is NOT windowed, which 1627 redefined as the borderless fullscreen window.
assert_eq!(
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
.unwrap(),
"Windowed (Fullscreen)"
);
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
// Two entries, and only two — the modern list has no Fullscreen row, because 8.0.1 removed
// the exclusive mode those clients had and 1627 ships none for its own platform reasons.
s.run(&format!("{ROW}DropdownButton:Click()")).unwrap();
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
assert_eq!(
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
2.0
);
assert!(s
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
.unwrap());
// Picking Windowed writes the CVar's "1", closes the list, and repaints the capsule.
s.run("DropDownList1Button2:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("gxWindow".to_string(), "1".to_string())]
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
.unwrap(),
"Windowed"
);
assert!(!s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Vertical Sync** (1394) — the Graphics page's third row, and the first option in this window
/// that reaches the *window* rather than a gameplay or UI knob. It is 1.12's own Video Options
/// checkbox 5 (`gxVSync`), which lived on the perf HUD as a dev checkbox until it turned out to be
/// unreachable in a player build (the HUD is `#[cfg(feature = "dev")]`) and to be a setting rather
/// than an instrument in the first place.
///
/// The row is deliberately **not** deferred, unlike the UI Scale slider above it: the reference's
/// row carries `gxRestart = 1` because its device could not swap the presentation interval live,
/// and wgpu can — so the click commits, like every other checkbox here.
#[test]
fn the_vertical_sync_row_reads_and_writes_the_present_mode_cvar() {
let mut s = audio_harness();
s.set_cvar_host("gxVSync", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
// Read from the table, not from a restated default: the harness seeded it off.
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:GetChecked()"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncLabel:GetText()"
)
.unwrap(),
"Vertical Sync"
);
let _ = s.take_cvar_changes();
// A click commits immediately — nothing stages it behind Apply.
s.run("BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("gxVSync".to_string(), "1".to_string())],
"the checkbox writes the cvar on click, not on Apply"
);
// Defaults walks it back to the registered "1" — which `cvars::tests` welds to
// `VideoConfig::default()`, and `video::tests` welds in turn to the window's boot mode.
s.run("BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:Click()")
.unwrap();
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:GetChecked()"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Environment Detail is the reference's own slider (1649): 0..2 step 1 over `WorldDetail`, with
/// 0992's Low/Medium/High names kept in the readout seat — a groove whose readout says "1" tells a
/// player nothing. Dragging writes the CVar; a value from outside the range shows the nearest stop
/// **The Brightness slider actually writes** (decision 2182, and the bug that shipped with it).
///
/// This row is the kit's first slider whose store is an engine PAIR rather than a CVar or a
/// saved-variable global — 1.12's own arrangement for its slider 6 — and
/// `OptionsSlider_OnValueChanged` guarded its write on `row.cvar or row.uvar`, two of the kit's
/// three stores. So dragging it moved the READOUT and wrote nothing: the control looked alive, the
/// picture never changed, and reopening the window re-read an untouched CVar and snapped back to
/// the centre. No error anywhere, on any path.
///
/// The shipped test missed it by calling `SetGamma` directly and checking the row's *wiring* — the
/// bounds, the numeric flag, the readout at the default. Everything it asserted was true. What it
/// never did was move the slider, which is the only thing a player does. So this one drags.
#[test]
fn the_brightness_slider_writes_through_its_engine_pair_and_survives_a_reopen() {
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowBrightness";
let s = audio_harness();
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
.unwrap(),
"Brightness"
);
// The registered `gamma = "1.0"` is `GetGamma() == 0`, the centre of the stock slider's
// travel — and selecting the page must not write it back.
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"50%"
);
assert!(
s.take_cvar_changes().is_empty(),
"reading the pair on select must not write it back"
);
// **A drag to each end WRITES.** The value is the reference's own `1 - slider`, six decimals,
// because `SetGamma` owns the formatting — this is the assertion the shipped test lacked.
for (slider, cvar, readout) in [
(0.5, "0.500000", "100%"),
(-0.5, "1.500000", "0%"),
(0.2, "0.800000", "70%"),
] {
s.run(&format!("{ROW}ControlSlider:SetValue({slider})"))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("gamma".to_string(), cvar.to_string())],
"dragging to {slider} must reach the store"
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
readout
);
}
// **And it comes back.** Leaving the page and returning re-reads the pair rather than the
// control, which is the half the player sees as "it didn't save": the row was left at 0.2.
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"70%",
"the reopened page reads the store, not the registered default"
);
assert!(
s.take_cvar_changes().is_empty(),
"…and the refresh itself writes nothing"
);
}
/// and writes nothing back (0959's out-of-range law).
#[test]
fn the_world_detail_slider_writes_the_cvar_and_the_readout_names_its_stop() {
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowWorldDetail";
let mut s = audio_harness();
s.set_cvar_host("WorldDetail", "0");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
.unwrap(),
"Environment Detail"
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"Low"
);
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
// The reference's grid, verbatim: three stops, one apart (OptionsFrame.lua l.27).
assert!(s
.eval::<bool>(&format!(
"local lo, hi = {ROW}ControlSlider:GetMinMaxValues() \
return lo == 0 and hi == 2 and {ROW}ControlSlider:GetValueStep() == 1"
))
.unwrap());
// A drag to the top stop: the write queues and the readout names it.
s.run(&format!("{ROW}ControlSlider:SetValue(2)")).unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("WorldDetail".to_string(), "2".to_string())]
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"High"
);
// Off-grid input snaps to the stop grid (era obeyStepOnDrag), naming the stop it landed on.
s.run(&format!("{ROW}ControlSlider:SetValue(0.6)")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"Medium"
);
// An out-of-range value (an env A/B: the hermetic capture's clutter-off session seeds "-1")
// displays the NEAREST stop — 0959's out-of-range law — and writes nothing back.
s.set_cvar_host("WorldDetail", "-1");
s.take_cvar_changes();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click(); BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"Low"
);
assert!(
s.take_cvar_changes().is_empty(),
"the nearest-stop display must not write back"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Nameplates page (0992 — live at last): the three 1.12 UnitName* checkbox rows read the
/// table on select and write their flags on the interface panel's own click kit (checked →
/// CheckBoxOn; these rows carry no soundQuirk).
/// **A world entry must leave the saved nameplate setting alone** (decision 2132) — the report,
/// on the real manifest with a real CVar table, which is the one combination no test had.
///
/// `UIParent_OnEvent` calls `UpdateNameplates()` twice per entry — `UIParent.lua` l.234
/// (VARIABLES_LOADED) and l.367 (PLAYER_ENTERING_WORLD) — and it acts on
/// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON`. On benilla the four verbs it calls write the CVar pair,
/// and the CVar pair IS the store, so with the globals left nil every entry wrote `"0"` over the
/// player's `"1"` and `config.toml` then dropped the line as "at default".
///
/// Two mechanisms are needed and this asserts both: the host seeding the globals ahead of
/// `VARIABLES_LOADED` (the enemy half), and our re-declared `UpdateNameplates` without the stock
/// `ShowFriendNameplates(); HideFriendNameplates();` typo (the friendly half). Drop either and the
/// matching row here goes back to `"0"`.
#[test]
fn a_world_entry_leaves_the_saved_nameplate_setting_alone() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// A manifest load with no player is a state the client never reaches (1848).
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefour".into()),
level: 60,
..Default::default()
}),
);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "loader errors: {failures:?}");
let _ = s.errors();
// The player has both halves on, restored from `config.toml` — a host write, so nothing is
// queued and the file is not re-dirtied by having been read.
let plates = crate::vplates::VPlateMode {
enemies: true,
friends: true,
};
let saved = |s: &mut UiScript| {
s.set_cvar_host(crate::vplates::CVAR_ENEMIES, "1");
s.set_cvar_host(crate::vplates::CVAR_FRIENDS, "1");
assert!(s.take_cvar_changes().is_empty(), "the host write is silent");
};
// ── The bug, kept as the control: with the globals nil the entry erases both halves ────────
saved(&mut s);
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert_eq!(
(
s.cvar(crate::vplates::CVAR_ENEMIES),
s.cvar(crate::vplates::CVAR_FRIENDS)
),
(Some("0".into()), Some("0".into())),
"nil globals ⇒ the replay writes the store off — this is what the report saw"
);
let _ = s.take_cvar_changes();
// ── The fix: the host seeds the globals, and the entry is a no-op end to end ───────────────
saved(&mut s);
crate::vplates::push_plate_globals(&s, plates);
assert_eq!(
s.eval::<i64>("return NAMEPLATES_ON").unwrap(),
1,
"the reference's own truthiness — the NUMBER 1, never the truthy `0`"
);
s.fire_event("VARIABLES_LOADED", vec![]);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert_eq!(
(
s.cvar(crate::vplates::CVAR_ENEMIES),
s.cvar(crate::vplates::CVAR_FRIENDS)
),
(Some("1".into()), Some("1".into())),
"the player's setting survives the entry"
);
assert!(
s.take_cvar_changes().is_empty(),
"and queues nothing, so `config.toml` is never dirtied by a world entry"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// The other direction still works: an addon that moves a global and calls the verb.
s.run("FRIENDNAMEPLATES_ON = nil UpdateNameplates()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(crate::vplates::CVAR_FRIENDS.to_string(), "0".to_string())],
"UpdateNameplates keeps its contract — it is adapted, not stubbed"
);
}
#[test]
fn the_nameplates_page_toggles_the_unit_name_cvars() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyNameplates:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// The plate pair leads the page and rides `VPlateMode::default()`: since 1804 both are OFF,
// which is the reference's own boot state (its bitmask starts clear and FrameXML's
// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` start nil). Enemy was 0167's pin until then.
for row in ["RowEnemyPlates", "RowFriendlyPlates"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
))
.unwrap(),
"{row} defaults unchecked"
);
}
// The name rows ride their registered defaults, which are the binary's: player "1", NPC and
// own "0" (1804 — the last two were director pins from 2026-07-12 until then).
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:GetChecked()"
)
.unwrap(),
"Player Names defaults checked"
);
for row in ["RowNpcNames", "RowOwnName"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
))
.unwrap(),
"{row} defaults unchecked"
);
}
let _ = s.take_sounds();
// The friendly plates row writes the bit the V/Shift-V pair writes — the same CVar, so the
// window and the keys can never disagree about what is on.
s.run("BenillaOptionsFrameContainerBodyNameplatesRowFriendlyPlatesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(crate::vplates::CVAR_FRIENDS.to_string(), "1".to_string())]
);
s.run("BenillaOptionsFrameContainerBodyNameplatesRowEnemyPlatesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(crate::vplates::CVAR_ENEMIES.to_string(), "1".to_string())]
);
let _ = s.take_sounds();
// Checking NPC Names queues the flag on and plays the ON kit; unchecking plays OFF (no quirk
// here — the 1.12 interface panel's PlayClickSound mapping, not the sound panel's inverted
// one).
s.run("BenillaOptionsFrameContainerBodyNameplatesRowNpcNamesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("UnitNameNPC".to_string(), "1".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
s.run("BenillaOptionsFrameContainerBodyNameplatesRowNpcNamesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("UnitNameNPC".to_string(), "0".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOff".into())));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The uiScale row DEFERS (0961, era CommitFlag.Apply transcribed): moves snap to the 1.12
/// 0.01 grid and update the readout, but the CVar does not move — the Apply button appears
/// instead, commits the pending value on click, and disappears. Dragging back onto the
/// committed value clears the pending without a commit (era's IsModified).
#[test]
fn the_ui_scale_slider_defers_to_the_apply_button() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
assert!(
!s.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap(),
"no pending edit, no Apply button"
);
// Off-grid 0.787 snaps to 0.79 and the readout follows — but NOTHING queues; the Apply
// button exists now (era shows and enables it together).
s.run("BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.787)")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"79%"
);
assert!(
s.take_cvar_changes().is_empty(),
"a deferred row must not write the CVar on the move"
);
assert!(s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsEnabled() ~= 0")
.unwrap());
// A second move re-stages the pending value — still nothing queues (0989: the steppers
// are gone; a drag's SetValue is the remaining move).
s.run("BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.8)")
.unwrap();
assert!(s.take_cvar_changes().is_empty());
// Apply commits the LAST pending value, once, and the button goes away.
s.run("BenillaOptionsFrameApplyButton:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("uiScale".to_string(), "0.8".to_string())]
);
assert!(!s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
// A move away arms Apply; dragging back onto the committed value disarms it.
s.run("BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.79)")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
s.run("BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.8)")
.unwrap();
assert!(!s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(
s.take_cvar_changes().is_empty(),
"arming and disarming never touched the CVar"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Render Scale** (decision 1639) — benilla's own Graphics row, and since 1648 the page's ONLY
/// antialiasing control: the reference's own Multisampling row was pulled on the director's call,
/// leaving `gxMultisample` reachable as a CVar and `$WOW_MSAA` but off the page.
///
/// Three things this pins, each of which was a real choice:
///
/// - **It reads the CVar and shows a percentage.** 1.0 must render as "100%", because the whole
/// point of the row is that the number in front of the player means something without a manual.
/// - **It is DEFERRED.** Committing live would rebuild the world's render target on every drag
/// tick — tens of megabytes, thirty times across one sweep of the handle. Staging to Apply makes
/// that exactly one rebuild. (`uiScale` above it defers for a different reason; the flag is the
/// same.)
/// - **It is not mute.** Every other tooltip on this window resolves an `OPTION_TOOLTIP_*` out of
/// 1.12's GlobalStrings, and a benilla row with no counterpart there has so far gone silent (the
/// nameplate pair). This one carries a description under a `BENILLA_` prefix — the reference's
/// namespace stays the reference's — because the dial's first reviewer said outright that they
/// could not tell what it did.
#[test]
fn the_render_scale_row_shows_a_percentage_and_defers_to_apply() {
let mut s = audio_harness();
s.set_cvar_host("renderScale", "1");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowRenderScaleLabel:GetText()"
)
.unwrap(),
"Render Scale"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowRenderScaleControlValue:GetText()"
)
.unwrap(),
"100%",
"off has to read as 100%, not as 1"
);
// The player's range is 50–200 %, narrower than the CVar's own clamp (0.25–4.0, which leaves
// room for the supersampling instrument). A row that offered the whole clamp would put 400 %
// in front of someone who only wanted their frame rate back.
assert!(s
.eval::<bool>(
"local lo, hi = BenillaOptionsFrameContainerBodyGraphicsRowRenderScaleControlSlider:GetMinMaxValues() return math.abs(lo - 0.5) < 0.0001 and math.abs(hi - 2.0) < 0.0001"
)
.unwrap());
// Not mute: the row resolves a description, unlike the other benilla-own rows.
assert!(s
.eval::<bool>("return BENILLA_TOOLTIP_RENDER_SCALE ~= nil")
.unwrap());
let _ = s.take_cvar_changes();
// The move stages and shows Apply; it does NOT rebuild the render target.
s.run("BenillaOptionsFrameContainerBodyGraphicsRowRenderScaleControlSlider:SetValue(0.75)")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowRenderScaleControlValue:GetText()"
)
.unwrap(),
"75%"
);
assert!(
s.take_cvar_changes().is_empty(),
"a deferred row must not write the CVar on the move"
);
assert!(s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
// Apply commits once.
s.run("BenillaOptionsFrameApplyButton:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("renderScale".to_string(), "0.75".to_string())]
);
assert!(!s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Pending edits are PANEL-wide like era's modified table: they survive a category switch
/// (the row redisplays the pending value on return, era's GetValue-returns-pending) and die
/// only when the window hides — the reopened window reads the committed truth.
#[test]
fn a_pending_ui_scale_survives_the_page_switch_and_dies_on_hide() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
s.run("BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.7)")
.unwrap();
let _ = s.take_cvar_changes();
// Off to Audio: the Apply button stays (the pending edit is not page-scoped)…
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
// …and back: the slider shows the PENDING value, not the committed one.
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"70%"
);
// Hide discards (the era confirm dialog is cut): the reopened window reads the truth.
s.run("HideUIPanel(BenillaOptionsFrame)").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"90%"
);
assert!(!s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(
s.take_cvar_changes().is_empty(),
"the whole pending lifecycle never wrote the CVar"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Terrain Distance slider (1513) is a LIVE row, unlike uiScale: a move snaps to the 1.12
/// panel grid — 177+n·60, ANCHORED AT THE MINIMUM, so 300 lands on 297, not the multiple-of-60
/// 300 — writes the CVar on the move as a clean short string, and raises no Apply button. The
/// write is what moves the far-clip wall and the residency window together.
#[test]
fn the_terrain_distance_slider_snaps_to_the_1_12_grid_and_writes_live() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
s.run("BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlSlider:SetValue(300)")
.unwrap();
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlSlider:GetValue() - 297) < 0.001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"297"
);
assert_eq!(
s.take_cvar_changes(),
vec![("farclip".to_string(), "297".to_string())]
);
assert!(
!s.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap(),
"a live row stages nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on the Graphics page: uiScale back to its registered default (0.9) and farclip to
/// its (350), the rows following, ONLY the moved values queuing — and a pending uiScale edit
/// dies with it (the default write supersedes what Apply would have committed).
#[test]
fn defaults_resets_the_graphics_page_to_registered_defaults() {
let mut s = audio_harness();
s.set_cvar_host("uiScale", "0.8");
s.set_cvar_host("farclip", "297");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
// Stage a pending edit too — Defaults must kill it, not commit it.
s.run("BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.7)")
.unwrap();
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
let changes = s.take_cvar_changes();
assert!(
changes.contains(&("uiScale".to_string(), "0.9".to_string())),
"{changes:?}"
);
assert!(
changes.contains(&("farclip".to_string(), "350".to_string())),
"{changes:?}"
);
assert_eq!(
changes.len(),
2,
"only the default writes queue — never the dead pending: {changes:?}"
);
// **357, not 350, and that is the reference's ladder rather than a rounding slip.** The row
// is the reference's own farclip slider — `minValue = OPTIONS_FARCLIP_MIN (177)`,
// `maxValue = OPTIONS_FARCLIP_MAX (777)`, `valueStep = (max − min)/10` — so its ten stops are
// 177, 237, 297, **357**, …, 777, and `SetValue` snaps onto them (2133). The registered
// default is 350 (1804: the reference's own `CVar::Register` value), which is not a stop, so
// seeding the slider from it lands on 357. 1.12 does exactly this and nobody can see it,
// because its option sliders print no number; ours prints one, so the ladder shows.
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowFarclipControlValue:GetText()"
)
.unwrap(),
"357"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"90%"
);
assert!(!s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── MONKEY (advanced graphics): the Advanced Graphics page ───────────────────────────────────
/// The name every row on the new page is derived from.
const ADVGFX: &str = "BenillaOptionsFrameContainerBodyAdvancedGraphics";
#[test]
fn water_quality_writes_numeric_tiers_with_localised_labels() {
for (locale, labels, lava_label) in [
("enUS", ["Classic", "Enhanced", "High"], "Lava Glow"),
("ruRU", ["Классическое", "Улучшенное", "Высокое"], "Свечение лавы"),
] {
let s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap();
assert_eq!(s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 17);
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality");
assert_eq!(s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[15]").unwrap(), "RowLavaGlow");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label);
assert!(s.eval::<bool>("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap());
let _ = s.take_cvar_changes();
for (tier, label) in labels.iter().enumerate() {
s.run(&format!("OptionsRow_Set({ADVGFX}RowWaterQuality, '{tier}') OptionsDropdown_ShowValue({ADVGFX}RowWaterQuality)")).unwrap();
assert_eq!(s.take_cvar_changes(), vec![("waterQuality".to_string(), tier.to_string())]);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), *label);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
}
#[test]
fn advanced_graphics_fire_spell_and_lava_sliders_read_and_write_the_full_range() {
let mut s = audio_harness();
s.set_cvar_host("fireLightGain", "3.25");
s.set_cvar_host("spellLightGain", "3.25");
s.set_cvar_host("lavaLightGain", "3.25");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
let _ = s.take_cvar_changes();
for (row, cvar) in [("RowFireLight", "fireLightGain"), ("RowSpellLights", "spellLightGain"), ("RowLavaGlow", "lavaLightGain")] {
assert_eq!(s.eval::<f32>(&format!(
"return {ADVGFX}{row}ControlSlider:GetValue()"
)).unwrap(), 3.25);
assert_eq!(s.eval::<String>(&format!(
"return {ADVGFX}{row}ControlValue:GetText()"
)).unwrap(), "325%");
for value in ["4", "2.75", "0", "0.05"] {
s.run(&format!("{ADVGFX}{row}ControlSlider:SetValue({value})")).unwrap();
assert!(s.take_cvar_changes().contains(&(cvar.to_string(), value.to_string())));
assert_eq!(s.cvar(cvar).as_deref(), Some(value));
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn advanced_graphics_selects_its_locale_at_load_including_yards() {
for (locale, title, distance) in [
("enUS", "Advanced Graphics", "80 yd"),
("ruRU", "Расширенная графика", "80 ярд."),
("deDE", "Advanced Graphics", "80 yd"),
] {
let s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end")).unwrap();
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame) \
BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
assert_eq!(s.eval::<String>(
"return BenillaOptionsFrameContainerTitle:GetText()"
).unwrap(), title);
assert_eq!(s.eval::<String>(&format!(
"return {ADVGFX}RowShadowDistanceControlValue:GetText()"
)).unwrap(), distance);
assert!(s.eval::<bool>(
"return BENILLA_TOOLTIP_NIGHT_DARKNESS == BENILLA_ADVGFX.tips.NIGHT_DARKNESS"
).unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
}
/// **The page reads the lighting CVars on select** — every control flavour on it at once, which
/// is the point of checking one page rather than one row: this is the first page in the window
/// carrying a dropdown, a checkbox and four sliders whose store is a benilla CVar with no
/// reference counterpart, and the first whose labels come out of a table rather than a literal.
///
/// The three rows that moved here off Graphics are checked BOTH ways: present here, and gone from
/// there. A row that was copied rather than moved would show up as two live controls over one
/// CVar, which nothing else in this file would catch.
#[test]
fn the_advanced_graphics_page_reads_the_lighting_cvars_on_select() {
let mut s = audio_harness();
s.set_cvar_host("shadowMapSize", "4096");
s.set_cvar_host("interiorShadows", "0");
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
assert!(s
.eval::<bool>(&format!("return {ADVGFX}:IsVisible()"))
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerTitle:GetText()")
.unwrap(),
"Advanced Graphics",
"the page title is the category row's label"
);
assert!(
s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"Defaults is live on a page with rows"
);
// The dropdowns: the ladder reads the registered default, the resolution reads the seed.
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
.unwrap(),
"High",
"a fresh registry is the High preset, member for member (cvars::tests)"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowShadowResolutionDropdownText:GetText()"))
.unwrap(),
"4096"
);
// The checkboxes read the table, not a restated default.
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowCharacterShadowsCheck:GetChecked()"))
.unwrap());
assert!(!s
.eval::<bool>(&format!("return {ADVGFX}RowInteriorShadowsCheck:GetChecked()"))
.unwrap());
// The sliders, each on its own registry clamp with its own readout grammar.
for (row, text) in [
("RowShadowDistance", "80 yd"),
("RowMoonShadows", "35%"),
("RowFireFlicker", "100%"),
("RowSpellLights", "100%"),
("RowLavaGlow", "100%"),
] {
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}{row}ControlValue:GetText()"))
.unwrap(),
text,
"{row}"
);
}
// The labels come out of the bilingual table (enUS here — benilla's `GetLocale` answers
// nothing else yet).
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowInteriorLightLabel:GetText()"))
.unwrap(),
"Dynamic Interior Lighting"
);
// MOVED, not copied: the Graphics page has no shadow rows left.
for row in ["RowCharacterShadows", "RowWorldShadows", "RowShadowDistance"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyGraphics{row} ~= nil"
))
.unwrap(),
"{row} is still seated on the Graphics page"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The Lighting Quality row writes ONE CVar** — and that is the whole of its contract with this
/// window. The preset's members are the host's to write (`cvars::LIGHTING_PRESETS`), so a page
/// that wrote them here would be a second copy of that table, silently drifting from the one the
/// console and the native panel use. What this pins is the boundary: one write, the engine's own
/// spelling, and nothing else queued.
#[test]
fn the_lighting_quality_row_writes_one_cvar_and_leaves_the_members_to_the_host() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
let _ = s.take_cvar_changes();
// The entry click's own body (`OptionsDropdownEntry_OnClick`), which is the row's Set
// followed by the capsule's redisplay — driven directly so the test does not depend on the
// shared dropdown kit's list geometry.
s.run(&format!(
"OptionsRow_Set({ADVGFX}RowLightingQuality, \"Low\") \
OptionsDropdown_ShowValue({ADVGFX}RowLightingQuality)"
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("lightingQuality".to_string(), "Low".to_string())],
"one write, and the value is the engine's own string — never the localised label"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowLightingQualityDropdownText:GetText()"))
.unwrap(),
"Low"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The two darkness dials read backwards, and that is the bug this row shape exists to avoid.**
/// Their store is a GAIN — `nightGain` 0.2..1.5, where 0.2 is the darkest — while the rows are
/// named for the darkness. Left is darker either way; without the `darkness` readout the NUMBER
/// beside the label would climb as the world got lighter, under a label that says "Darkness".
#[test]
fn the_darkness_sliders_read_out_so_that_left_is_darker() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
// The registered default, 0.45 of 0.2..1.5, is (1.5 − 0.45) / 1.3 = 81 % of the way dark.
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
.unwrap(),
"81%"
);
// The darkest gain reads 100 %, the brightest 0 %; the reference night is gain 1.0.
for (value, text) in [("0.2", "100%"), ("1.5", "0%"), ("1.0", "38%"), ("0.85", "50%")] {
s.run(&format!(
"{ADVGFX}RowNightDarknessControlSlider:SetValue({value})"
))
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowNightDarknessControlValue:GetText()"))
.unwrap(),
text,
"nightGain {value}"
);
}
// …and the STORE keeps the gain, unchanged — the readout is a readout.
assert!(
s.take_cvar_changes()
.contains(&("nightGain".to_string(), "0.85".to_string())),
"the slider writes the gain the engine reads, not the percentage it shows"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowInteriorDarknessControlValue:GetText()"))
.unwrap(),
"77%",
"interiorGain 0.5 of 0.2..1.5"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The page's dependencies**, in the reference's own shape: a master's switch greys what it
/// gates, and the CVars keep their values throughout so turning the master back on finds its
/// children where they were left.
#[test]
fn the_advanced_graphics_masters_grey_what_they_gate() {
let s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
// A greyed row wears the DISABLE font — 1.12's own signal, and the one `OptionsRow_SetEnabled`
// sets on every flavour of row whatever else it does to the control. Compared as an OBJECT,
// not a name: `GetFontObject` answers the object (`script::font`).
let live = |s: &UiScript, row: &str| -> bool {
s.eval::<bool>(&format!(
"return {ADVGFX}{row}Label:GetFontObject() == GameFontNormal"
))
.unwrap()
};
assert!(live(&s, "RowInteriorShadows"));
assert!(live(&s, "RowShadowResolution"));
// Dynamic Interior Lighting off greys the indoor torch row — the CVar's own documented
// dependency ("needs interiorLight").
s.run(&format!("{ADVGFX}RowInteriorLightCheck:Click()"))
.unwrap();
assert!(!live(&s, "RowInteriorShadows"));
// Both sun lanes off leave nothing for the cascade rows to size.
s.run(&format!("{ADVGFX}RowCharacterShadowsCheck:Click()"))
.unwrap();
s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
.unwrap();
assert!(!live(&s, "RowShadowResolution"));
assert!(!live(&s, "RowShadowDistance"));
// …and one of them back on wakes them, with their values untouched.
s.run(&format!("{ADVGFX}RowWorldShadowsCheck:Click()"))
.unwrap();
assert!(live(&s, "RowShadowResolution"));
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowShadowDistanceControlValue:GetText()"))
.unwrap(),
"80 yd"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The page follows a write it did not make.** This is the one page in the window whose CVars
/// move from outside it — the Lighting Quality row's members are written by the host a frame or
/// two after the click, and the console and the dev lighting panel reach the same rows while the
/// page is open. Without the poll the player would pick a preset and watch nothing below it move.
#[test]
fn the_page_repaints_when_a_host_write_moves_a_row_under_it() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()")
.unwrap();
// The page has to be effectively visible before its OnUpdate is in the sweep at all.
s.resolve();
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.unwrap());
// What the host does when a preset's members land: a mirror write, with no CVAR_UPDATE and no
// Lua write behind it.
s.set_cvar_host("exteriorShadows", "0");
s.set_cvar_host("moonShadowStrength", "0");
s.set_cvar_host("waterQuality", "2");
s.set_cvar_host("lavaLightGain", "0.5");
// Under the poll interval: nothing has happened yet.
s.tick(0.1);
assert!(s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.unwrap());
// Past it: one refresh, and the page tells the truth again.
s.tick(0.2);
assert!(!s
.eval::<bool>(&format!("return {ADVGFX}RowExteriorShadowsCheck:GetChecked()"))
.unwrap());
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowMoonShadowsControlValue:GetText()"))
.unwrap(),
"0%"
);
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), "High");
assert_eq!(s.eval::<String>(&format!("return {ADVGFX}RowLavaGlowControlValue:GetText()")).unwrap(), "50%");
// The refresh writes NOTHING back — it is a read of the table, not a round trip.
assert!(
s.take_cvar_changes().is_empty(),
"the poll must never write a CVar"
);
// …and a settled page costs nothing: the signature has not moved, so no further refresh runs.
s.tick(0.3);
assert!(s.take_cvar_changes().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Controls is the DEFAULT page and has rows since 0961: opening the window lands on it with
/// Defaults armed, and the rows read the table — Sticky Targeting INVERTED (checked when
/// `deselectOnClick` is "0", the 1.12 interface panel's own arm), the plain flags direct.
#[test]
fn the_controls_page_reads_flags_with_the_sticky_inversion() {
let mut s = audio_harness();
s.set_cvar_host("deselectOnClick", "0");
s.set_cvar_host("autoLootDefault", "1");
let s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyControls:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowStickyTargetCheck:GetChecked()"
)
.unwrap(),
"deselectOnClick '0' reads as Sticky Targeting CHECKED"
);
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowAutoLootCheck:GetChecked()"
)
.unwrap());
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowInvertMouseCheck:GetChecked()"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowStickyTargetLabel:GetText()"
)
.unwrap(),
"Sticky Targeting"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The Controls checkboxes write their flags on the INTERFACE panel's kit mapping (checked →
/// CheckBoxOn — OptionsFrame.lua's PlayClickSound, NOT the Audio page's inverted quirk), and
/// Sticky Targeting writes the CVar inverted both ways.
#[test]
fn the_controls_checkboxes_write_flags_with_the_interface_panel_kit() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on open must not write it back"
);
let _ = s.take_sounds();
// Invert Mouse on: flag "1", and the just-CHECKED box plays CheckBoxOn (normal mapping).
s.run("BenillaOptionsFrameContainerBodyControlsRowInvertMouseCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("mouseInvertPitch".to_string(), "1".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Sticky Targeting on: the write INVERTS — checking it writes deselectOnClick "0".
s.run("BenillaOptionsFrameContainerBodyControlsRowStickyTargetCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("deselectOnClick".to_string(), "0".to_string())]
);
// …and off again: back to "1", the just-UNchecked box on the CheckBoxOff kit.
s.run("BenillaOptionsFrameContainerBodyControlsRowStickyTargetCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("deselectOnClick".to_string(), "1".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOff".into())));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on the Controls page: the moved flags come back (deselectOnClick "1",
/// autoLootDefault "0"), the rows follow, and only the moved values queue.
#[test]
fn defaults_resets_the_controls_page_to_registered_defaults() {
let mut s = audio_harness();
s.set_cvar_host("deselectOnClick", "0");
s.set_cvar_host("autoLootDefault", "1");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
let changes = s.take_cvar_changes();
assert!(
changes.contains(&("deselectOnClick".to_string(), "1".to_string())),
"{changes:?}"
);
assert!(
changes.contains(&("autoLootDefault".to_string(), "0".to_string())),
"{changes:?}"
);
assert_eq!(changes.len(), 2, "only the moved values queue: {changes:?}");
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowStickyTargetCheck:GetChecked()"
)
.unwrap(),
"deselectOnClick back at '1' reads as Sticky Targeting unchecked"
);
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowAutoLootCheck:GetChecked()"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Drive the window a few frames so the scroll body's frame-late fit converges: rects resolve
/// after Lua runs, so `OptionsScroll_Fit` answers one frame behind. (The tab widths are not in
/// that set — 2028 put them on a one-shot `<OnShow>OptionsTab_FitWidth`.)
fn settle(s: &mut UiScript) {
for _ in 0..4 {
s.resolve();
s.tick(0.016);
}
s.resolve();
}
/// B217: a search that matches across categories chains five group heads and every matched row
/// into one column — ~150 units past the page area, which before the page scrolled drew straight
/// out through the dialog border onto the world. Now the body is the scroll child: it grows to the
/// content, the bar and its trough appear, and everything past the fold is CLIPPED to the page
/// rect until scrolled to.
#[test]
fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
// On NAMEPLATES, not the default Controls page: since 2180 seated the four camera toggles and
// the two camera-speed sliders, Controls is sixteen rows and overflows its area on its own —
// which is the scroll body doing its job, and no longer a control for "nothing overflows".
// Nameplates is six rows and fits.
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
settle(&mut s);
// Control first: a settled page fits, so there is no scroll and no bar at all — and the body
// sits exactly on the page rect, the seat every page's XML anchors were authored against.
assert_eq!(
s.eval::<f32>("return BenillaOptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap(),
0.0,
"the Nameplates page fits its area"
);
for f in [
"BenillaOptionsFrameContainerScrollBar",
"BenillaOptionsFrameContainerScrollBarTrough",
] {
assert!(
!s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
"{f} stays away while the page fits"
);
}
let page_h: f32 = s
.eval("return BenillaOptionsFrameContainerScroll:GetHeight()")
.unwrap();
assert!(
(s.eval::<f32>("return BenillaOptionsFrameContainerBody:GetHeight()")
.unwrap()
- page_h)
.abs()
< 0.5,
"the body is the page rect when nothing overflows"
);
// Now the broad search: every category surfaces, and the column outruns the page.
s.run("BenillaOptionsFrameSearchBox:SetText(\"e\")")
.unwrap();
settle(&mut s);
let range: f32 = s
.eval("return BenillaOptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap();
assert!(
range > 100.0,
"the results outrun the page, so there is something to scroll (range {range})"
);
for f in [
"BenillaOptionsFrameContainerScrollBar",
"BenillaOptionsFrameContainerScrollBarTrough",
] {
assert!(
s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
"{f} appears with the overflow"
);
}
// THE SYMPTOM: nothing under the page draws past its bottom edge any more. Every quad the
// page's own content emits carries the page rect as its clip (the engine's ScrollFrame
// mechanism), so the deepest DRAWN pixel is the page's own bottom — while the content's own
// rects still reach far below it, which is exactly the spill that used to be on screen.
let quads = s.extract();
let clip = quads
.iter()
.find_map(|q| q.clip)
.expect("the page clips its content");
assert!(
quads.iter().all(|q| q.clip.is_none_or(|c| c == clip)),
"one clip in play: the page area"
);
let deepest_rect = quads
.iter()
.filter(|q| q.clip.is_some())
.filter_map(|q| q.rect)
.map(|r| r.bottom)
.fold(f32::INFINITY, f32::min);
assert!(
deepest_rect < clip.bottom - 100.0,
"there is a real fold to make: the results reach {deepest_rect} against a page bottom of {}",
clip.bottom
);
let deepest_drawn = quads
.iter()
.filter_map(|q| Some((q.rect?, q.clip?)))
.map(|(r, c)| r.bottom.max(c.bottom))
.fold(f32::INFINITY, f32::min);
assert!(
deepest_drawn >= clip.bottom - 0.01,
"…and it is folded AT the page bottom, not spilling ({deepest_drawn} vs {})",
clip.bottom
);
// The last result is reachable: scrolling to the end brings it inside the page rect. (Widget
// coordinates here, not the extract's screen px — the window carries ERA_WINDOW_SCALE.)
let sf_bottom: f32 = s
.eval("return BenillaOptionsFrameContainerScroll:GetBottom()")
.unwrap();
let tail = |s: &UiScript| -> f32 { s.eval("return OptionsScroll_ContentBottom()").unwrap() };
assert!(
tail(&s) < sf_bottom,
"the tail starts below the fold ({} vs {sf_bottom})",
tail(&s)
);
s.run(&format!(
"BenillaOptionsFrameContainerScrollBar:SetValue({range})"
))
.unwrap();
settle(&mut s);
assert!(
tail(&s) >= sf_bottom - 0.5,
"scrolling to the end brings the tail into the page ({} vs {sf_bottom})",
tail(&s)
);
// Clearing the search puts the page — and the bar with its trough — back.
s.run("BenillaOptionsFrameSearchBox:SetText(\"\")").unwrap();
settle(&mut s);
assert_eq!(
s.eval::<f32>("return BenillaOptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap(),
0.0
);
for f in [
"BenillaOptionsFrameContainerScrollBar",
"BenillaOptionsFrameContainerScrollBarTrough",
] {
assert!(
!s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
"{f} goes away with the results"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bar's TROUGH — the recessed channel it rides in, the "background and border" a bare
/// arrows-and-knob bar was missing. It is the shared kit's template on 1.12's own
/// UI-Character-ScrollBar channel art over the ref's black backing, and it seats ITSELF on its bar
/// at the ref's hang: 31 wide against the bar's 16, 8 units left, and — the part B224 caught —
/// overhanging the bar by 21 above and 20 below, which is what drops each arrow button into the
/// 16-tall SOCKET the art carries for it. (The Keybindings page's own bar wears the same one —
/// keybindings_tests, where the harness has a real binding registry to overflow the list with.)
#[test]
fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameSearchBox:SetText(\"e\")")
.unwrap();
settle(&mut s);
let bar = "BenillaOptionsFrameContainerScrollBar";
let trough = "BenillaOptionsFrameContainerScrollBarTrough";
let g = |f: &str, m: &str| s.eval::<f32>(&format!("return {f}:{m}()")).unwrap();
assert!(
(g(trough, "GetWidth") - 31.0).abs() < 0.01,
"the trough is the 31-wide channel"
);
assert!(
(g(bar, "GetWidth") - 16.0).abs() < 0.01,
"against a 16-wide bar"
);
assert!(
(g(bar, "GetLeft") - g(trough, "GetLeft") - 8.0).abs() < 0.01,
"the trough hangs the ref's 8 units left of the bar, so the bar rides it centred"
);
// THE SOCKET LAW (BenillaScrollTrough_Seat): the trough overhangs the bar by 21/20 — not the
// arrow-to-arrow 16/16 that B224 reported, which sat both arrows 4 units OUT of their sockets,
// riding the caps with bare socket showing beside them. Ref: ReputationFrame's trough at the
// scroll frame's +5/-4 against a UIPanelScrollBarTemplate bar at -16/+16.
assert!(
(g(trough, "GetTop") - g(bar, "GetTop") - 21.0).abs() < 0.01,
"trough top 21 above the bar"
);
assert!(
(g(bar, "GetBottom") - g(trough, "GetBottom") - 20.0).abs() < 0.01,
"trough bottom 20 below the bar"
);
// Read the same law off the ARROWS, which is what the eye actually judges: 5 units of top cap
// above the up arrow, 4 of bottom cap below the down arrow, and the 16-tall sockets between.
let up = format!("{bar}ScrollUpButton");
let down = format!("{bar}ScrollDownButton");
assert!((g(&up, "GetHeight") - 16.0).abs() < 0.01, "16-tall arrows");
assert!((g(&down, "GetHeight") - 16.0).abs() < 0.01);
assert!(
(g(trough, "GetTop") - g(&up, "GetTop") - 5.0).abs() < 0.01,
"the cap shows 5 above the up arrow"
);
assert!(
(g(&down, "GetBottom") - g(trough, "GetBottom") - 4.0).abs() < 0.01,
"and 4 below the down arrow"
);
// …and on THIS page the whole channel lands on the page rect: the scroll frame is the page
// area, so the bar takes the 21/20 inset rather than the trough poking out through the header
// divider above and the container's bottom below.
let sf = "BenillaOptionsFrameContainerScroll";
assert!((g(trough, "GetTop") - g(sf, "GetTop")).abs() < 0.01);
assert!((g(trough, "GetBottom") - g(sf, "GetBottom")).abs() < 0.01);
// The art: three channel slices from the one 1.12 file — top cap, stretched run, bottom cap —
// each spanning the channel's full width, stacked with no gap and no overlap.
let mut slices: Vec<_> = s
.extract()
.into_iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Character-ScrollBar") => {
q.rect
}
_ => None,
})
.collect();
assert_eq!(slices.len(), 3, "top cap, stretched run, bottom cap");
slices.sort_by(|a, b| b.top.total_cmp(&a.top));
assert!(
slices
.windows(2)
.all(|w| (w[0].bottom - w[1].top).abs() < 0.01),
"the three stack flush into one channel: {slices:?}"
);
let width = slices[0].right - slices[0].left;
assert!(
slices
.iter()
.all(|r| (r.right - r.left - width).abs() < 0.01),
"…all at the channel's own width: {slices:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── B223 · the row tooltips (decision 1054) ─────────────────────────────────────────────────────
/// Hover the middle of a named row's LABEL half (left of the control column) — the surface the
/// reporter's cursor was on when the row lit and said nothing. Callers pin `ERA_WINDOW_SCALE = 1`
/// first (the wash test's own idiom): frame rects are in the frame's scale space, the pointer is
/// in the screen's, and at scale 1 the two are the same numbers.
fn hover_label(s: &mut UiScript, frame: &str) {
s.resolve();
let g = |s: &mut UiScript, verb: &str| -> f32 {
s.eval(&format!("return {frame}:{verb}()")).unwrap()
};
let (l, t, b) = (g(s, "GetLeft"), g(s, "GetTop"), g(s, "GetBottom"));
s.mouse_move(l + 60.0, (t + b) * 0.5);
}
/// Bring `frame` inside the page's scroll rect, if the page scrolls at all. A page longer than
/// its area clips everything past the fold (B217), so a row down there cannot be hovered where its
/// rect says it is — the cursor lands outside the scroll frame and hits nothing. Nine of the
/// Controls page's sixteen rows and the last of Combat's seventeen live there since 2180.
fn scroll_into_view(s: &mut UiScript, frame: &str) {
s.resolve();
s.run(&format!(
"local sf = BenillaOptionsFrameContainerScroll \
local range = sf:GetVerticalScrollRange() \
if range and range > 0 then \
local row = getglobal(\"{frame}\") \
local want = sf:GetVerticalScroll() \
if row:GetBottom() < sf:GetBottom() then \
want = want + (sf:GetBottom() - row:GetBottom()) + 2 \
elseif row:GetTop() > sf:GetTop() then \
want = want - (row:GetTop() - sf:GetTop()) - 2 \
end \
if want < 0 then want = 0 end \
if want > range then want = range end \
sf:SetVerticalScroll(want) \
end"
))
.unwrap();
s.resolve();
}
/// A hovered row raises its 1.12 description, on the era's seat, and drops it on leave — the row
/// itself, its checkbox, and (B223's report) the label the cursor actually crosses. The string
/// resolves by KEY at hover, so seeding it AFTER the window loaded still paints: that is the
/// property the 1.12 panel's own `getglobal("OPTION_TOOLTIP_"..key)` lookup buys.
#[test]
fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
let mut s = harness_on(audio_harness());
// The stock tooltip declares no size: it sizes from its lines through the font engine, as
// the client's does (1968) — a harness that reads its rect needs one; the fixed-width
// font is that engine here.
s.set_text_measurer(Box::new(super::FixedWidthFont(6.0)));
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Checking this will prevent the deselection.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let row = "BenillaOptionsFrameContainerBodyControlsRowStickyTarget";
hover_label(&mut s, row);
s.resolve();
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the label half raises the plate — the reported gap"
);
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Checking this will prevent the deselection."
);
assert_eq!(
s.eval::<i64>("return GameTooltip:NumLines()").unwrap(),
1,
"the description ALONE — the era's white name line is cut (1054)"
);
// The era seat: BOTTOMLEFT on the label region's TOPRIGHT, hung 10 back over the control
// column (DefaultTooltipMixin's ANCHOR_RIGHT / x -10).
let owned: bool = s
.eval(&format!("return GameTooltip:IsOwned({row}Tip)"))
.unwrap();
assert!(owned, "owned by the row's $parentTip region");
let (tip_right, tip_top): (f32, f32) = (
s.eval(&format!("return {row}Tip:GetRight()")).unwrap(),
s.eval(&format!("return {row}Tip:GetTop()")).unwrap(),
);
let (left, bottom): (f32, f32) = (
s.eval("return GameTooltip:GetLeft()").unwrap(),
s.eval("return GameTooltip:GetBottom()").unwrap(),
);
assert!(
(left - (tip_right - 10.0)).abs() < 0.01 && (bottom - tip_top).abs() < 0.01,
"plate at ({left}, {bottom}); the era seat is ({}, {tip_top})",
tip_right - 10.0
);
// Crossing onto the checkbox keeps it up (one seam lights the wash and raises the plate, so
// the row's OnLeave and the box's OnEnter cancel out inside the one move).
let (bl, br, bt, bb): (f32, f32, f32, f32) = (
s.eval(&format!("return {row}Check:GetLeft()")).unwrap(),
s.eval(&format!("return {row}Check:GetRight()")).unwrap(),
s.eval(&format!("return {row}Check:GetTop()")).unwrap(),
s.eval(&format!("return {row}Check:GetBottom()")).unwrap(),
);
s.mouse_move((bl + br) * 0.5, (bt + bb) * 0.5);
assert!(
s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the plate survives the crossing onto the control"
);
// Leaving the row puts it away.
s.mouse_move(5.0, 5.0);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"OnLeave drops the plate"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The row with no 1.12 string raises NOTHING — not a plate echoing the label under the cursor,
/// and not the LAST row's description left standing over it. Auto Loot is that row: 1.12 has no
/// Auto Loot setting, so no `OPTION_TOOLTIP_*` for it. The second leg is the live probe's find: a
/// hover driven without the outgoing row's OnLeave (1054) must still put the neighbour away.
#[test]
fn a_row_with_no_1_12_string_raises_no_plate() {
let mut s = harness_on(audio_harness());
s.run("OPTION_TOOLTIP_GAMEFIELD_DESELECT = \"Sticky's own description.\"")
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
hover_label(
&mut s,
"BenillaOptionsFrameContainerBodyControlsRowAutoLoot",
);
s.resolve();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"no 1.12 description, no plate"
);
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowAutoLootHover:IsVisible()"
)
.unwrap(),
"…but the row still lights: the wash is not gated on the string"
);
// Sticky's plate up, then straight into the mute row with no OnLeave in between.
s.run("OptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowStickyTarget, 1)")
.unwrap();
assert!(s.eval::<bool>("return GameTooltip:IsVisible()").unwrap());
s.run("OptionsRow_Hover(BenillaOptionsFrameContainerBodyControlsRowAutoLoot, 1)")
.unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"the mute row puts the neighbour's description away — it never describes THIS row"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The RUNTIME leg on the real client data (`ui_quest`'s pattern): every key the rows carry
/// resolves to a non-empty string in the shipped 1.12 `GlobalStrings.lua` — the guard against a
/// typo'd key degrading a description to silence — and the ONE row without a key is Auto Loot.
/// Pins the reporter's own row end to end (B223's screenshot text). Skips without client data.
#[test]
fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let src = chain
.read_file("Interface\\FrameXML\\GlobalStrings.lua")
.expect("GlobalStrings.lua in the chain");
let strings = UiScript::new().expect("VM");
strings
.run(&String::from_utf8_lossy(&src))
.expect("GlobalStrings runs clean");
// The mapping, read off the LIVE rows so a page added later can't slip the check.
let s = harness();
let listing: String = s
.eval(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
table.insert(out, page .. rkey .. \"=\" .. (row.tip or \"\")) \
end \
end \
return table.concat(out, \",\")",
)
.unwrap();
let mut untipped = vec![];
let mut checked = 0;
for entry in listing.split(',') {
let (row, key) = entry.split_once('=').expect("row=key");
if key.is_empty() {
untipped.push(row.to_string());
continue;
}
// The deliberate exceptions (1639 Render Scale, 1650 Display Mode, 1847 Enable Sound in
// Background, 2182 Brightness). None has a 1.12 counterpart whose `OPTION_TOOLTIP_*` could
// be resolved — Render Scale has no era row at all, Display Mode's era row was a CHECKBOX
// whose string says "Check to…", 1.12 has no background-sound setting at all (it mutes on
// its window-activation event and offers no way out), and `OPTION_TOOLTIP_GAMMA` spends
// its second sentence on "all 21 levels of gray bars to the right", which is the stock
// video window's own calibration art and not something this page has — and each is a row a
// player needs a description for. Each carries one under a `BENILLA_` prefix so the reference's
// namespace stays the reference's, which is exactly what this guard is here to protect.
// Everything the guard was built to catch — an invented or typo'd `OPTION_TOOLTIP_` key
// that silently resolves to nothing — is untouched: the pairing below is exact, so a
// `BENILLA_` key on the wrong row still fails.
const BENILLA_OWNED: &[(&str, &str)] = &[
("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
("BENILLA_TOOLTIP_DISPLAY_MODE", "GraphicsRowDisplayMode"),
(
"BENILLA_TOOLTIP_BACKGROUND_SOUND",
"AudioRowBackgroundSound",
),
("BENILLA_TOOLTIP_BRIGHTNESS", "GraphicsRowBrightness"),
// MONKEY (advanced graphics): the whole Advanced Graphics page. Every row on it is
// benilla's own mechanism — 1.12 has no realtime shadow, no fixture-lit interior and
// no invented light — so there is no `OPTION_TOOLTIP_*` any of them could resolve,
// and each carries a `BENILLA_` key for the same reason the four above it do. The
// first three are the rows that MOVED here off the Graphics page: same keys, same
// texts, new seat, which is why they read `AdvancedGraphicsRow…` now.
(
"BENILLA_TOOLTIP_CHARACTER_SHADOWS",
"AdvancedGraphicsRowCharacterShadows",
),
(
"BENILLA_TOOLTIP_WORLD_SHADOWS",
"AdvancedGraphicsRowWorldShadows",
),
(
"BENILLA_TOOLTIP_SHADOW_DISTANCE",
"AdvancedGraphicsRowShadowDistance",
),
(
"BENILLA_TOOLTIP_LIGHTING_QUALITY",
"AdvancedGraphicsRowLightingQuality",
),
(
"BENILLA_TOOLTIP_SHADOW_RESOLUTION",
"AdvancedGraphicsRowShadowResolution",
),
(
"BENILLA_TOOLTIP_MOON_SHADOWS",
"AdvancedGraphicsRowMoonShadows",
),
(
"BENILLA_TOOLTIP_INTERIOR_LIGHT",
"AdvancedGraphicsRowInteriorLight",
),
(
"BENILLA_TOOLTIP_INTERIOR_SHADOWS",
"AdvancedGraphicsRowInteriorShadows",
),
(
"BENILLA_TOOLTIP_EXTERIOR_SHADOWS",
"AdvancedGraphicsRowExteriorShadows",
),
("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"),
("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"),
(
"BENILLA_TOOLTIP_FIRE_FLICKER",
"AdvancedGraphicsRowFireFlicker",
),
(
"BENILLA_TOOLTIP_SPELL_LIGHTS",
"AdvancedGraphicsRowSpellLights",
),
(
"BENILLA_TOOLTIP_NIGHT_DARKNESS",
"AdvancedGraphicsRowNightDarkness",
),
(
"BENILLA_TOOLTIP_INTERIOR_DARKNESS",
"AdvancedGraphicsRowInteriorDarkness",
),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
assert_eq!(row, *want_row, "{row}: not this row's string");
let text: String = s.eval(&format!("return {key}")).unwrap();
assert!(!text.is_empty(), "{row}: {key} resolves to nothing");
checked += 1;
continue;
}
assert!(
key.starts_with("OPTION_TOOLTIP_"),
"{row}: {key} is not a 1.12 option-tooltip key"
);
let text: String = strings
.lua()
.globals()
.get::<String>(key)
.unwrap_or_default();
assert!(
!text.is_empty() && text != "PLACE_HOLDER",
"{row}: {key} resolves to nothing in the real GlobalStrings"
);
checked += 1;
}
// 27 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed Loot
// Information + Guild Member Alert are 1589's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's, its Display Mode
// 1627's (a dropdown since 1650) and its Multisampling 1632's; Camera Following Style
// 1493's; Terrain Distance 1513's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 6 (3 from
// 1136, Buff Durations 1139, the target-of-target pair 1576), the Action Bars page's 2 (the
// lock 1136, Always Show
// ActionBars 1500) and the Chat page's 1 (Remove Chat Hover Delay, 1589) + 7 API rows (the Interface page's Show Cloak / Show Helm, 1472 and
// Show Tutorials, 2077; the Action Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// per page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1) and Show When on its own (Always's OPTION_TOOLTIP_TARGETOFTARGET5);
// their other entries ride the same census as the selection moves.
// 59 of the 62 are 1.12's own; the other three are Render Scale (1639), Display Mode
// (1650) and Enable Sound in Background (1847), whose descriptions are benilla's and whose
// carve-out is above.
// The 28th CVar row is Block Trades (1764), on the Controls page — its key
// OPTION_TOOLTIP_BLOCK_TRADES is the reference's, so it is counted here like the rest; the
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto; the 30th is
// Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that
// page the reference never made settable.
// The 7th API row is Show Tutorials (2077) — the Interface page's Help box, whose store is
// `TutorialsEnabled()` / `ResetTutorials()` / `ClearTutorials()` rather than a CVar, and whose
// key OPTION_TOOLTIP_SHOW_TUTORIALS is 1.12's own.
// The 30th and 31st CVar rows are the two text filters (2077): Profanity Filter on the
// Interface page and Disable Spam Filter on the Chat page, both keys 1.12's own.
// 2180 added fourteen rows, every one of them tipped with the reference's own key: the
// four camera toggles and two camera-speed sliders plus Attack on assist, Auto Clear AFK and
// Auto Self Cast on Controls, Enhanced Tooltips on Interface, Player Guild Names on
// Nameplates, and the damage-number trio on Combat. 62 -> 76.
// …and Weather Intensity (2181), on the Graphics page, whose OPTION_TOOLTIP_WEATHER_DETAIL is
// 1.12's own and rides its own slider-9 row verbatim. 76 -> 77.
// …and Brightness (2182), the FOURTH row whose description is benilla's own rather than 1.12
// GlobalStrings — OPTION_TOOLTIP_GAMMA spends its second sentence on the stock window's
// 21-step grey ramp, which this page does not have (see the guard above). 77 -> 78.
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
// MONKEY (advanced graphics): +12, the rows the new page ADDS — Lighting Quality, Shadow
// Resolution, Moon Shadows, Dynamic Interior Lighting, Indoor and Outdoor Torch Shadows,
// Torch Shadow Softness, Fire Light Brightness, Fire Flicker, Spell Lights, Night Darkness
// and Interior Darkness. The three rows that MOVED onto it are already in the 81: a row
// changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93.
// Water Quality and Lava Glow add two more: 93 -> 95.
assert_eq!(checked, 95, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
"ControlsRowAutoLoot".to_string(),
"NameplatesRowEnemyPlates".to_string(),
"NameplatesRowFriendlyPlates".to_string(),
],
"the rows with no 1.12 option-tooltip string: Auto Loot (no 1.12 setting at all) and \
the two V-plate toggles (1.12 HAS the setting, as the V/Shift-V keybinding over a \
RegisterForSave'd global, but its options UI never carried a row for it — its own \
UIOptionsFrame comment says so — so there is no OPTION_TOOLTIP_ key to resolve)"
);
// The reporter's row, byte for byte off the MPQ chain.
assert_eq!(
strings
.lua()
.globals()
.get::<String>("OPTION_TOOLTIP_GAMEFIELD_DESELECT")
.unwrap(),
"Checking this will prevent the deselection of targets by clicking on the gamefield. \
Targets can only be cleared by pressing escape or clicking another target."
);
}
/// EVERY row on every page, all three control flavors: hovering it raises a plate exactly when
/// the row carries a 1.12 key, and no row's hover errors. The teeth are the per-template
/// `$parentTip` seat — a flavor missing that region would take `SetOwner(nil, …)` and print a red
/// line instead of a plate, which no key-mapping check would ever see.
#[test]
fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
let mut s = harness_on(audio_harness());
// Stand-in strings for every key the rows name (the real texts are the data test's job).
s.run(
"for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
if row.tip then setglobal(row.tip, \"described: \" .. rkey) end \
end \
end",
)
.unwrap();
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let mut raised = 0;
for page in [
"Controls",
"Audio",
"Graphics",
// MONKEY (advanced graphics): the tenth category, and the one with the most
// benilla-owned descriptions on it — every row here raises a `BENILLA_TOOLTIP_*`.
"AdvancedGraphics",
"Nameplates",
"Combat",
"Interface",
"ActionBars",
"Chat",
] {
s.run(&format!("BenillaOptionsFrameCategoryListRow{page}:Click()"))
.unwrap();
let rows: String = s
.eval(&format!(
"return table.concat(OPTIONS_PAGE_ROWS.{page}, \",\")"
))
.unwrap();
for rkey in rows.split(',') {
let row = format!("BenillaOptionsFrameContainerBody{page}{rkey}");
scroll_into_view(&mut s, &row);
hover_label(&mut s, &row);
s.resolve();
let tipped: bool = s.eval(&format!("return {row}.tip ~= nil")).unwrap();
let shown: bool = s.eval("return GameTooltip:IsVisible()").unwrap();
assert_eq!(
shown, tipped,
"{row}: plate shown {shown}, has key {tipped}"
);
if tipped {
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
format!("described: {rkey}"),
"{row}: the plate reads ITS OWN row's description"
);
assert!(
s.eval::<bool>(&format!("return GameTooltip:IsOwned({row}Tip)"))
.unwrap(),
"{row}: seated on its own $parentTip region"
);
raised += 1;
}
s.mouse_move(5.0, 5.0);
}
assert!(
s.errors().is_empty(),
"{page}: script errors: {:?}",
s.errors()
);
}
// 27 of the 28 CVar rows (the Chat page's two bubble switches are 1139's and its Detailed
// Loot Information + Guild Member Alert 1589's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's, Display Mode 1627's and Multisampling
// 1632's; Camera Following
// Style 1493's; Terrain Distance 1513's), plus the Combat page's 14 saved-variable rows (1134), the Interface
// page's 6 (1136, + Buff Durations 1139, + the target-of-target pair 1576), Action Bars' 2
// (the lock 1136, Always Show
// ActionBars 1500), the Chat page's 1 (Remove Chat Hover Delay, 1589) and 6 API rows (Show
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
// …plus the Graphics page's Render Scale (1639) and Display Mode (1650) and the Audio page's
// Enable Sound in Background (1847), the three rows whose descriptions are benilla's own
// rather than 1.12 GlobalStrings — see the guard above.
// …and Block Trades (1764), the Controls page's 28th CVar row, and Enable Error Speech
// (1815), the Audio page's fourth checkbox and 1.12's own.
// …and Show Tutorials (2077), the Interface page's seventh API row.
// …and the two text-filter rows (2077).
// …and 2180's fourteen: Attack on assist, Auto Clear AFK, Auto Self Cast, Follow Terrain,
// Head Bob, Auto-Follow Speed, Water Collision, Smart Pivot and Mouse Look Speed on Controls;
// Enhanced Tooltips on Interface; Player Guild Names on Nameplates; Show Target Damage,
// Periodic Damage and Pet Damage on Combat. Every one of them tipped with the reference's
// own key, which is what makes the count move by exactly the number of rows added.
// …and Weather Intensity (2181) and Brightness (2182), the Graphics page's fourth and fifth
// sliders.
// MONKEY (world shadows): +3 for Real Character Shadows, World Shadows and the Shadow
// Distance slider (all benilla-owned keys, carved out in BENILLA_OWNED above). 78 -> 81.
// MONKEY (advanced graphics): +12 for the rows the new page ADDS (the three it inherits were
// already counted, on their old page). 81 -> 93 — and the page is where this test's own
// teeth bite hardest, since it is the first one to seat a dropdown, a checkbox and a slider
// whose descriptions are all benilla's.
// Water Quality and Lava Glow add two more: 93 -> 95.
assert_eq!(raised, 95, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
/// saved-variable GLOBAL rather than a CVar, and so the first thing that can change any of the
/// stock globals 1128 ported. The page is 1.12's AdvancedOptionsCombatText box: it reads the
/// globals on select, a click writes the global (and *nothing* reaches the CVar table), and each
/// write runs the family's `applyFunc` — `CombatText_UpdateDisplayedMessages`, whose visible
/// effect is the per-type `show` flag and the scroll function.
#[test]
fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyCombat:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// Read: each box shows its global, at CombatText.xml's own file-scope value — the whole
// family at the reference's own defaults since 1804, master included (it was "1" from 0578).
for (row, checked) in [
("RowCombatText", false),
("RowAuras", true),
("RowAuraFade", false),
("RowDodgeParryMiss", false),
] {
assert_eq!(
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyCombat{row}Check:GetChecked() and true or false"
))
.unwrap(),
checked,
"{row} reads its global"
);
}
// The dropdown capsule reads COMBAT_TEXT_FLOAT_MODE = "1" as its named stop.
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyCombatRowFloatModeDropdownText:GetText()"
)
.unwrap(),
"Scroll Up"
);
// Write: the global moves, the CVar table is untouched, and the applyFunc lands.
//
// The master goes first, and it is a write under test in its own right — but it is also the
// gate: every other row on this page is greyed while `SHOW_COMBAT_TEXT` is "0" (the shipped
// state since 1804; the dependency rule is pinned next door in
// `the_combat_master_greys_the_family_and_combo_points_is_class_gated`), and a greyed control
// eats its click. So the two clicks below only mean anything with this one ahead of them.
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
"1",
"the master writes its own global"
);
s.run("BenillaOptionsFrameContainerBodyCombatRowDodgeParryMissCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_DODGE_PARRY_MISS")
.unwrap(),
"1"
);
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
assert_eq!(
s.eval::<i64>("return COMBAT_TEXT_TYPE_INFO[\"MISS\"].show or 0")
.unwrap(),
1,
"applyFunc ran: the message type is live now"
);
// The dropdown writes its global and applies too (the scroll function follows the mode).
s.run(
"BenillaOptionsFrameContainerBodyCombatRowFloatModeDropdownButton:Click() \
DropDownList1Button2:Click()",
)
.unwrap();
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_FLOAT_MODE").unwrap(),
"2"
);
assert!(
s.eval::<bool>("return COMBAT_TEXT_SCROLL_FUNCTION == CombatText_StandardScroll")
.unwrap(),
"mode 2 keeps the standard scroll with a downward step (CombatText.xml's own arm)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The master's dependency rule, 1.12's verbatim: `SHOW_COMBAT_TEXT` off greys **every** other
/// row on the page including the dropdown, and back on wakes them — except Combo Points, which
/// carries the reference's second gate and stays greyed for anyone who is not a rogue or druid.
///
/// Since 1804 the page **opens** in the greyed state, because the master ships at the reference's
/// "0" (0578's `"1"` was the pin). So the walk here is off → on → off, and the sub-rows' first
/// wake is the master's own click rather than the load: the same rule, entered from the other end.
#[test]
fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
let enabled = |s: &mut UiScript, row: &str, control: &str| -> bool {
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyCombat{row}{control}:IsEnabled() ~= 0"
))
.unwrap()
};
// The shipped state: the master is off, so the whole family is greyed on arrival — and the
// master itself is not, because it is the way in.
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "0");
assert!(!enabled(&mut s, "RowAuras", "Check"));
assert!(!enabled(&mut s, "RowFloatMode", "DropdownButton"));
assert!(enabled(&mut s, "RowCombatText", "Check"));
// On: the siblings wake. No player class in this VM, so Combo Points stays greyed while they
// are live — the two gates are independent.
s.run("BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "1");
assert!(enabled(&mut s, "RowAuras", "Check"));
assert!(!enabled(&mut s, "RowComboPoints", "Check"));
assert!(enabled(&mut s, "RowFloatMode", "DropdownButton"));
s.run("BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "0");
assert!(
!enabled(&mut s, "RowAuras", "Check"),
"the master off greys every sub-toggle"
);
assert!(
!enabled(&mut s, "RowFloatMode", "DropdownButton"),
"…and the scroll dropdown, so its list cannot even open"
);
assert!(
enabled(&mut s, "RowCombatText", "Check"),
"the master itself stays live — it is the way back"
);
assert!(
!enabled(&mut s, "RowComboPoints", "Check"),
"the class gate outlives the master's departure too"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on a saved-variable page restores each global to **the value its own XML assigned at
/// file scope** — captured at the row's OnLoad, which the load order guarantees runs before the
/// saved chunk (1128). The panel restates no defaults of its own, so it cannot drift from
/// CombatText.xml the way the reference's hand-copied `default` field can. Since 1804 that
/// assignment is the reference's own value for all fourteen, so this is now also the check that
/// Defaults lands a player back on a stock 1.12 combat-text family.
#[test]
fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
let s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
// Move FOUR of them off their shipped values, in both directions and both flavors. The master
// leads because the page ships with it off (1804) and a greyed row eats its click — so it is
// both the way in and the fourth value Defaults has to walk back.
s.run(
"BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click() \
BenillaOptionsFrameContainerBodyCombatRowAurasCheck:Click() \
BenillaOptionsFrameContainerBodyCombatRowReputationCheck:Click() \
BenillaOptionsFrameContainerBodyCombatRowFloatModeDropdownButton:Click() \
DropDownList1Button3:Click()",
)
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "1");
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_AURAS").unwrap(),
"0"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_REPUTATION")
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_FLOAT_MODE").unwrap(),
"3"
);
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
"0",
"the master walks back too — the reference's own value since 1804"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_AURAS").unwrap(),
"1"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_SHOW_REPUTATION")
.unwrap(),
"0"
);
assert_eq!(
s.eval::<String>("return COMBAT_TEXT_FLOAT_MODE").unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyCombatRowFloatModeDropdownText:GetText()"
)
.unwrap(),
"Scroll Up",
"the capsule follows the reset"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The point of the whole page: what it writes is **remembered**. A toggle lands in the
/// saved-variables text (1128's serializer) under its own name, and a fresh VM that executes that
/// text comes up with the player's choice rather than CombatText.xml's shipped default.
#[test]
fn what_the_combat_page_writes_survives_a_restart() {
let s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
// The master first: the page ships with it off (1804) and a greyed row eats its click, so
// without this the Honor Gained click below would be a silent no-op. Both writes are then
// held to the same standard — they have to come back after the restart.
s.run("BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
s.run("BenillaOptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
.unwrap();
let saved = s.saved_variables_text();
assert!(
saved.contains("COMBAT_TEXT_SHOW_HONOR_GAINED = \"0\""),
"the toggle is in the saved text:\n{saved}"
);
assert!(
saved.contains("SHOW_COMBAT_TEXT = \"1\""),
"and so is the master:\n{saved}"
);
// The restart: a fresh tree at its shipped defaults, then the saved chunk over the top.
let fresh = combat_harness();
assert_eq!(
fresh
.eval::<String>("return COMBAT_TEXT_SHOW_HONOR_GAINED")
.unwrap(),
"1"
);
assert_eq!(
fresh.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
"0"
);
fresh.run(&saved).unwrap();
assert_eq!(
fresh
.eval::<String>("return COMBAT_TEXT_SHOW_HONOR_GAINED")
.unwrap(),
"0",
"the saved value wins over the file-scope default"
);
assert_eq!(
fresh.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
"1",
"…and so does the master the player turned on"
);
// And the page paints the restored value the next time it is opened.
fresh.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
fresh
.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
assert!(!fresh
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyCombatRowHonorGainedCheck:GetChecked() and true or false"
)
.unwrap());
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// The **Action Bars page**'s lock row (decision 1136) — the one global on that group whose store
/// is a uvar. It is also the page whose setting had to be BUILT first: 1134 §3 listed
/// `LOCK_ACTIONBAR` as "not defined, and no guard exists". The five switches above it are 1500's
/// and have their own test below.
///
/// The end-to-end teeth are the last block: the row's write reaches the shipped bar's own drag
/// guard in the same VM. `action_bar_tests`/`pet_bar_tests` own the guard's full behaviour
/// (including the shift-click that deliberately still works); this owns the wire between them.
#[test]
fn the_action_bars_page_locks_the_real_bar() {
let mut s = actionbars_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowActionBars:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyActionBars:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyActionBarsRowLockActionBarCheck:GetChecked() \
and true or false"
)
.unwrap(),
"unchecked: ActionBar.xml ships the bar unlocked"
);
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyActionBarsRowLockActionBarCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return LOCK_ACTIONBAR").unwrap(), "1");
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
// The real bar, in this same VM, now refuses the drag — the row and the guard are wired.
s.set_action(
1,
Some(benilla_ui::script::ActionSlot {
texture: Some("Interface\\Icons\\Spell_A".into()),
kind: 0x00,
action: 111,
count: 0,
consumable: false,
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnDragStart\")()")
.unwrap();
assert!(
s.cursor_payload().is_none(),
"the page's write reached the bar's guard"
);
// Defaults walks it back to ActionBar.xml's own assignment, and the bar drags again.
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return LOCK_ACTIONBAR").unwrap(), "0");
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnDragStart\")()")
.unwrap();
assert!(s.cursor_payload().is_some(), "unlocked again");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Interface page** (decision 1136) — the second store's second page, and the three globals
/// 1134 §3 named as ready: already defined, already consumed, needing only a row. Each box reads
/// its definer's own file-scope assignment, and a click writes the global and touches nothing else.
///
/// None of the three carries an `applyFunc`, and that is the property being asserted at the end:
/// the consumer reads the global as it acts, so the write alone changes behaviour — here, the
/// questgiver's instant-text arm, live on the very next show.
#[test]
fn the_interface_page_writes_the_three_stock_globals() {
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyInterface:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// Read: each box shows its definer's own file-scope value. Two ship on — QuestLogFrame.xml's
// "1" (the reference's advertised default, as a STRING — see that file on 1.12's own
// number/string break) and GameTooltip.xml's "1" — and Instant Quest Text ships OFF, which is
// QuestFrame.xml's `QUEST_FADING_DISABLE = "0"`: the reference's value, and ours since 1804
// (it was pinned "1" by direction on 2026-07-17).
for (row, checked) in [
("RowInstantQuestText", false),
("RowAutoQuestWatch", true),
("RowNewbieTips", true),
] {
assert_eq!(
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyInterface{row}Check:GetChecked() and true or false"
))
.unwrap(),
checked,
"{row} reads its global"
);
}
// Write: the global moves, and the CVar table is never reached.
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyInterfaceRowNewbieTipsCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "0");
s.run("BenillaOptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "0");
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
// No applyFunc, because the write IS the apply: the questgiver's fade arm reads the global as
// the panel shows, so turning instant text ON makes the very next show land its text whole.
s.run("BenillaOptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
"1"
);
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowInstantQuestText.applyFunc == nil"
)
.unwrap(),
"these three need no apply hook"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **target-of-target pair** (decision 1576) — the Interface page's first dependent rows and
/// the window's first `uvar` DROPDOWN. Three things here belong to no other row:
///
/// * the picker is dead while the switch is off, which is 1.12's own rule for exactly this pair
/// (`OptionsFrame_DisableDropDown`, UIOptionsFrame.lua l.694-700);
/// * both rows carry the same `applyFunc`, and unlike the three stock globals above they NEED one
/// — the frame's visibility is decided once and kept, and the saved chunk lands after the file
/// that decided it, so without the re-run a saved "1" would leave the frame hidden;
/// * the picker's five values are the reference's own strings, in its own order.
#[test]
fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
// Ships off, at "always" — 1.12's own two defaults, read off UnitFrames.xml's file scope.
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the switch ships off"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownText:GetText()"
)
.unwrap(),
"Always"
);
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton \
:IsEnabled() ~= 0"
)
.unwrap(),
"the picker is dead while the switch is off"
);
// The switch: the global moves, the CVar table is never reached, and the picker wakes.
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return SHOW_TARGET_OF_TARGET").unwrap(),
"1"
);
assert!(
s.take_cvar_changes().is_empty(),
"a uvar row must not touch the CVar table"
);
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton \
:IsEnabled() ~= 0"
)
.unwrap(),
"and the picker wakes with it"
);
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTarget.applyFunc \
== \"TargetofTarget_Update\" \
and BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetMode.applyFunc \
== \"TargetofTarget_Update\""
)
.unwrap(),
"both rows re-decide the frame when they are written"
);
// The picker: five entries in the reference's order, the stored value checked, and a pick
// writes the reference's own value string.
s.run("BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownButton:Click()")
.unwrap();
assert_eq!(
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
5.0
);
assert!(
s.eval::<bool>("return DropDownList1Button5Check:IsVisible()")
.unwrap(),
"Always is the one checked"
);
s.run("DropDownList1Button3:Click()").unwrap();
assert_eq!(
s.eval::<String>("return SHOW_TARGET_OF_TARGET_STATE")
.unwrap(),
"3",
"Solo is the reference's third value"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetModeDropdownText:GetText()"
)
.unwrap(),
"Solo"
);
assert!(
s.take_cvar_changes().is_empty(),
"still nothing in the CVar table"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **equipment-display rows** — Show Cloak and Show Helm (decision 1472, B123). They are the
/// window's third kind of row and the only one with no store at all: the preference is a bit of the
/// character's own server-side `PLAYER_FLAGS`, so the row reads an engine getter and writes an
/// engine setter, 1.12's own `func`/`setFunc` pair for exactly these two entries.
///
/// Three things are asserted that no other row shape can be: the read comes from the API rather
/// than a saved value (a page revisit re-asks), the write leaves both the CVar table and the global
/// namespace untouched and produces a **wire** intent instead, and the getter follows the click
/// immediately — before the server's descriptor answers — because the wire verb is a blind flip and
/// a second click inside that round trip would otherwise compute the wrong direction.
#[test]
fn the_equipment_display_rows_read_and_write_through_the_api_not_a_store() {
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
// Both ship shown — the model's own default, and the reference's hand-written one.
for row in ["RowShowHelm", "RowShowCloak"] {
assert!(
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyInterface{row}Check:GetChecked() and true or false"
))
.unwrap(),
"{row} reads the API"
);
}
let _ = s.take_cvar_changes();
let _ = s.take_worn_display_toggles();
s.run("BenillaOptionsFrameContainerBodyInterfaceRowShowHelmCheck:Click()")
.unwrap();
assert_eq!(
s.take_worn_display_toggles(),
vec![WornDisplay::Helm],
"the click is a CMSG_TOGGLE_HELM intent, not a stored value"
);
assert!(
s.take_cvar_changes().is_empty(),
"an API row must not touch the CVar table"
);
assert!(
!s.eval::<bool>("return ShowingHelm() and true or false")
.unwrap(),
"the getter follows the click at once — the server has not answered yet"
);
assert!(
s.eval::<bool>("return ShowingCloak() and true or false")
.unwrap(),
"and the other slot is a different bit"
);
// The read is a re-ask, not a remembered string: leave the page and come back.
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowShowHelmCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the revisit re-asks the getter"
);
// The wire is the truth: a descriptor edge that disagrees wins over the optimistic belief.
s.set_worn_display(true, true);
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowShowHelmCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the server said the helm is shown, so the row says so"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Show Tutorials** (decision 2077) — the fourth `func` row, and the one 1.12's own table hides:
/// its `UIOptionsFrameCheckButtons` entry is a bare `{ index = 28 }`, so a reading of the table
/// alone (decision 1140's census) files it as unbound. The store is the three special-case arms —
/// `TutorialsEnabled()` reads it, `ResetTutorials()`/`ClearTutorials()` write it — which is what
/// this asserts: the row reads the acknowledged bank, ticking it OFF clears, ticking it back ON
/// resets, and the reference's `~=` guard keeps a no-op click from re-arming every dismissed
/// tutorial.
#[test]
fn show_tutorials_reads_the_bank_and_writes_through_clear_and_reset() {
let mut s = interface_harness();
// A bank with unacknowledged bits: tutorials are enabled.
s.set_tutorial_bank(Some(vec![0x00; 32]));
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the row reads TutorialsEnabled(), not a stored value"
);
let _ = s.take_cvar_changes();
let _ = s.take_tutorial_clears();
let _ = s.take_tutorial_resets();
// Ticking it off is `ClearTutorials()` — every bit acknowledged.
s.run("BenillaOptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:Click()")
.unwrap();
assert_eq!(s.take_tutorial_clears(), 1, "off clears the bank");
assert_eq!(s.take_tutorial_resets(), 0);
assert!(
s.take_cvar_changes().is_empty(),
"there is no tutorial CVar in 1.12 — the row must not invent one"
);
// The app answers by pushing the cleared bank back; the row now reads off.
s.set_tutorial_bank(Some(vec![0xFF; 32]));
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:GetChecked() \
and true or false"
)
.unwrap(),
"the revisit re-asks the getter"
);
// Ticking it back on is `ResetTutorials()`.
s.run("BenillaOptionsFrameContainerBodyInterfaceRowShowTutorialsCheck:Click()")
.unwrap();
assert_eq!(s.take_tutorial_resets(), 1, "on resets the bank");
assert_eq!(s.take_tutorial_clears(), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The reference's `~=` guard, which is the whole reason its Save arm is not a bare pair of calls:
/// setting the row to the value it already holds must send NOTHING. Without it, a Defaults click on
/// a page where tutorials are already on would `ResetTutorials()` and re-arm every popup the player
/// has dismissed — an option that silently undoes hours of play.
#[test]
fn a_no_op_write_does_not_re_arm_the_tutorials() {
let mut s = interface_harness();
s.set_tutorial_bank(Some(vec![0x00; 32])); // enabled
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
let _ = s.take_tutorial_clears();
let _ = s.take_tutorial_resets();
// Defaults writes "1" over a row that already reads "1".
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
(s.take_tutorial_resets(), s.take_tutorial_clears()),
(0, 0),
"the guard held: no bank write for a value that had not moved"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults on an API row: both boxes go back to shown, and the flip is sent **only** for the one
/// that was actually off — the setter is a *set* over a wire verb that is a blind *toggle*, so a
/// no-op default must not queue a packet that would turn the preference on its head.
#[test]
fn defaults_sends_a_flip_only_for_the_row_that_moved() {
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
s.run("BenillaOptionsFrameContainerBodyInterfaceRowShowCloakCheck:Click()")
.unwrap();
let _ = s.take_worn_display_toggles();
assert!(!s
.eval::<bool>("return ShowingCloak() and true or false")
.unwrap());
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
s.take_worn_display_toggles(),
vec![WornDisplay::Cloak],
"only the cloak had moved"
);
assert!(s
.eval::<bool>("return ShowingCloak() and true or false")
.unwrap());
assert!(s
.eval::<bool>("return ShowingHelm() and true or false")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults here restores the value **each global's own definer file assigns**, which is the
/// visible payoff of capturing the default at OnLoad instead of restating it (1134 §1): the page
/// holds no second copy that could disagree with QuestFrame.xml or GameTooltip.xml.
///
/// This test was `defaults_on_the_interface_page_restores_our_pin_not_the_references` until 1804,
/// and its premise is now inverted. `QUEST_FADING_DISABLE` shipped `"1"` by direction
/// (2026-07-17) where 1.12's own table hand-writes `default = "0"`, so Defaults used to walk the
/// page back to a value the reference did not have. It walks back to `"0"` now — not because the
/// mechanism changed, but because the assignment it reads **is** the reference's. That is the
/// property worth holding: the capture follows the definer wherever the definer stands.
#[test]
fn defaults_on_the_interface_page_restores_the_definers_own_assignment() {
let s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
s.run(
"BenillaOptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:Click() \
BenillaOptionsFrameContainerBodyInterfaceRowNewbieTipsCheck:Click()",
)
.unwrap();
assert_eq!(
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
"1"
);
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "0");
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
"0",
"back to QuestFrame.xml's own assignment, which is the reference's \"0\""
);
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "1");
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:GetChecked() \
and true or false"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The point of the page: what it writes is remembered. The toggle lands in the saved-variables
/// text under the **reference's** global name — `AUTO_QUEST_WATCH`, which is why 1136 renamed our
/// `BENILLA_AUTO_QUEST_WATCH` onto it — and a fresh tree replaying that text comes up on the
/// player's choice instead of QuestLogFrame.xml's shipped one.
#[test]
fn what_the_interface_page_writes_survives_a_restart() {
let s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
s.run("BenillaOptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
.unwrap();
let saved = s.saved_variables_text();
assert!(
saved.contains("AUTO_QUEST_WATCH = \"0\""),
"the toggle is in the saved text under the reference's name:\n{saved}"
);
let fresh = interface_harness();
assert_eq!(
fresh.eval::<String>("return AUTO_QUEST_WATCH").unwrap(),
"1"
);
fresh.run(&saved).unwrap();
assert_eq!(
fresh.eval::<String>("return AUTO_QUEST_WATCH").unwrap(),
"0",
"the saved value wins over the file-scope default"
);
fresh.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
fresh
.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(!fresh
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:GetChecked() \
and true or false"
)
.unwrap());
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// **A saved value that has a SIDE EFFECT has to be applied when the chunk lands.** The whole UI's
/// XML runs before `benilla-config/saved-variables.lua` executes over it (1128), so every file-scope
/// consumer of a global ran against the *shipped* default: `CombatText_OnLoad` decides its six
/// event registrations from `SHOW_COMBAT_TEXT` once, at load, and nothing re-runs when the saved
/// value replaces it. The player's choice came back undone at every restart. 1.12 closes this with
/// a hand-written ladder in `UIOptionsFrame`'s `VARIABLES_LOADED` arm ("Option specific function
/// calls", UIOptionsFrame.lua l.204-220); we hold each side effect on its own row already, so the
/// window runs them there.
///
/// **The saved value here is `"1"`, and the direction is the whole point.** Until 1804 the master
/// shipped `"1"`, so the bug was a saved `"0"` coming back ON and the test drove the switch down.
/// The master ships at the reference's `"0"` now, so driving it down would assert nothing — the
/// file-scope default already leaves the six events unregistered. The load-bearing case is the
/// other one: the registrations were never armed at all, and only the walk can arm them.
#[test]
fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
let mut s = combat_harness();
// What the saved chunk does, verbatim: assign over the file-scope default, then the event.
s.run("SHOW_COMBAT_TEXT = \"1\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
// The walk loaded the addon (1964); a frame passes before any message can arrive, and the
// stock message placement reads the strings' screen positions, which that frame lays out.
s.resolve();
s.fire_event(
"COMBAT_TEXT_UPDATE",
vec![
benilla_ui::script::ScriptValue::Str("DAMAGE".into()),
benilla_ui::script::ScriptValue::Str("17".into()),
],
);
assert!(
s.eval::<bool>("return getn(COMBAT_TEXT_TO_ANIMATE) == 1")
.unwrap(),
"the saved-on master is applied at load: the walk armed the registrations the XML did not"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Buff Durations** row (decision 1139) — the first setting in this window whose value has a
/// consequence nothing re-derives on its own, and so the first to carry an `applyFunc`. The timer
/// text needs no hook (the bar re-decides it every frame), but the ROW PITCH is stated once: with
/// timers the buff bar's three rows sit 45px apart, without them 35px — the reference's own two
/// geometries (`BuffButtons_UpdatePositions`). The click has to move it, and so does the saved
/// value landing at load, which is what the VARIABLES_LOADED walk is for.
///
/// The walk runs OFF → ON, because the bar ships without timers: `SHOW_BUFF_DURATIONS` is the
/// reference's `"0"` since 1804. It shipped `"1"` from 0255, which put the durations-shown
/// geometry in on the director's call, through 1139, which turned it into a setting without
/// re-weighing what it ships as. Nothing about the two geometries or the apply hook changed —
/// only which of them a fresh install opens on.
#[test]
fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
let gap = |s: &mut UiScript| -> f64 {
s.resolve();
s.eval::<f64>("return BuffButton0:GetBottom() - BuffButton8:GetTop()")
.unwrap()
};
let mut s = interface_harness();
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "0");
assert!(
(gap(&mut s) - 5.0).abs() < 1e-3,
"the shipped default is the durations-HIDDEN geometry: {}",
gap(&mut s)
);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:GetChecked() \
and true or false"
)
.unwrap());
s.run("BenillaOptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "1");
assert!(
(gap(&mut s) - 15.0).abs() < 1e-3,
"the click opened the timer gutter: {}",
gap(&mut s)
);
// Restart: the fresh tree comes up on the shipped geometry, the chunk replaces the value, and
// VARIABLES_LOADED is what puts the bar where the value says.
let saved = s.saved_variables_text();
assert!(
saved.contains("SHOW_BUFF_DURATIONS = \"1\""),
"the switch is in the saved text:\n{saved}"
);
let mut fresh = interface_harness();
fresh.run(&saved).unwrap();
assert!(
(gap(&mut fresh) - 5.0).abs() < 1e-3,
"the chunk moved the variable, not the bar"
);
fresh.fire_event("VARIABLES_LOADED", vec![]);
assert!(
(gap(&mut fresh) - 15.0).abs() < 1e-3,
"the apply walk re-derives the geometry from the saved value: {}",
gap(&mut fresh)
);
assert!(
fresh.errors().is_empty(),
"script errors: {:?}",
fresh.errors()
);
}
/// **Every category in this window now leads somewhere** (decision 1139). Chat — which was called
/// `Social` until 1589 — was the last one
/// that opened onto an empty page, and the arc that started at 1134 — rows over the second store —
/// closes here: Controls, Interface, Action Bars, Combat, Chat, Nameplates, Graphics and Audio
/// all carry rows, and Keybindings runs its own machinery. A category added without a page fails
/// this, which is the point: the honest tree (0950) is now a property the test holds, not a
/// promise the reader has to check.
///
/// The Defaults guard is keyed on ROWS, not on the category being real, so it is pinned directly
/// rather than through a stand-in page — there is no longer one to borrow.
#[test]
fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
let s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
let keys: Vec<String> = s
.eval::<String>("return table.concat(OPTIONS_CATEGORY_KEYS, \",\")")
.unwrap()
.split(',')
.map(str::to_string)
.collect();
// MONKEY (advanced graphics): ten, not nine — the era's own tree has no Advanced Graphics
// page, because the era client has no dynamic light and shadow system to put on one. It is
// benilla's own category, seated under Graphics in the System group, and it is held to the
// same bar as the era's nine: it opens onto rows, and Defaults is live on it.
assert_eq!(keys.len(), 10, "the nine 1.15.9 categories + ours: {keys:?}");
for key in &keys {
let has_rows = s
.eval::<bool>(&format!(
"return OPTIONS_PAGE_ROWS[\"{key}\"] ~= nil and \
getn(OPTIONS_PAGE_ROWS[\"{key}\"]) > 0"
))
.unwrap();
assert!(
has_rows || key == "Keybindings",
"{key} opens onto nothing — every category leads somewhere since 1139"
);
s.run(&format!("BenillaOptionsFrameCategoryListRow{key}:Click()"))
.unwrap();
assert!(
s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"{key}: Defaults is live on a page that has something to reset"
);
}
// And the guard itself: a key with no rows behind it leaves Defaults asleep.
s.run("BenillaOptionsFrame_SelectCategory(\"NotACategory\")")
.unwrap();
assert!(
!s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"Defaults is dead when the selected page has no rows"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Chat page**'s two bubble switches (decision 1139; the page was `Social` until 1589 grew
/// it into 1.12's own `CHAT_LABEL` box) — and the last category in
/// this window to stop opening onto nothing. Both are CVar rows: `chat_bubble.rs` transcribed the
/// client's `ChatBubbles`/`ChatBubblesParty` spawn gate faithfully in 0598 and then froze it at a
/// pair of `const bool`, so this is the action-bar lock's shape again — the knob had to become
/// real before the row could mean anything. The page reads the registered table on select, a
/// click queues the flag the host drains onto `BubbleConfig`, and the two are independent (the
/// client gates party lines on their own CVar, which is why party bubbles survive turning
/// say/yell bubbles off).
/// **Disable Spam Filter** (decision 2077) — the reference's own *inverted* row, and the one shape
/// on this page that would read backwards if it were wired like its neighbours.
///
/// `spamFilter` registers `"1"` (the filter ON), and the label says *Disable*: 1.12 checks the box
/// when `GetCVar == "0"` (`UIOptionsFrame.lua` l.253) and flips the value before writing it
/// (l.337). So a fresh client shows the box **unchecked**, and ticking it turns the filter off.
#[test]
fn the_disable_spam_filter_row_is_inverted() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowChat:Click()")
.unwrap();
assert_eq!(
s.cvar("spamFilter").as_deref(),
Some("1"),
"the registered default is the filter ON"
);
assert!(
!s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyChatRowSpamFilterCheck:GetChecked() and true or false"
)
.unwrap(),
"a filter that is ON shows *Disable Spam Filter* unchecked"
);
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyChatRowSpamFilterCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("spamFilter".to_string(), "0".to_string())],
"ticking *Disable* writes the CVar OFF"
);
assert_eq!(s.cvar("spamFilter").as_deref(), Some("0"));
// And back: the row round-trips rather than latching.
s.run("BenillaOptionsFrameContainerBodyChatRowSpamFilterCheck:Click()")
.unwrap();
assert_eq!(s.cvar("spamFilter").as_deref(), Some("1"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Profanity Filter** (decision 2077) — an ordinary CVar row, asserted because its *seat* is the
/// surprising part: 1.12 files it under Basic Options' **Display** box (CheckButton5 anchors under
/// CheckButton66), not with the chat settings its name suggests, so it closes the Interface page's
/// column-C chain rather than joining the Chat page.
#[test]
fn the_profanity_filter_row_sits_on_the_interface_page_and_writes_its_cvar() {
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowProfanityFilterCheck:GetChecked() \
and true or false"
)
.unwrap(),
"registered \"1\" — a stock client boots with profanity masking on"
);
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyInterfaceRowProfanityFilterCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("profanityFilter".to_string(), "0".to_string())],
"not inverted — the label and the CVar agree"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
#[test]
fn the_chat_page_toggles_the_chat_bubble_cvars() {
// No host override: since 1804 the registered pair IS bubbles-on / party-off, so the page's
// read below is of the shipped table rather than of a value this test planted.
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowChat:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyChat:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap());
// Read from the table, not from a restated default: bubbles on, party bubbles off.
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
.unwrap());
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()"
)
.unwrap());
let _ = s.take_sounds();
s.run("BenillaOptionsFrameContainerBodyChatRowPartyChatBubblesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("ChatBubblesParty".to_string(), "1".to_string())]
);
assert!(s
.take_sounds()
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOn".into())));
// Turning say/yell bubbles off writes only its own switch — party keeps the value above.
s.run("BenillaOptionsFrameContainerBodyChatRowChatBubblesCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("ChatBubbles".to_string(), "0".to_string())]
);
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()"
)
.unwrap());
// Defaults walks the page back to the registered pair, which is the binary's own since 1804:
// `ChatBubbles` "1", `ChatBubblesParty` "0" (the party half was the director's /p ask, 0598).
// The party row was clicked ON above, so this is a real walk-back, not a no-op.
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
.unwrap());
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Chat page**'s two new rows (decision 1589 — B246, "no chat section in options"), the two
/// that make it 1.12's `CHAT_LABEL` box rather than a two-row bubble page.
///
/// They are deliberately over *different stores*, and that is what this pins: **Remove Chat Hover
/// Delay** is a saved-variable global with an `applyFunc` (its consumer is a pair of constants
/// nothing re-reads per frame, so the write alone would change nothing), and **Detailed Loot
/// Information** is a CVar with none (the loot-roll composer reads it as each line is built —
/// 1136's rule for when a row needs one, seen from both sides on one page).
#[test]
fn the_chat_page_writes_the_hover_delay_global_and_the_loot_spam_cvar() {
let mut s = chat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowChat:Click()")
.unwrap();
let _ = s.take_sounds();
let _ = s.take_cvar_changes();
// Read: the shipped values, off the two files that declare them — not restated here.
assert_eq!(
s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(),
"0",
"ChatFrame.xml's own file-scope value, and the reference's declared default"
);
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyChatRowRemoveChatDelayCheck:GetChecked()"
)
.unwrap());
assert!(
s.eval::<bool>("return BenillaOptionsFrameContainerBodyChatRowLootSpamCheck:GetChecked()")
.unwrap(),
"showLootSpam is registered \"1\" — the binary's own default"
);
// The hover-delay row: the write is the global, and the applyFunc is what makes it mean
// something. Both fade constants collapse to zero — the reference's SetChatMouseOverDelay.
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.2, 0.15)
);
s.run("BenillaOptionsFrameContainerBodyChatRowRemoveChatDelayCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(), "1");
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.0, 0.0),
"the box appears the instant the cursor crosses it"
);
assert!(
s.take_cvar_changes().is_empty(),
"a saved-variable row reaches the CVar table not at all"
);
// …and back, which is the half a one-way applyFunc would break.
s.run("BenillaOptionsFrameContainerBodyChatRowRemoveChatDelayCheck:Click()")
.unwrap();
assert_eq!(s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(), "0");
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.2, 0.15)
);
// The loot-spam row: a plain CVar write, no applyFunc, nothing else on the page moved.
s.run("BenillaOptionsFrameContainerBodyChatRowLootSpamCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("showLootSpam".to_string(), "0".to_string())]
);
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
.unwrap());
// Defaults walks the page back across BOTH stores in one click.
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("showLootSpam".to_string(), "1".to_string())],
"only the row that had moved is written back"
);
assert_eq!(s.eval::<String>("return REMOVE_CHAT_DELAY").unwrap(), "0");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The saved *Remove Chat Hover Delay* survives a restart — the `applyFunc` runs at
/// `VARIABLES_LOADED`, which is the only thing that can re-collapse the fade constants after a
/// fresh VM has re-run `ChatFrame.xml`'s file-scope `"0"`.
#[test]
fn a_saved_hover_delay_is_applied_when_the_variables_land() {
let mut s = chat_harness();
// What the saved-variables chunk does: assign the global, then the window's VARIABLES_LOADED.
s.run("REMOVE_CHAT_DELAY = \"1\"").unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.2, 0.15),
"the bare assignment changes nothing on its own — that is why the row has an applyFunc"
);
// The reference's own VARIABLES_LOADED arm, off the chain since 2115 — this row's apply is
// `SetChatMouseOverDelay(REMOVE_CHAT_DELAY)` at `UIOptionsFrame.lua:216`, and the event is
// what runs it. (It used to be our own `OptionsFrame_ApplySavedSettings` walk.)
s.fire_event("VARIABLES_LOADED", vec![]);
assert_eq!(
s.eval::<(f64, f64)>("return CHAT_TAB_SHOW_DELAY, CHAT_FRAME_FADE_TIME")
.unwrap(),
(0.0, 0.0)
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Status Bar Text** (decision 1140) — the row that finally reaches `TextStatusBar.xml`. That
/// file has been transcribed whole since 1082, with a `CVAR_UPDATE` watcher and a `statusBarText`
/// read on every repaint, and nothing in the client could move the variable: `GetCVar` answered nil
/// for a key the host never registered, which reads as off. So the numerals were hover-only, with
/// no way to pin them.
///
/// The row is also the only one in this window carrying a `cvarEvent`, and this is what that buys:
/// the XP bar's numerals appear on the click, not on the next XP tick. The event is 1.12's own —
/// `SetCVar(cvar, value, index)`'s third argument, handed back as arg1 — which is why the token
/// here is the uppercase display name and not the CVar's own spelling.
#[test]
fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
let mut s = interface_harness();
// The bar needs a real span before it decides anything about its numerals (its update bails
// on valueMax == 0 and hides the strip instead).
s.set_player_xp(1000, 10000);
s.run("this = MainMenuExpBar; MainMenuExpBar_Update()")
.unwrap();
// Shipped default: off, so the numerals only show while hovered.
assert_eq!(
s.eval::<String>("return GetCVar(\"statusBarText\")")
.unwrap(),
"0"
);
assert!(!s
.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyInterfaceRowStatusTextCheck:GetChecked()"
)
.unwrap());
s.run("BenillaOptionsFrameContainerBodyInterfaceRowStatusTextCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("statusBarText".to_string(), "1".to_string())]
);
// No repaint, no XP tick — only the CVAR_UPDATE the third argument queued.
s.tick(0.0);
assert!(
s.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap(),
"the watcher woke on the click, not on the next value change"
);
// And back off again, the same way.
s.run("BenillaOptionsFrameContainerBodyInterfaceRowStatusTextCheck:Click()")
.unwrap();
s.tick(0.0);
assert!(!s
.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Mouse Sensitivity** (decision 1140) — the Controls page's first slider, and the third frozen
/// constant this arc has unfrozen: the camera's radians-per-pixel rate was a `const` with no way
/// to reach it. 1.12's own row (`UIOptionsFrameSliders`' MOUSE_SENSITIVITY): 0.5 … 1.5 by 0.05,
/// a multiplier, so the registered default 1 is the shipped feel and the percent readout reads it
/// straight. The slider snaps to the reference's step and writes the CVar the host drains onto
/// `LookConfig::sensitivity`.
#[test]
fn the_mouse_sensitivity_slider_snaps_to_the_reference_step() {
let mut s = audio_harness();
s.set_cvar_host("mousespeed", "1.25");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
// Read from the table on select, with the era's rounded-percent readout.
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyControlsRowMouseSpeedControlSlider:GetValue() \
- 1.25) < 0.0001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMouseSpeedControlValue:GetText()"
)
.unwrap(),
"125%"
);
// A user move snaps to 0.05 and writes once.
s.run("BenillaOptionsFrameContainerBodyControlsRowMouseSpeedControlSlider:SetValue(1.42)")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("mousespeed".to_string(), "1.4".to_string())]
);
// Defaults walks it back to the neutral notch — the shipped feel, not the slider's floor.
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return GetCVar(\"mousespeed\")").unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMouseSpeedControlValue:GetText()"
)
.unwrap(),
"100%"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Max Camera Distance** (decision 1140) — the fourth frozen constant, and the one with a wrinkle
/// worth pinning: 1.12 stores a FACTOR over `cameraDistanceMax`'s 15 yd base, so the value that
/// persists is `1.0 … 2.0` while the thing the player is choosing is a distance. The readout shows
/// the distance; the CVar carries the factor.
///
/// The slider's RANGE is unchanged and still reaches the 30 yd ceiling benilla used to ship at;
/// what moved is where it rests. Since 1804 the default is the registrar's own `1.0` — 15 yd, the
/// distance a fresh 1.12 client stops the wheel at — so the walk here is up the range and back
/// rather than down it.
#[test]
fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
// The shipped ceiling, shown as what it buys.
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
)
.unwrap(),
"15 yd",
"vanilla's own out-of-box ceiling"
);
// A move off the notch: the factor is what persists, the yards are the label — and the two
// disagree numerically, which is why a mid-range stop is worth a step of its own.
s.run(
"BenillaOptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(1.4)",
)
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraDistanceMaxFactor".to_string(), "1.4".to_string())]
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
)
.unwrap(),
"21 yd"
);
// All the way up — the slider's top, which is where this row's default sat until 1804.
s.run(
"BenillaOptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(2.0)",
)
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraDistanceMaxFactor".to_string(), "2".to_string())]
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
)
.unwrap(),
"30 yd"
);
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return GetCVar(\"cameraDistanceMaxFactor\")")
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
)
.unwrap(),
"15 yd",
"the readout follows the reset"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Camera Following Style** (decisions 1493/1502) — 1.12's `cameraSmoothStyle`, worn as a
/// Controls-page dropdown, and the setting that decides whether the camera returns to behind the
/// character at all. What is pinned here is the trap: the reference's own dropdown writes `1/2/3`,
/// but the ENGINE's tables are indexed `0 = Never · 1 = Smart · 2 = Always`, and `3` is not a style
/// — the validator accepts it while the terrain-tilt consumer indexes off the end of its table
/// (wow-re `camera-smooth-style.md` §2/§4). So our entries carry the engine's numbers in the
/// reference's display order, a stray `3` still reads as Never rather than as the numerically
/// nearest "Always", and the plate follows the SELECTION the way that dropdown's own does.
#[test]
fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
// The registrar default is the reference's, and it is what the director asked to ship.
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Smart"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleLabel:GetText()"
)
.unwrap(),
"Camera Following Style"
);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyle.tip")
.unwrap(),
"OPTION_TOOLTIP_CAMERA1"
);
assert!(
s.take_cvar_changes().is_empty(),
"reading the table on select must not write it back"
);
// The list is the reference dropdown's, entry for entry and in its order.
s.run("BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
assert_eq!(
s.eval::<f64>("return DropDownList1.numButtons").unwrap(),
3.0
);
assert_eq!(
s.eval::<String>(
"return DropDownList1Button1:GetText() .. \",\" .. DropDownList1Button2:GetText() \
.. \",\" .. DropDownList1Button3:GetText()"
)
.unwrap(),
"Smart,Always,Never"
);
assert!(s
.eval::<bool>("return DropDownList1Button1Check:IsVisible()")
.unwrap());
// Never stores "0" — the engine's own index, not the "3" the reference's dropdown writes —
// and the row's plate becomes Never's own description.
s.run("DropDownList1Button3:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraSmoothStyle".to_string(), "0".to_string())]
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Never"
);
assert_eq!(
s.eval::<String>("return BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyle.tip")
.unwrap(),
"OPTION_TOOLTIP_CAMERA3",
"the plate follows the selection, like the reference dropdown's own"
);
// Always is the middle entry and stores "2".
s.run("BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
s.run("DropDownList1Button2:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraSmoothStyle".to_string(), "2".to_string())]
);
// A config written by a REAL 1.12 client says "3" for Never. It must not display as the
// numerically nearest stop — which is Always, the opposite of what it means.
s.set_cvar_host("cameraSmoothStyle", "3");
s.run("BenillaOptionsFrameCategoryListRowAudio:Click(); BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Never",
"the reference dropdown's own stray value still means Never"
);
assert!(
s.take_cvar_changes().is_empty(),
"displaying a stray value must not write it back"
);
// Defaults walks it back to Smart.
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return GetCVar(\"cameraSmoothStyle\")")
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownText:GetText()"
)
.unwrap(),
"Smart"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Action Bars page's five switches** (decision 1500) — the four bar toggles and the grid
/// option, on ONE page over TWO different stores, which is the whole point of the test.
///
/// The four bar rows are API (`func`) rows: there is nothing local to save, because the preference
/// is four bits of this character's server-side `PLAYER_FIELD_BYTES` byte 2. A click writes the
/// live Lua global, re-derives the bars and re-sends the whole byte — with **four** arguments, the
/// binding's verified arity. Always Show ActionBars is a saved-variable row instead, and NOT
/// because someone preferred it: the same binding silently drops the fifth argument the reference
/// passes it, so that switch has no server store to write.
///
/// The end-to-end teeth are the real bars moving in the same VM — the row's write reaches
/// `MultiActionBar_Update`, which reaches `UIParent_ManageFramePositions`.
#[test]
fn the_action_bars_page_toggles_the_real_bars() {
let mut s = actionbars_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowActionBars:Click()")
.unwrap();
let box_of = |row: &str| format!("BenillaOptionsFrameContainerBodyActionBars{row}Check");
let checked = |s: &UiScript, row: &str| {
s.eval::<bool>(&format!(
"return {}:GetChecked() and true or false",
box_of(row)
))
.unwrap()
};
let shown =
|s: &UiScript, bar: &str| s.eval::<bool>(&format!("return {bar}:IsShown()")).unwrap();
// Read: every switch ships OFF, and so does every bar.
for row in [
"RowMultiBar1",
"RowMultiBar2",
"RowMultiBar3",
"RowMultiBar4",
"RowAlwaysShowMultibars",
] {
assert!(!checked(&s, row), "{row} ships unticked");
}
for bar in [
"MultiBarBottomLeft",
"MultiBarBottomRight",
"MultiBarRight",
"MultiBarLeft",
] {
assert!(!shown(&s, bar), "{bar} ships down");
}
// Bar 4's row is DEAD while bar 3's is unticked — MultiBarLeft cannot stand without
// MultiBarRight (the reference's own rule for this pair, UIOptionsFrame.lua l.722-726).
assert!(
!s.eval::<bool>(&format!(
"return {}:IsEnabled() ~= 0",
box_of("RowMultiBar4")
))
.unwrap(),
"Show Right ActionBar 2 is disabled until Show Right ActionBar is on"
);
// The write: a click raises the bar, writes the global as the number 1, and sends the byte.
let _ = s.take_cvar_changes();
s.run(&format!("{}:Click()", box_of("RowMultiBar1")))
.unwrap();
assert!(shown(&s, "MultiBarBottomLeft"), "the row reached the bar");
assert_eq!(s.eval::<i64>("return SHOW_MULTI_ACTIONBAR_1").unwrap(), 1);
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x01],
"one CMSG_SET_ACTIONBAR_TOGGLES carrying the whole byte"
);
assert!(
s.take_cvar_changes().is_empty(),
"an API row must not touch the CVar table"
);
// …and the managed bottom stack moved with it (CONTAINER_OFFSET_Y's bottomEither 27).
assert_eq!(s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap(), 97.0);
// Ticking bar 3 wakes bar 4's row; ticking bar 4 then brings MultiBarLeft up beside it.
s.run(&format!("{}:Click()", box_of("RowMultiBar3")))
.unwrap();
assert!(
s.eval::<bool>(&format!(
"return {}:IsEnabled() ~= 0",
box_of("RowMultiBar4")
))
.unwrap(),
"bar 3 on wakes bar 4's row"
);
assert!(shown(&s, "MultiBarRight"));
assert!(!shown(&s, "MultiBarLeft"), "bar 4 is still off");
s.run(&format!("{}:Click()", box_of("RowMultiBar4")))
.unwrap();
assert!(shown(&s, "MultiBarLeft"));
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x05, 0x0d],
"one packet per click — bars 1+3, then 1+3+4"
);
// Untick bar 3 and MultiBarLeft goes with it, even though bar 4's own flag is still set — the
// conjunction is in MultiActionBar_Update, not in the row.
s.run(&format!("{}:Click()", box_of("RowMultiBar3")))
.unwrap();
assert!(!shown(&s, "MultiBarRight"));
assert!(!shown(&s, "MultiBarLeft"), "MultiBarLeft rides on bar 3");
assert_eq!(s.eval::<i64>("return SHOW_MULTI_ACTIONBAR_4").unwrap(), 1);
assert!(
!s.eval::<bool>(&format!(
"return {}:IsEnabled() ~= 0",
box_of("RowMultiBar4")
))
.unwrap(),
"…and its row goes back to sleep"
);
// The grid switch is the OTHER store: a saved-variable global, no packet, and an applyFunc
// that opens every extra bar's empty wells.
let _ = s.take_action_bar_toggle_sends();
s.run(&format!("{}:Click()", box_of("RowAlwaysShowMultibars")))
.unwrap();
assert_eq!(
s.eval::<String>("return ALWAYS_SHOW_MULTIBARS").unwrap(),
"1",
"a uvar row stores the panel's string"
);
assert!(
s.take_action_bar_toggle_sends().is_empty(),
"and sends NOTHING — the binding has no room for a fifth argument"
);
assert!(
s.eval::<bool>("return MultiBarBottomLeftButton5:IsShown()")
.unwrap(),
"the applyFunc opened the empty wells"
);
// Defaults walks the whole page back: every bar down, the grid off, the lock unlocked.
s.run("BenillaOptionsFrameContainerDefaults:Click()")
.unwrap();
for bar in [
"MultiBarBottomLeft",
"MultiBarBottomRight",
"MultiBarRight",
"MultiBarLeft",
] {
assert!(!shown(&s, bar), "{bar} back down");
}
assert_eq!(
*s.take_action_bar_toggle_sends()
.last()
.expect("Defaults writes every bar row"),
0,
"the last packet Defaults sends is the empty byte"
);
assert_eq!(
s.eval::<String>("return ALWAYS_SHOW_MULTIBARS").unwrap(),
"0",
"MultiBars.xml's own file-scope assignment IS the registered default"
);
assert_eq!(s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap(), 70.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The window tabs (decision 2028) ─────────────────────────────────────────────────────────────
/// A measurer that models the one property `FixedWidthFont` throws away: **glyph advances step to
/// whole physical pixels**, so a label is not proportionally the same width at two scales. Six
/// units per character at the DRAWN raster size (`6 × scale`, rounded), divided back by the scale
/// — the shape `ui_text::measurer` really has, small enough to predict by hand.
struct SteppedFont(f32);
impl benilla_ui::script::TextMeasure for SteppedFont {
fn measure(&mut self, req: &benilla_ui::script::MeasureRequest) -> (f32, f32, f32) {
let per_glyph = (self.0 * req.scale).round();
let natural = req.text.chars().count() as f32 * per_glyph / req.scale;
(natural, 12.0, natural)
}
}
/// **Both tabs fit their labels once per show** — the era's width law (`MinimalTab.lua` l.7:
/// label + 40) on the 1.12 tab template's own seat (`<OnShow>` → `PanelTemplates_TabResize`,
/// decision 1993), with no OnUpdate poll behind it and no `fitted` latch. The engine's measurer
/// answers `GetStringWidth` inside the Lua call that asks it, so one call is the whole fit.
///
/// **The seat is a show and not the era's OnLoad, and that is what the numbers below pin.**
/// `GetStringWidth` answers in the region's OWN units, so its number depends on the owner's
/// effective scale — and this window's scale is set by `BenillaOptionsFrame_UpdateScale` in the window's
/// own `<OnShow>`, after every `<OnLoad>` has already run. At OnLoad these tabs measure at scale 1
/// (64 and 76 here); on the show, behind the window's own OnShow, they measure at the drawn 0.78.
#[test]
fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
let mut s = UiScript::new().unwrap();
s.set_text_measurer(Box::new(SteppedFont(6.0)));
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.resolve();
let num = |s: &mut UiScript, expr: &str| -> f32 { s.eval(&format!("return {expr}")).unwrap() };
assert_eq!(
num(&mut s, "BenillaOptionsFrame:GetScale()"),
0.78,
"ERA_WINDOW_SCALE"
);
// The law, against each tab's own live measure.
for tab in ["BenillaOptionsFrameGameTab", "BenillaOptionsFrameAddOnsTab"] {
let w = num(&mut s, &format!("{tab}:GetWidth()"));
let l = num(&mut s, &format!("{tab}Text:GetStringWidth()"));
assert!(
(w - (l + 40.0)).abs() < 0.01,
"{tab} is {w} wide; the era's law is its label ({l}) + 40"
);
}
// …and the numbers themselves, which say WHEN it ran. At 0.78 a 6-unit glyph rasterizes at 5
// physical px and reads back 5/0.78 units, so "Game" (4) is 25.64 and "AddOns" (6) is 38.46.
// An OnLoad fit — before the window's OnShow set the scale — would have measured at 1 and left
// these at 64 and 76, which is 3.3 and 15.5 units off the drawn label.
let close = |a: f32, b: f32| (a - b).abs() < 0.01;
assert!(close(
num(&mut s, "BenillaOptionsFrameGameTab:GetWidth()"),
65.641_03
));
assert!(close(
num(&mut s, "BenillaOptionsFrameAddOnsTab:GetWidth()"),
78.461_54
));
// Nothing re-fits them afterwards: the same two numbers with four frames run, which is the
// value the retired OnUpdate settle used to converge to and latch.
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
assert!(close(
num(&mut s, "BenillaOptionsFrameGameTab:GetWidth()"),
65.641_03
));
assert!(close(
num(&mut s, "BenillaOptionsFrameAddOnsTab:GetWidth()"),
78.461_54
));
// A re-show re-fits rather than latching — the stock template's own behaviour, and what lets
// a tab follow the window's scale when the screen changes under it.
s.run("HideUIPanel(BenillaOptionsFrame) ShowUIPanel(BenillaOptionsFrame)")
.unwrap();
s.resolve();
assert!(close(
num(&mut s, "BenillaOptionsFrameGameTab:GetWidth()"),
65.641_03
));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The falsifier: **with no measurer installed**, the same one-shot measures 0 and each tab comes
/// out 40 wide — the law's padding and nothing else. That is the engine-less truth (`benilla-ui`
/// `script/measure.rs`: absent is a supported state, metrics stay 0 until the host's batch
/// round-trip fills them), and it is why the fit is a *shown* frame's job: by then the app has
/// seated `AtlasMeasurer` for certain, whatever order the boot took.
#[test]
fn without_a_seated_measurer_the_same_fit_reads_zero() {
let mut s = harness_on(UiScript::new().unwrap());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.resolve();
for tab in ["BenillaOptionsFrameGameTab", "BenillaOptionsFrameAddOnsTab"] {
let w: f32 = s.eval(&format!("return {tab}:GetWidth()")).unwrap();
assert!(
(w - 40.0).abs() < 0.01,
"{tab} is {w} wide, not the bare 40"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// ── The reference table's CVar census (decision 2077) ────────────────────────────────────────────
//
// `UIOptionsFrameCheckButtons` in `OptionsFrame.xml` is a transcription of the reference's own
// table (`UIOptionsFrame.lua` l.4-84), and every entry names its store. Twenty-eight of them name a
// **CVar** — and a name in that table is a claim: `SetCVar(value.cvar, …)` against a CVar this
// client never registered writes nothing and reads back nil, so the entry is a setting benilla
// appears to offer and does not.
//
// Decision 1140 §4 wrote that census as prose in a decision record, and prose in a record is a
// snapshot: by the time anyone looked again it said SHOW_TUTORIALS had "no store at all" (it has
// three special-case arms, 2077) and had been amended once already for the multibar rows. So the
// census lives here instead, where it cannot go stale — the same posture as `cvars::Reference`,
// and checked in **both directions**: an unregistered name missing from this list fails, and a
// listed name that has since been registered fails too.
//
// A row on this list is not a defect. It is a 1.12 setting whose *feature* benilla does not have,
// and the reason is the row's whole point — "no key without a reader" (1134 §4) is why the CVar is
// absent, and this is where that decision is written down per setting.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
// **A blocker is now a SPEC, not a label.** Every row below has had its mechanism derived at
// the bytes since 2115 wrote this list, so what is missing is the *feature*, never the
// knowledge — and saying which is the difference between a row somebody can pick up and a row
// that just reads "not done".
(
"autointeract",
"click-to-move — the one row here that is a whole movement mode rather than a knob. \
`CanAutoInteract 0x60f900` gates the world-click pick mask's bit 0 and inverts \
`0x5ec110`'s interaction-distance refusal, so an out-of-range corpse/GO/NPC/cast click \
queues an approach (`0x60fed0`, move kinds 6/7/9/0xa) instead of refusing. The commit \
itself puts NOTHING on the wire — it stamps a local goal at `0x611130` — and the packets \
are its consequences (a stand, then the original verb on arrival). benilla has only the \
keyboard controller and /follow (mode 3). Registered default is \"0\" on every locale but \
koKR (`0x603374` selects on the locale index), so stock West ships it OFF",
),
(
"UnitNamePlayerPVPTitle",
"the PvP rank prefix on the overhead name line — slot a4 of `0x608f50`, bit `0x20` of the \
same mask, resolved by `0x609370` through the `PVP_RANK_%d_%d` GlobalStrings key. Blocked \
one step further back than its guild twin: the rank byte streams, but the key's second \
index is a FACTION SIDE that `ui_unit` does not resolve for an arbitrary player yet",
),
// **The SLIDERS' half is empty, and that is the point.** 2115 extended this census from
// `UIOptionsFrameCheckButtons` to `UIOptionsFrameSliders` and it caught exactly one row —
// `cameraYawMoveSpeed`, the MOUSE_LOOK_SPEED slider — which was not cosmetic:
// `UIOptionsFrame_Load` does `slider:SetValue(GetCVar(value.cvar))`, `Slider:SetValue` is a
// shape-A binding (`0x790980`) that RAISES on a nil in the reference too, and that raise is
// what stopped the stock window's `_Load()` (and `_SetDefaults`, through `GetCVarDefault`).
// It is registered now, so all four slider CVars are backed and the window's `_Load()` runs to
// completion. Every row left below is a check button, and a check button's
// `GetCVar(x) == "1"` is nil-safe — so nothing on this list can raise any more. A new SLIDER
// row arriving here is therefore a live raise, not a missing feature, and should be read that
// way by whoever adds it.
];
/// **Every CVar the reference's own options table names is registered here, or listed above with
/// the feature it waits on** (decision 2077) — the check that turns 1140 §4's prose census into
/// something that cannot rot, in both directions.
///
/// This is the instrument for the whole class: a table entry that names a CVar benilla does not
/// register is a row that would tick and do nothing, and until now the only thing standing between
/// us and shipping one was somebody re-reading a decision record from months ago.
#[test]
fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker() {
let s = harness();
// The table as the file declares it: `name` (the display key) -> its `cvar`, if it has one.
// **BOTH of the reference's option tables** (2115). The checkbuttons were the whole census
// until the stock window went on the manifest and `UIOptionsFrameSliders` turned out to name a
// CVar nothing here registers — a row the check could not see because it only read one table.
let named: Vec<String> = s
.eval::<Vec<String>>(
"local out = {} \
for key, v in pairs(UIOptionsFrameCheckButtons) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
for _, v in ipairs(UIOptionsFrameSliders) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
table.sort(out) \
return out",
)
.expect("read UIOptionsFrameCheckButtons and UIOptionsFrameSliders");
assert!(
named.len() >= 28,
"the transcription lost rows: only {} cvar entries",
named.len()
);
let registered: std::collections::HashSet<String> = crate::cvars::registered_pairs()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
let listed: std::collections::HashSet<String> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
let mut unlisted: Vec<&str> = Vec::new();
for cvar in &named {
let key = cvar.to_ascii_lowercase();
if !registered.contains(&key) && !listed.contains(&key) {
unlisted.push(cvar);
}
}
assert!(
unlisted.is_empty(),
"these rows name a CVar nothing registers, and nothing says why: {unlisted:?} — either \
build the backing or add the row to UNBACKED_REFERENCE_CVARS with its blocker"
);
// The other direction: a listed name that IS registered now means the feature landed and the
// row is stale — exactly the drift `cvars::Reference` guards against on its own column.
let stale: Vec<&str> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| *n)
.filter(|n| registered.contains(&n.to_ascii_lowercase()))
.collect();
assert!(
stale.is_empty(),
"these are registered now — drop them from UNBACKED_REFERENCE_CVARS and give them a row: \
{stale:?}"
);
// And a listed name the table never mentions is a row describing nothing.
let phantom: Vec<&str> = UNBACKED_REFERENCE_CVARS
.iter()
.map(|(n, _)| *n)
.filter(|n| !named.iter().any(|c| c.eq_ignore_ascii_case(n)))
.collect();
assert!(
phantom.is_empty(),
"UNBACKED_REFERENCE_CVARS names CVars the reference's table does not: {phantom:?}"
);
}
/// **Nothing the reference lets a player change, and this client backs, is out of reach**
/// (decision 2180) — the other half of the census above, and the half that was missing.
///
/// The test above asks the ENGINE's question: is the CVar registered? It was green the entire time
/// the four camera toggles had no box on any window a player can open. 2149 built their
/// mechanisms and dropped them off `UNBACKED_REFERENCE_CVARS`; 2115 loads the stock window that
/// carries their checkboxes deliberately HIDDEN, so addons find real frames and the player never
/// sees it; and our own window — the one the ESC menu opens — simply had no rows. Two records were
/// individually right and the setting was unreachable, because no check compared them.
///
/// This asks the UI's question. A CVar that lands on the list below is one the reference lets a
/// player change and benilla does not, and it costs a stated reason at the row.
#[test]
fn every_registered_reference_cvar_has_a_row_on_our_own_window() {
let s = harness();
// Both of the reference's own option tables, exactly as the registration census reads them.
let named: Vec<String> = s
.eval::<Vec<String>>(
"local out = {} \
for key, v in pairs(UIOptionsFrameCheckButtons) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
for _, v in ipairs(UIOptionsFrameSliders) do \
if v.cvar then table.insert(out, v.cvar) end \
end \
table.sort(out) \
return out",
)
.expect("read the reference's two option tables");
// What OUR window can actually move. A row's own `cvar`, plus the `partner` a handful of rows
// write beside it (`PetSpellDamage`, `cameraPitchMoveSpeed`) — those are moved by a control
// the player uses, which is what "reachable" means here, even though neither is table-named.
let ours: std::collections::HashSet<String> = s
.eval::<Vec<String>>(
"local out = {} \
for page, rows in pairs(OPTIONS_PAGE_ROWS) do \
for _, rkey in ipairs(rows) do \
local row = getglobal(\"BenillaOptionsFrameContainerBody\" .. page .. rkey) \
if row.cvar then table.insert(out, row.cvar) end \
if row.partner then table.insert(out, row.partner.cvar) end \
end \
end \
return out",
)
.expect("read our own window's rows")
.into_iter()
.map(|c| c.to_ascii_lowercase())
.collect();
let registered: std::collections::HashSet<String> = crate::cvars::registered_pairs()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
let excused: std::collections::HashSet<String> = UNREACHABLE_REFERENCE_CVARS
.iter()
.map(|(n, _)| n.to_ascii_lowercase())
.collect();
// Two floors, because the failure this check is worth having is the one where it passes for
// the wrong reason: a harness whose stock tables did not load reads no names, and one whose
// window did not build reads no rows, and either way `missing` is empty and green.
let backed = named
.iter()
.filter(|c| registered.contains(&c.to_ascii_lowercase()))
.count();
assert!(
backed >= 28,
"the reference's tables did not load: only {backed} of their names are registered here"
);
assert!(
ours.len() >= 40,
"our own window's rows did not build: only {} carry a cvar",
ours.len()
);
let missing: Vec<&str> = named
.iter()
.filter(|c| {
let k = c.to_ascii_lowercase();
registered.contains(&k) && !ours.contains(&k) && !excused.contains(&k)
})
.map(String::as_str)
.collect();
assert!(
missing.is_empty(),
"these are backed and the reference lets a player change them, and our own options \
window has no row for them: {missing:?} — seat a row, or add them to \
UNREACHABLE_REFERENCE_CVARS with the reason"
);
// The other direction, the one that keeps the list from rotting: an excuse for a row that now
// exists is an excuse describing nothing.
let stale: Vec<&str> = UNREACHABLE_REFERENCE_CVARS
.iter()
.map(|(n, _)| *n)
.filter(|n| ours.contains(&n.to_ascii_lowercase()))
.collect();
assert!(
stale.is_empty(),
"these have a row now — drop them from UNREACHABLE_REFERENCE_CVARS: {stale:?}"
);
}
/// **It is empty, and that is the finding, not an omission.** Every CVar the reference's own two
/// option tables name is now either unregistered (and on `UNBACKED_REFERENCE_CVARS` above with the
/// feature it waits on) or reachable from benilla's own options window. `autointeract` and
/// `UnitNamePlayerPVPTitle`, the last two unbacked rows, will each need a row here the day their
/// feature lands — which is exactly what the census above will say when it calls them stale.
///
/// A name arriving here needs a reason of the same shape as the list above's: what the *player*
/// cannot do, and why the row is not simply seated.
const UNREACHABLE_REFERENCE_CVARS: &[(&str, &str)] = &[];
/// The four camera toggles (2149's mechanisms, 2179's water-collision rebuild) on the page a
/// player can open. Each box opens showing the value the reference registers — two of them "1",
/// which is the half that makes this more than cosmetic: on `cameraPivot` and
/// `cameraWaterCollision` a player who wants them OFF had no way to say so at all.
#[test]
fn the_four_camera_toggles_read_their_shipped_defaults_and_write_on_the_click() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
assert!(
s.take_cvar_changes().is_empty(),
"reading the four on open must not write them back"
);
let checked = |s: &mut UiScript, row: &str| -> bool {
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyControls{row}Check:GetChecked()"
))
.unwrap()
};
// The registrar's own values: pivot and water collision ON, terrain tilt and head bob OFF.
assert!(checked(&mut s, "RowSmartPivot"));
assert!(checked(&mut s, "RowWaterCollision"));
assert!(!checked(&mut s, "RowFollowTerrain"));
assert!(!checked(&mut s, "RowHeadBob"));
// And the click writes — the first flip of each is the one that could not be expressed before.
for (row, cvar, want) in [
("RowFollowTerrain", "cameraTerrainTilt", "1"),
("RowHeadBob", "cameraBobbing", "1"),
("RowWaterCollision", "cameraWaterCollision", "0"),
("RowSmartPivot", "cameraPivot", "0"),
] {
s.run(&format!(
"BenillaOptionsFrameContainerBodyControls{row}Check:Click()"
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![(cvar.to_string(), want.to_string())],
"{row} writes {cvar}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The partner write** (2180): two rows drive a second CVar that appears in neither option
/// table, because `UIOptionsFrame_Save` keeps a special-case arm for each. Without it the control
/// is a half-write, and a silent one — `SetCVar` on a name nobody wrote is not an error, it is a
/// value that stayed where it was, so the box would read "pet damage off" while pet SPELL damage
/// kept floating.
#[test]
fn the_pet_damage_box_and_the_look_slider_each_write_their_unnamed_twin() {
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
let _ = s.take_cvar_changes();
// Pet Damage off: both pet CVars move, the melee one through the row and the spell one
// through the partner (UIOptionsFrame_Save l.334-336).
s.run("BenillaOptionsFrameContainerBodyCombatRowPetDamageCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![
("PetMeleeDamage".to_string(), "0".to_string()),
("PetSpellDamage".to_string(), "0".to_string()),
],
"one box, both pet knobs"
);
// Mouse Look Speed: the pitch twin at half the yaw value (l.355-356) — which is exactly the
// 180/90 pair the two are registered at, so the ratio the reference ships is preserved by
// dragging rather than broken by it.
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
let _ = s.take_cvar_changes();
s.run("BenillaOptionsFrameContainerBodyControlsRowMouseLookSpeedControlSlider:SetValue(240)")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![
("cameraYawMoveSpeed".to_string(), "240".to_string()),
("cameraPitchMoveSpeed".to_string(), "120".to_string()),
]
);
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMouseLookSpeedControlValue:GetText()"
)
.unwrap(),
"240",
"a deg/s slider reads out the raw number, not a percent of nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Show Target Damage is a SECOND master on the Combat page, and the floating-text one does not
/// own it.** The reference keeps the two dependency blocks apart —
/// `UIOptionsFrame_UpdateDependencies` greys 53…69 from CheckButton52 and greys 9 and 11 from 19,
/// in two separate `if`s — and collapsing them would tell a player that turning off scrolling
/// combat text also turns off damage numbers, which is the opposite of what `CombatDamage` does.
#[test]
fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_alone() {
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
let enabled = |s: &mut UiScript, row: &str| -> bool {
s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyCombat{row}Check:IsEnabled() ~= 0"
))
.unwrap()
};
// `SHOW_COMBAT_TEXT` ships "0", so the page arrives with the floating-text family greyed —
// and the damage trio live beside it, which is the whole point of the split.
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "0");
assert!(!enabled(&mut s, "RowAuras"));
assert!(enabled(&mut s, "RowShowDamage"));
assert!(enabled(&mut s, "RowPeriodicDamage"));
assert!(enabled(&mut s, "RowPetDamage"));
// The other master: Show Target Damage off greys its two children and nothing else.
s.run("BenillaOptionsFrameContainerBodyCombatRowShowDamageCheck:Click()")
.unwrap();
assert_eq!(
s.eval::<String>("return GetCVar(\"CombatDamage\")")
.unwrap(),
"0"
);
assert!(!enabled(&mut s, "RowPeriodicDamage"));
assert!(!enabled(&mut s, "RowPetDamage"));
assert!(
enabled(&mut s, "RowShowDamage"),
"the master is the way back in"
);
// Waking the floating-text family does not wake them: the two gates are independent.
s.run("BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
assert!(enabled(&mut s, "RowAuras"));
assert!(
!enabled(&mut s, "RowPetDamage"),
"scrolling combat text does not own the damage numbers"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The two other dependencies 2180 seated, both the reference's own: Player Guild Names is dead
/// while player names are off (l.702-708 — there is no overhead stack for the guild line to be
/// the second row of), and Auto-Follow Speed is dead while the following style is Never
/// (l.715-719 — there is no follow to set a speed for).
#[test]
fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
// Nameplates: `UnitNamePlayer` ships "1", so the child arrives live.
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
let guild_on = |s: &mut UiScript| -> bool {
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyNameplatesRowGuildNamesCheck:IsEnabled() ~= 0",
)
.unwrap()
};
assert!(guild_on(&mut s));
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
.unwrap();
assert!(!guild_on(&mut s), "no names, no guild line to gate");
s.run("BenillaOptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:Click()")
.unwrap();
assert!(guild_on(&mut s));
// Controls: the style ships "1" (Smart), so the slider arrives live; "0" is Never.
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
let speed_thumb = |s: &mut UiScript| -> bool {
s.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowAutoFollowSpeedControlSliderThumb\
:IsShown()",
)
.unwrap()
};
assert!(speed_thumb(&mut s));
s.run("SetCVar(\"cameraSmoothStyle\", \"0\") OptionsPage_Refresh(\"Controls\")")
.unwrap();
assert!(
!speed_thumb(&mut s),
"a Never follow leaves the speed groove thumbless, 1.12's own way of saying dead"
);
// …and the groove refuses the press as well as looking refused, which the reference never
// needed: its slider has no track jump to refuse.
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyControlsRowAutoFollowSpeedControlSlider\
:IsMouseEnabled()"
)
.unwrap());
s.run("SetCVar(\"cameraSmoothStyle\", \"1\") OptionsPage_Refresh(\"Controls\")")
.unwrap();
assert!(speed_thumb(&mut s));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}