Files
benilla-octo/crates/benilla-app/src/ui_script/options_tests.rs
T
gromandClaude Opus 5 1c786b7ecb Anisotropic Filtering takes its seat on the Graphics page
The CVar has been registered and read since the filter policy landed, and
last commit put it on the Graphics Preset ladder — but a player had no way
to reach it. The reference's own `OptionsFrameSliders` declares the row at
index 7; ours left that index a hole, so the only route to anisotropy was
`/console anisotropic 16`, and a player whose preset reads Custom (the
seeder never overwrites one) never met the setting at all.

Seated where the reference's own slider order puts it: after Brightness,
before Weather Intensity. Three things it does differently, each stated
beside it:

- The groove runs 1 to 16, the CVar's own `ANISO_RANGE`, not the stock
  table's 1 to 4. That ceiling was the reference *slider's*, not the
  CVar's — its callback parses 1-16 (`0x689110`) — and both the High and
  the Ultra rung ask for 16. So the row carries literal bounds and stays
  out of `BENILLA_GRAPHICS_SLIDER_ROWS`, exactly as `RowRenderScale` does
  for a CVar the stock table never had, which leaves `OptionsFrameSliders`
  verbatim for any addon that reads it.
- `1` reads out as "Off", not "1x": the reference writes filter mode 5
  only at 2 and above, so 1 is the feature off and not its weakest stop.
- The description is ours. 1.12's own OPTION_TOOLTIP_ANISOTROPIC describes
  a four-stop slider, and this one is sixteen.

Deferred to Apply, as Vertical Sync and Render Scale are: a sampler is
baked into each `Image` at load, so the write cannot reach a texture that
is already resident. The tooltip says so in the row rather than leaving a
player to wonder why the ground did not change — the same thing the
reference's UI means by "enabled upon restart".

Verified: `benilla-app --lib` at the exact 269-failure data-gated baseline,
set-identical by `comm`, with the new row's own test and the two census
counts (tooltip keys, raised plates) moved 116 -> 117 as the row asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-26 14:39:25 -06:00

5571 lines
207 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.
//! Benilla's options window (`OptionsFrame.xml`): the shell, the search and scroll, and every
//! page's rows against the real CVar set, the saved option globals and the API rows.
use benilla_ui::script::{QuadContent, SoundRequest, UiScript, WornDisplay};
/// The options window and the game menu over the manifest slice they need.
fn harness() -> UiScript {
harness_on(UiScript::new().unwrap())
}
/// Load the manifest slice onto `s`, so a page test can seed CVars before the XML loads, as the
/// app does. GameTooltip.xml precedes UIDropDownMenu.xml for the kit's `TOOLTIP_DEFAULT_COLOR`.
fn harness_on(mut s: UiScript) -> UiScript {
s.set_screen_size(1024.0, 768.0);
// Each file loads once: a page harness may have loaded it already, and a second load of the
// money kit's frames breaks `MoneyFrame_Update`, which writes through `getglobal(name)`.
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",
// Before every window that names `parent="UIParent"`, which resolves at load.
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 stock options kit, hidden: `UIOptionsFrame_Init` assigns the option globals our
// rows capture at OnLoad, and these declare the tables the Graphics rows and census read.
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"ScrollTemplates.xml", // the page scroll and the Keybindings list
"KeyBindingsPage.xml", // the Keybindings page's templates and script
"OptionsFrame.xml",
"GameMenuFrame.xml",
] {
// Our window loads strict: a missing template there fails instead of only warning.
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 post-load pass: `BuffFrame_OnLoad` never pitches the buff rows, and the stock arm
// that does waits for `VARIABLES_LOADED` (`UIOptionsFrame.lua:206`).
super::manifest::apply_buff_durations(&s).unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
}
/// On the reference's igMainMenuOption kit; the window takes the center slot the menu leaves.
#[test]
fn the_menu_options_button_swaps_the_menu_for_the_options_window() {
benilla_formats::wow_data_or_skip!();
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());
}
/// The title is the selected row's label, which differs from its key only for ActionBars.
#[test]
fn controls_is_the_default_category_and_the_title_reads_it() {
benilla_formats::wow_data_or_skip!();
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());
}
/// The selection survives a close and reopen: the OnShow re-applies the last category.
#[test]
fn clicking_a_row_moves_the_selection_and_the_page_title() {
benilla_formats::wow_data_or_skip!();
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"
);
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());
}
#[test]
fn the_close_button_hides_the_window() {
benilla_formats::wow_data_or_skip!();
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, unlike the era window: the red button and ESC close it.
assert!(s
.eval::<bool>("return BenillaOptionsFrameClosePanelButton == nil")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 1.14's OptionsListButtonTemplate wash: one UI-QuestLogTitleHighlight quad in ADD blend at the
/// era's 187x21, gold (1,1,0) locked on the selected row, steel blue (.196,.388,.8) on hover.
#[test]
fn the_selected_row_wears_the_gold_wash_and_hover_runs_blue() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
// Scale 1, so rects and the pointer share coordinates; at 1024x768 the fit clamp stays above 1.
s.run("ERA_WINDOW_SCALE = 1").unwrap();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
assert_eq!(
s.eval::<f64>("return BenillaOptionsFrameCategoryListRowControlsBg:GetWidth()")
.unwrap(),
187.0
);
assert_eq!(
s.eval::<f64>("return BenillaOptionsFrameCategoryListRowControlsBg:GetHeight()")
.unwrap(),
21.0
);
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
);
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<_>>()
);
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 black fill draws over the tiled ground and clear of the rope border, whose edge slices in
/// `UI-DialogBox-Border` are dead from texel 16 of 32 and whose corners' ink ends by 14: an inset
/// of 14 clears it, where the 11/12 bg insets would not.
#[test]
fn the_ground_dim_draws_over_the_tile_and_clear_of_the_rope() {
benilla_formats::wow_data_or_skip!();
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}"
);
}
// `extract` is in ascending z: a later index draws later.
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());
}
/// The era SettingsPanel search: the live rows under a category head, a matched child pulling its
/// parent in above it; clearing the box lays every row back on its authored XML chain.
#[test]
fn search_reflows_live_rows_and_restores_the_page() {
benilla_formats::wow_data_or_skip!();
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 reflow runs from the box's `OnTextChanged`, which the engine defers to the drain.
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))"
);
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}"
);
}
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:?}"
);
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())]
);
s.run("BenillaOptionsFrameSearchBox:SetText(\"\")").unwrap();
s.tick(0.0); // the clear reflows on the drain too
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 scoring (`MatchesSearchTags`, Blizzard_Settings.lua): the whole query is the first
/// word tried, so a phrase hit outscores its own words and leads its group.
#[test]
fn the_phrase_match_outranks_its_words() {
benilla_formats::wow_data_or_skip!();
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());
}
/// A head click selects its category and clears the box, as the era's `SelectCategory` does.
#[test]
fn a_head_click_ends_the_search_and_a_miss_shows_no_results() {
benilla_formats::wow_data_or_skip!();
let mut s = harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameSearchBox:SetText(\"volume\")")
.unwrap();
s.tick(0.0); // the reflow builds the head clicked below
s.run("BenillaOptionsFrameContainerBodySearchHeadAudio:Click()")
.unwrap();
// The click clears the box at once; the restore rides that clear's deferred `OnTextChanged`.
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
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
assert!(!s
.eval::<bool>("return BenillaOptionsFrameContainerBodyNoResults:IsVisible()")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// A left press on the track, off the thumb, seats the thumb under the cursor and drags on.
#[test]
fn a_track_press_seats_the_thumb_and_keeps_dragging() {
benilla_formats::wow_data_or_skip!();
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();
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
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;
// 1 unit inside the right end, within the thumb's half-width end zone, where the centered
// seat clamps the fraction to 1.0.
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 midpoint is exactly fraction 0.5, on the 0.05 grid, so nothing snaps.
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());
}
/// The readout shares its label's line: the +3 that seats the groove and thumb in the row is on
/// the art frames, not on the control the readout hangs off.
#[test]
fn a_slider_rows_readout_sits_on_its_labels_line() {
benilla_formats::wow_data_or_skip!();
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}");
// Both are regions, so their rects share a space and need 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
);
// The groove still rides 3 units high of the row.
assert!(
(mid(&format!("{base}ControlGroove")) - mid(&base) - 3.0).abs() < 0.01,
"{base}: the groove left its seat"
);
}
}
}
/// A VM with the real registered CVar set, before any XML loads, as the app boots.
fn audio_harness() -> UiScript {
let s = UiScript::new().unwrap();
s.register_cvars(crate::cvars::registered_pairs());
s
}
/// The Combat page's harness, with `Blizzard_CombatText` seated off the chain as LoadOnDemand, for
/// the master row's apply to load as the client does.
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"]);
super::test_ui::seat_chain_addon(&mut s, "Blizzard_CombatText");
harness_on(s)
}
/// Load `files` ahead of the window, in order, each recorded so `harness_on` loads it only once.
fn load_definers(s: &UiScript, files: &[&str]) {
for file in files {
super::test_ui::load_ui(s, file);
s.run(&format!(
"BENILLA_TEST_LOADED = BENILLA_TEST_LOADED or {{}} BENILLA_TEST_LOADED[{file:?}] = true"
))
.unwrap();
}
}
/// The Interface page's harness, with its rows' consumers loaded ahead of the window: the unit
/// frames, the buff bar, the XP bar, the quest windows and the tutorial frame.
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 unit frames and the kits ahead of them: their dropdowns initialize at load, and
// the dropdown kit reads `GameTooltip`'s `TOOLTIP_DEFAULT_COLOR`.
"Interface\\FrameXML\\GameTooltip.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"Interface\\FrameXML\\BasicControls.xml", // `TEXT`, which UnitPopup.lua calls at load
"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",
// For `OpacityFrameSlider`, which `PartyMemberBackground` sets on `VARIABLES_LOADED`.
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Interface\\FrameXML\\Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// `ExhaustionTick_Update` reads `ReputationWatchBar`, which `ReputationFrame.xml`
// declares, after the templates its check boxes inherit.
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 setter, like the reference's Save arm, reaches
// `TutorialFrameCheckButton` and `TutorialFrame_HideAllAlerts` unguarded.
"Interface\\FrameXML\\TutorialFrame.xml",
],
);
harness_on(s)
}
/// The Chat page's harness: the chat frames ahead of the window, for `SetChatMouseOverDelay` and
/// the fade constants it moves (`FloatingChatFrame.lua:8-9`, `:1456`).
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 the chat and emote menus build on
"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: the stock bars its rows move, and the options windows ahead of
/// `MultiActionBars.xml`, as in the manifest.
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",
// `ExhaustionTick_Update` reads `ReputationWatchBar`, which `ReputationFrame.xml`
// declares, after the templates its check boxes inherit.
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",
// `UIOptionsFrame_Init` assigns the globals these rows capture at OnLoad as their
// default, and `MultiActionBars.xml` writes into `UIOptionsFrameCheckButtons` at load.
r"Interface\FrameXML\OptionsFrame.lua",
r"Interface\FrameXML\UIOptionsFrame.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
],
);
harness_on(s)
}
/// Sliders read the stored value with the era's rounded-percent readout; checkboxes read the flag.
#[test]
fn the_audio_page_reads_the_cvar_table_on_select() {
benilla_formats::wow_data_or_skip!();
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());
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyAudioRowMusicControlSlider:GetValue() - 0.7) < 0.0001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyAudioRowMusicControlValue:GetText()"
)
.unwrap(),
"70%"
);
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowEnableMusicCheck:GetChecked()"
)
.unwrap());
assert!(s
.eval::<bool>("return BenillaOptionsFrameContainerBodyAudioRowEnableAllCheck:GetChecked()")
.unwrap());
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 move snaps to the era's 5% grid (`obeyStepOnDrag`) and writes a short string.
#[test]
fn a_slider_move_snaps_and_writes_the_cvar() {
benilla_formats::wow_data_or_skip!();
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"
);
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 sound panel's inverted click kit (`SoundOptionsFrame.lua:86-90`) and its dependency:
/// Enable All Sound off greys Enable Ambience but not Enable Music.
#[test]
fn the_checkbox_rows_write_flags_and_the_master_greys_ambience() {
benilla_formats::wow_data_or_skip!();
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();
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())));
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());
}
/// 1.12 has no such checkbox and always goes quiet in the background, so the row ships off; the
/// master's rule (`SoundOptionsFrame_UpdateDependencies`) names two other rows, not this one.
#[test]
fn the_background_sound_row_boots_off_and_writes_the_era_cvar() {
benilla_formats::wow_data_or_skip!();
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()
)]
);
s.run("BenillaOptionsFrameContainerBodyAudioRowEnableAllCheck:Click()")
.unwrap();
assert!(s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyAudioRowBackgroundSoundCheck:IsEnabled() ~= 0"
)
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Defaults is the era's per-page reset, to the registered defaults.
#[test]
fn defaults_resets_the_audio_page_to_registered_defaults() {
benilla_formats::wow_data_or_skip!();
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:?}"
);
// Rows already at their 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());
}
/// The two 1.12 sliders read the table: uiScale over 0.64..1.0 in percent, farclip over 177..777
/// in yards (`OptionsFrame.lua:25-26`).
#[test]
fn the_graphics_page_reads_the_cvar_table_on_select() {
benilla_formats::wow_data_or_skip!();
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"
);
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 Resolution row's entries come from the **engine**, which has pushed nothing when this
/// file loads: `publish_display_modes` is a Bevy system, so the list lands a frame or more after
/// the UI is up. So this test pushes the modes *after* loading, which is the real sequence — a
/// row that built its list once at OnLoad shows the bare stored index ("2") in the capsule and
/// opens an empty menu, which is exactly what it did on first play.
#[test]
fn the_resolution_dropdown_lists_modes_the_engine_pushes_after_the_ui_is_already_up() {
benilla_formats::wow_data_or_skip!();
const ROW: &str = "BenillaOptionsFrameContainerBodyGraphicsRowResolution";
let res = |w: u32, h: u32| benilla_ui::script::ScreenResolution {
width: w,
height: h,
};
let mut s = harness_on(audio_harness());
// Up and showing, with the engine still silent: the row must not wedge itself on nothing.
s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}Label:GetText()"))
.unwrap(),
"Resolution"
);
// The engine's push, as `publish_display_modes` makes it: the panel's modes ascending, and a
// live window size that is one of them.
s.set_screen_resolutions(
vec![res(1280, 720), res(1920, 1080), res(2560, 1440)],
Some(res(2560, 1440)),
);
s.run("OptionsPage_Refresh(\"Graphics\")").unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
.unwrap(),
"2560x1440",
"the capsule names the live mode, not the raw index"
);
// The menu lists every mode the monitor offers, which is the half that did nothing before.
s.run(&format!("{ROW}DropdownButton:Click()")).unwrap();
assert!(s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
assert_eq!(
s.eval::<usize>("return DropDownList1.numButtons").unwrap(),
3,
"one entry per pushed mode"
);
assert_eq!(
s.eval::<String>("return DropDownList1Button1:GetText()")
.unwrap(),
"1280x720"
);
// A pick is latched like the other two video rows: it stages, Apply commits it.
s.run("DropDownList1Button1:Click()").unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
.unwrap(),
"1280x720",
"the pick shows at once even though it is not committed yet"
);
assert!(s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
s.run("BenillaOptionsFrameApplyButton:Click()").unwrap();
assert_eq!(
s.eval::<String>("return GetCVar(\"gxResolution\")")
.unwrap(),
"1280x720",
"Apply drives SetScreenResolution, which writes the size behind the 1-based index"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Modern Classic's two-entry Display Mode dropdown over 1.12's `gxWindow`, whose "1" is
/// windowed: the first entry, the borderless window, is "0".
#[test]
fn the_display_mode_dropdown_maps_its_entries_to_the_gx_window_polarity() {
benilla_formats::wow_data_or_skip!();
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"
);
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: there is no exclusive fullscreen, as in modern Classic.
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());
// `gxWindow` is latched like the other two video rows, so the pick stages and Apply
// commits; the dropdown still shows the chosen entry straight away.
s.run("DropDownList1Button2:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![],
"a deferred row stages rather than writing on the pick"
);
assert_eq!(
s.eval::<String>(&format!("return {ROW}DropdownText:GetText()"))
.unwrap(),
"Windowed",
"the pick is shown at once even though it is not committed yet"
);
s.run("BenillaOptionsFrameApplyButton:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("gxWindow".to_string(), "1".to_string())]
);
assert!(!s.eval::<bool>("return DropDownList1:IsVisible()").unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 1.12's Video Options checkbox 5 (`OptionsFrame.lua:9`). Deferred, because of its
/// `gxRestart = 1`: the present mode changes live.
#[test]
fn the_vertical_sync_row_reads_and_writes_the_present_mode_cvar() {
benilla_formats::wow_data_or_skip!();
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();
assert!(!s
.eval::<bool>(
"return BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:GetChecked()"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncLabel:GetText()"
)
.unwrap(),
"Vertical Sync"
);
let _ = s.take_cvar_changes();
// `gxVSync` is a **latched** CVar (`cvars.rs`: the reference registers it flags 3), so a
// write only stages host-side until `RestartGx`. The row is therefore deferred: the click
// stages, Apply commits. It used to write straight through, which wrote a value that never
// committed and so was never persisted either — vertical sync came back on after every
// relaunch, and with no row pending the Apply button never even appeared to press.
s.run("BenillaOptionsFrameContainerBodyGraphicsRowVerticalSyncCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![],
"a deferred row stages rather than writing on the click"
);
assert!(
s.eval::<bool>("return BenillaOptionsFrameApplyButton:IsShown()")
.unwrap(),
"and the Apply button appears, which is the only way to reach RestartGx"
);
s.run("BenillaOptionsFrameApplyButton:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("gxVSync".to_string(), "1".to_string())],
"Apply writes it"
);
// Defaults walks it back to the registered "1".
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());
}
/// Brightness is an API row over `GetGamma`/`SetGamma`, the reference's slider 6
/// (`OptionsFrame.lua:30`): a drag writes `gamma`, and a revisit reads it back.
#[test]
fn the_brightness_slider_writes_through_its_engine_pair_and_survives_a_reopen() {
benilla_formats::wow_data_or_skip!();
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 slider's travel.
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"
);
// `SetGamma` writes the reference's `1 - slider`, with six decimals.
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
);
}
// A revisit re-reads the pair, not the control; 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"
);
}
/// Environment Detail over `WorldDetail`: the readout names the stop, and an out-of-range value
/// shows the nearest one without being written back.
#[test]
fn the_world_detail_slider_writes_the_cvar_and_the_readout_names_its_stop() {
benilla_formats::wow_data_or_skip!();
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: three stops, one apart (`OptionsFrame.lua:27`).
assert!(s
.eval::<bool>(&format!(
"local lo, hi = {ROW}ControlSlider:GetMinMaxValues() \
return lo == 0 and hi == 2 and {ROW}ControlSlider:GetValueStep() == 1"
))
.unwrap());
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"
);
s.run(&format!("{ROW}ControlSlider:SetValue(0.6)")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {ROW}ControlValue:GetText()"))
.unwrap(),
"Medium"
);
// "-1" is what the clutter-density override seeds for clutter off.
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());
}
/// On the real manifest and CVar table: `UIParent_OnEvent` calls `UpdateNameplates()` on
/// `VARIABLES_LOADED` and `PLAYER_ENTERING_WORLD` (`UIParent.lua:234`, `:367`), and here its verbs
/// write the CVar pair that is the store. The setting survives only with both the host seeding
/// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` first and our `UpdateNameplates` without the stock
/// friendly show-then-hide (`UIOptionsFrame.lua:775-776`).
#[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);
// The client never loads the manifest without a player.
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();
// Both halves on, restored from `config.toml`: a host write, which queues nothing.
let plates = crate::vplates::VPlateMode {
enemies: true,
friends: true,
range_sq: 0.0,
occlusion: false,
critters: false,
spread: 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 control: with the globals nil, the entry writes both halves off.
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();
// With the globals seeded by the host, the entry changes nothing.
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());
// An addon that moves a global and calls the verb still writes the CVar.
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() {
benilla_formats::wow_data_or_skip!();
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());
// Both plates boot off, the reference's state: its plate bits start clear, and
// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` start nil (`UIOptionsFrame.lua:181-183`).
for row in ["RowEnemyPlates", "RowFriendlyPlates"] {
assert!(
!s.eval::<bool>(&format!(
"return BenillaOptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
))
.unwrap(),
"{row} defaults unchecked"
);
}
// The name rows at the reference's registered defaults: player "1", NPC and own "0".
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 CVars the V and Shift-V bindings write, so the window and the keys agree.
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();
// The interface panel's click kit (`PlayClickSound`, `OptionsFrame.lua:509-515`), 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 era's `CommitFlag.Apply`: a move snaps to 1.12's 0.01 grid and shows, Apply commits it,
/// and dragging back onto the committed value disarms Apply (`IsModified`).
#[test]
fn the_ui_scale_slider_defers_to_the_apply_button() {
benilla_formats::wow_data_or_skip!();
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"
);
// The era shows and enables Apply 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());
s.run("BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlSlider:SetValue(0.8)")
.unwrap();
assert!(s.take_cvar_changes().is_empty());
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());
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, benilla's own row: a percentage readout, deferred to Apply because a live write
/// would rebuild the world render target on every drag tick.
#[test]
fn the_render_scale_row_shows_a_percentage_and_defers_to_apply() {
benilla_formats::wow_data_or_skip!();
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 row offers 50% to 200%, inside the CVar's 0.25 to 4.0 clamp.
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());
// A `BENILLA_` description, as 1.12 has no string for the row.
assert!(s
.eval::<bool>("return BENILLA_TOOLTIP_RENDER_SCALE ~= nil")
.unwrap());
let _ = s.take_cvar_changes();
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());
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());
}
/// GFX (anisotropic): the row that was a hole. Three things it has to get right — the groove
/// reaches the CVar's own 16 and not the stock table's 4, `1` reads as "Off" and not as "1x", and
/// the write waits for Apply (committing re-samples every resident texture, which a drag would
/// pay on every step).
#[test]
fn the_anisotropic_row_reaches_sixteen_reads_off_at_one_and_defers_to_apply() {
benilla_formats::wow_data_or_skip!();
let mut s = audio_harness();
s.set_cvar_host("anisotropic", "1");
let mut s = harness_on(s);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
let row = "BenillaOptionsFrameContainerBodyGraphicsRowAnisotropic";
assert_eq!(
s.eval::<String>(&format!("return {row}Label:GetText()"))
.unwrap(),
"Anisotropic Filtering"
);
assert_eq!(
s.eval::<String>(&format!("return {row}ControlValue:GetText()"))
.unwrap(),
"Off",
"1 is the feature off, not its weakest stop"
);
// The CVar's own `ANISO_RANGE`, not `OptionsFrameSliders[7]`'s 1-4, which was the reference
// slider's ceiling and is below both the High and the Ultra rung.
assert!(s
.eval::<bool>(&format!(
"local lo, hi = {row}ControlSlider:GetMinMaxValues() \
return lo == 1 and hi == 16"
))
.unwrap());
assert!(s
.eval::<bool>("return BENILLA_TOOLTIP_ANISOTROPIC ~= nil")
.unwrap());
// The stock table stays verbatim for any addon that reads it.
assert!(s
.eval::<bool>("return OptionsFrameSliders[7].maxValue == 4")
.unwrap());
let _ = s.take_cvar_changes();
s.run(&format!("{row}ControlSlider:SetValue(16)")).unwrap();
assert_eq!(
s.eval::<String>(&format!("return {row}ControlValue:GetText()"))
.unwrap(),
"16x"
);
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());
s.run("BenillaOptionsFrameApplyButton:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("anisotropic".to_string(), "16".to_string())]
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Pending edits are panel-wide, as the era's modified table: a category switch keeps them, and
/// hiding the window discards them.
#[test]
fn a_pending_ui_scale_survives_the_page_switch_and_dies_on_hide() {
benilla_formats::wow_data_or_skip!();
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();
s.run("BenillaOptionsFrameCategoryListRowAudio:Click()")
.unwrap();
assert!(s
.eval::<bool>("return BenillaOptionsFrameApplyButton:IsVisible()")
.unwrap());
s.run("BenillaOptionsFrameCategoryListRowGraphics:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyGraphicsRowUiScaleControlValue:GetText()"
)
.unwrap(),
"70%"
);
// Hiding discards, with no confirm dialog (cut from the era window).
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());
}
/// A live row on 1.12's grid, 177 + n * 60 (`OptionsFrame.lua:26`), so 300 lands on 297.
#[test]
fn the_terrain_distance_slider_snaps_to_the_1_12_grid_and_writes_live() {
benilla_formats::wow_data_or_skip!();
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());
}
/// uiScale back to its registered 0.9, farclip to 350, and a pending uiScale edit dropped.
#[test]
fn defaults_resets_the_graphics_page_to_registered_defaults() {
benilla_formats::wow_data_or_skip!();
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();
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: the registered 350 is off the reference's 177 + n * 60 ladder
// (`OptionsFrame.lua:1-2`, `:26`), and `SetValue` snaps it on as 1.12's does; only our
// readout shows it.
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";
// MONKEY (volumetric fog): exercise translations and numeric writes through the real UI.
#[test]
fn volumetric_fog_dropdown_is_localised_and_live() {
benilla_formats::wow_data_or_skip!();
// MONKEY (volumetric fog): validate the authored table and row even on a
// data-free checkout; the complete window additionally needs stock FrameXML.
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml
.split("<Frame name=\"$parentRowVolumetricFog\"")
.nth(1)
.unwrap();
let on_load = row
.split("<OnLoad>")
.nth(1)
.unwrap()
.split("</OnLoad>")
.next()
.unwrap();
for (locale, title, labels) in [
("enUS", "Volumetric Fog", ["Off", "Low", "High"]),
("ruRU", "Объёмный туман", ["Выкл", "Низкое", "Высокое"]),
] {
let mut s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
s.run(strings).unwrap();
s.run(
r#"
self = {}
function OptionsRow_OnLoad(row, cvar, title, tip)
row.cvar, row.title, row.tip = cvar, title, tip
end
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
"#,
)
.unwrap();
s.run(on_load).unwrap();
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
assert!(s.eval::<bool>("return getglobal(self.tip) == BENILLA_ADVGFX.tips.VOLUMETRIC_FOG and string.len(getglobal(self.tip)) > 80").unwrap());
for (tier, label) in labels.iter().enumerate() {
assert_eq!(
s.eval::<String>(&format!("return self.choices[{}].text", tier + 1))
.unwrap(),
*label
);
let _ = s.take_cvar_changes();
s.run(&format!(
"SetCVar(self.cvar, self.choices[{}].value)",
tier + 1
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("volumetricFog".to_string(), tier.to_string())]
);
}
if benilla_formats::wow_data().is_none() {
continue;
}
// MONKEY (volumetric fog): with an install, also exercise the real widget kit.
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::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[5]")
.unwrap(),
"RowVolumetricFog"
);
assert_eq!(
s.eval::<String>(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()"))
.unwrap(),
title
);
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowVolumetricFogDropdownText:GetText()"
))
.unwrap(),
labels[1]
);
let _ = s.take_cvar_changes();
for (tier, label) in labels.iter().enumerate() {
s.run(&format!("OptionsRow_Set({ADVGFX}RowVolumetricFog, '{tier}') OptionsDropdown_ShowValue({ADVGFX}RowVolumetricFog)")).unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("volumetricFog".to_string(), tier.to_string())]
);
assert_eq!(
s.eval::<String>(&format!(
"return {ADVGFX}RowVolumetricFogDropdownText:GetText()"
))
.unwrap(),
*label
);
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
}
// MONKEY (ao): the row's strings, tooltip and numeric writes, both locales.
#[test]
fn ambient_occlusion_dropdown_is_localised_and_live() {
benilla_formats::wow_data_or_skip!();
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml
.split("<Frame name=\"$parentRowAmbientOcclusion\"")
.nth(1)
.unwrap();
let on_load = row
.split("<OnLoad>")
.nth(1)
.unwrap()
.split("</OnLoad>")
.next()
.unwrap();
for (locale, title, labels) in [
("enUS", "Ambient Occlusion", ["Off", "Low", "High"]),
("ruRU", "Затенение окружения", ["Выкл", "Низкое", "Высокое"]),
] {
let mut s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
s.run(strings).unwrap();
s.run(
r#"
self = {}
function OptionsRow_OnLoad(row, cvar, title, tip)
row.cvar, row.title, row.tip = cvar, title, tip
end
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
"#,
)
.unwrap();
s.run(on_load).unwrap();
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
assert!(s.eval::<bool>("return getglobal(self.tip) == BENILLA_ADVGFX.tips.AMBIENT_OCCLUSION and string.len(getglobal(self.tip)) > 80").unwrap());
// High first: the registered default is Off, and rewriting the same value is no change.
for (tier, label) in labels.iter().enumerate().rev() {
assert_eq!(
s.eval::<String>(&format!("return self.choices[{}].text", tier + 1))
.unwrap(),
*label
);
let _ = s.take_cvar_changes();
s.run(&format!(
"SetCVar(self.cvar, self.choices[{}].value)",
tier + 1
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("ambientOcclusion".to_string(), tier.to_string())]
);
}
}
}
// MONKEY (lampfog): the end-of-page quality row uses the same numeric tier contract as the pass.
#[test]
fn lamp_fog_dropdown_is_localised_and_live() {
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let strings = &xml[xml.find("BENILLA_ADVGFX_STRINGS = {").unwrap()
..xml.find("OPTIONS_PAGE_ROWS = {").unwrap()];
let row = xml
.split("<Frame name=\"$parentRowLampFog\"")
.nth(1)
.unwrap();
let on_load = row
.split("<OnLoad>")
.nth(1)
.unwrap()
.split("</OnLoad>")
.next()
.unwrap();
for (locale, title, labels) in [
("enUS", "Lamp Fog", ["Off", "Low", "High"]),
(
"ruRU",
"Свечение фонарей в тумане",
["Выкл", "Низкое", "Высокое"],
),
] {
let mut s = audio_harness();
s.run(&format!("function GetLocale() return '{locale}' end"))
.unwrap();
s.run(strings).unwrap();
s.run(
r#"
self = {}
function OptionsRow_OnLoad(row, cvar, title, tip)
row.cvar, row.title, row.tip = cvar, title, tip
end
function OptionsDropdownRow_Setup(row, choices) row.choices = choices end
"#,
)
.unwrap();
s.run(on_load).unwrap();
assert_eq!(s.eval::<String>("return self.title").unwrap(), title);
assert!(s
.eval::<bool>(
"return getglobal(self.tip) == BENILLA_ADVGFX.tips.LAMP_FOG and string.len(getglobal(self.tip)) > 80",
)
.unwrap());
// `lampFog` registers at Off, so seed away from the first choice before asserting that
// every selection produces the expected live write (the CVar layer suppresses no-ops).
s.run("SetCVar(self.cvar, '2')").unwrap();
let _ = s.take_cvar_changes();
for (tier, label) in labels.iter().enumerate() {
assert_eq!(
s.eval::<String>(&format!("return self.choices[{}].text", tier + 1))
.unwrap(),
*label
);
let _ = s.take_cvar_changes();
s.run(&format!(
"SetCVar(self.cvar, self.choices[{}].value)",
tier + 1
))
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("lampFog".to_string(), tier.to_string())]
);
}
}
}
#[test]
fn water_quality_writes_numeric_tiers_with_localised_labels() {
benilla_formats::wow_data_or_skip!();
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();
// MONKEY (volumetric fog): account for the atmosphere row after water.
// MONKEY (integration): Graphics Preset + Render Distance lead the page, then every programme row (sky, post, dither, fog, wet, wind, ao, lamp fog, window split, zone skyboxes): 33.
// MONKEY (fix-wind / volumetric light / moonlight): sway strength and the three light
// rows append after those: 33 -> 37.
assert_eq!(
s.eval::<usize>("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)")
.unwrap(),
37
);
assert_eq!(
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]")
.unwrap(),
"RowWaterQuality"
);
assert_eq!(
s.eval::<String>("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]")
.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() {
benilla_formats::wow_data_or_skip!();
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() {
benilla_formats::wow_data_or_skip!();
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() {
benilla_formats::wow_data_or_skip!();
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() {
benilla_formats::wow_data_or_skip!();
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() {
benilla_formats::wow_data_or_skip!();
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() {
benilla_formats::wow_data_or_skip!();
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() {
benilla_formats::wow_data_or_skip!();
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());
}
/// Sticky Targeting reads inverted, as 1.12 checks it on `deselectOnClick` "0"
/// (`UIOptionsFrame.lua:249-252`); the other flags read directly.
#[test]
fn the_controls_page_reads_flags_with_the_sticky_inversion() {
benilla_formats::wow_data_or_skip!();
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());
}
/// Checked plays CheckBoxOn (`PlayClickSound`, `OptionsFrame.lua:509-515`), and Sticky
/// Targeting writes inverted, as 1.12 flips it (`UIOptionsFrame.lua:337-343`).
#[test]
fn the_controls_checkboxes_write_flags_with_the_interface_panel_kit() {
benilla_formats::wow_data_or_skip!();
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();
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())));
s.run("BenillaOptionsFrameContainerBodyControlsRowStickyTargetCheck:Click()")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("deselectOnClick".to_string(), "0".to_string())]
);
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());
}
#[test]
fn defaults_resets_the_controls_page_to_registered_defaults() {
benilla_formats::wow_data_or_skip!();
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());
}
/// Run a few frames so the page body's fit converges: `OptionsScroll_Fit` reads the previous
/// frame's rects.
fn settle(s: &mut UiScript) {
for _ in 0..4 {
s.resolve();
s.tick(0.016);
}
s.resolve();
}
/// Results taller than the page grow the scroll body: the bar and its trough appear, and
/// everything past the fold is clipped to the page rect.
#[test]
fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
// Nameplates' six rows fit its area; Controls' sixteen overflow on their own.
s.run("BenillaOptionsFrameCategoryListRowNameplates:Click()")
.unwrap();
settle(&mut s);
// Control: a page that fits has no bar, and the body is exactly the page rect.
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"
);
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"
);
}
// Every content quad carries the page rect as its clip, so nothing draws past the page's
// bottom while the rects themselves reach far below it.
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
);
// Widget coordinates here, not the extract's pixels: 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)
);
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 trough, 1.12's `UI-Character-ScrollBar` channel, seats itself on the bar at the reference's
/// hang: 31 wide, 8 left of the 16-wide bar, overhanging it 21 above and 20 below, so each arrow
/// sits in the 16-tall socket the art carries for it.
#[test]
fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
benilla_formats::wow_data_or_skip!();
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 reference's hang: ReputationFrame.xml's trough at its scroll frame's +5/-4, against the
// template bar's -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"
);
// Off the arrows: 5 units of cap above the up arrow, 4 below the down arrow.
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"
);
// On this page the channel spans exactly the scroll frame, the bar taking the 21/20 inset.
let sf = "BenillaOptionsFrameContainerScroll";
assert!((g(trough, "GetTop") - g(sf, "GetTop")).abs() < 0.01);
assert!((g(trough, "GetBottom") - g(sf, "GetBottom")).abs() < 0.01);
// Three slices of the one 1.12 file, flush and at the channel's full width.
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());
}
// ── The row tooltips ─────────────────────────────────────────────────────────────
/// Hover a row's label half, 60 units in at mid-height. Callers pin `ERA_WINDOW_SCALE = 1` first,
/// so the row's rect and the pointer share coordinates.
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);
}
/// Scroll `frame` into the page's rect: past the fold a row is clipped and cannot be hovered.
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();
}
/// The row's key resolves at hover, so a string set after the window loaded still paints.
#[test]
fn a_hovered_row_raises_its_1_12_description_on_the_era_seat() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
// The stock tooltip sizes from its lines through the font engine, so reading its rect needs
// a measurer.
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"
);
// The era seat, `DefaultTooltipMixin`'s `ANCHOR_RIGHT` at x -10: BOTTOMLEFT on the label
// region's TOPRIGHT, 10 back.
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
);
// Onto the checkbox: the row's OnLeave and the box's OnEnter land in 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"
);
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());
}
/// Auto Loot has no 1.12 setting and so no string: its hover raises nothing, and puts away a
/// neighbour's plate left standing without an OnLeave.
#[test]
fn a_row_with_no_1_12_string_raises_no_plate() {
benilla_formats::wow_data_or_skip!();
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());
}
/// Every row's key resolves in the player's 1.12 `GlobalStrings.lua`, so a mistyped key cannot
/// silence a description.
#[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");
// Read off the live rows, so a new page is checked too.
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;
}
// Four rows with no fitting 1.12 string carry a `BENILLA_` one, each held to its row:
// Render Scale and Enable Sound in Background have no 1.12 setting, Display Mode's
// string describes a checkbox, and `OPTION_TOOLTIP_GAMMA` cites art this page lacks.
const BENILLA_OWNED: &[(&str, &str)] = &[
// MONKEY (volumetric fog): the row owns a translated tooltip too.
(
"BENILLA_TOOLTIP_VOLUMETRIC_FOG",
"AdvancedGraphicsRowVolumetricFog",
),
// MONKEY (sky): the sky tier row owns its translated tooltip.
(
"BENILLA_TOOLTIP_SKY_QUALITY",
"AdvancedGraphicsRowSkyQuality",
),
// MONKEY (ao): the contact-shadow row's translated tooltip.
(
"BENILLA_TOOLTIP_AMBIENT_OCCLUSION",
"AdvancedGraphicsRowAmbientOcclusion",
),
// MONKEY (lampfog): the quality row owns a translated tooltip too.
("BENILLA_TOOLTIP_LAMP_FOG", "AdvancedGraphicsRowLampFog"),
// MONKEY (integration): the daylight window split row.
(
"BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT",
"AdvancedGraphicsRowDaylightWindowSplit",
),
// MONKEY (skybox): the zone skybox row.
(
"BENILLA_TOOLTIP_ZONE_SKYBOXES",
"AdvancedGraphicsRowZoneSkyboxes",
),
// GFX (volumetric light) / (moonlight)
(
"BENILLA_TOOLTIP_VOLUMETRIC_LIGHT",
"AdvancedGraphicsRowVolumetricLight",
),
(
"BENILLA_TOOLTIP_VOLUMETRIC_LIGHT_STRENGTH",
"AdvancedGraphicsRowVolumetricLightStrength",
),
("BENILLA_TOOLTIP_MOONLIGHT", "AdvancedGraphicsRowMoonLight"),
(
"BENILLA_TOOLTIP_WATER_QUALITY",
"AdvancedGraphicsRowWaterQuality",
),
("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"),
("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"),
// GFX (anisotropic): ours, because 1.12's own text describes a four-stop slider
// that needs a restart and this row is neither.
("BENILLA_TOOLTIP_ANISOTROPIC", "GraphicsRowAnisotropic"),
("BENILLA_TOOLTIP_DISPLAY_MODE", "GraphicsRowDisplayMode"),
// 1.12 shipped the resolution dropdown with no tooltip at all, so there is no
// `OPTION_TOOLTIP_*` for it to borrow; benilla's own text explains the restart.
("BENILLA_TOOLTIP_RESOLUTION", "GraphicsRowResolution"),
(
"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",
),
// MONKEY (presets)
(
"BENILLA_TOOLTIP_GRAPHICS_QUALITY",
"AdvancedGraphicsRowGraphicsQuality",
),
(
"BENILLA_TOOLTIP_RENDER_DISTANCE",
"AdvancedGraphicsRowRenderDistance",
),
(
"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",
),
// MONKEY (daylight: terrain torch casters)
(
"BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS",
"AdvancedGraphicsRowTorchTerrainShadows",
),
("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",
),
// The post, dither, fog and wind programmes' rows: all benilla mechanisms with no
// 1.12 counterpart, so each carries its own key for the same reason as the rest of
// this page.
("BENILLA_TOOLTIP_BLOOM", "AdvancedGraphicsRowBloom"),
("BENILLA_TOOLTIP_SUN_SHAFTS", "AdvancedGraphicsRowSunShafts"),
(
"BENILLA_TOOLTIP_COLOR_GRADING",
"AdvancedGraphicsRowColorGrading",
),
("BENILLA_TOOLTIP_SKY_DITHER", "AdvancedGraphicsRowSkyDither"),
("BENILLA_TOOLTIP_FOG_MODEL", "AdvancedGraphicsRowFogModel"),
(
"BENILLA_TOOLTIP_RAIN_SURFACES",
"AdvancedGraphicsRowRainSurfaces",
),
(
"BENILLA_TOOLTIP_FOLIAGE_WIND",
"AdvancedGraphicsRowFoliageWind",
),
(
"BENILLA_TOOLTIP_FOLIAGE_WIND_STRENGTH",
"AdvancedGraphicsRowFoliageWindStrength",
),
];
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;
}
// 81 rows less the three untipped below; four of the 78 carry a `BENILLA_` key, and a dropdown
// row is checked on the key it wears at rest.
// MONKEY (graphics programme): the Advanced Graphics page's tipped rows (lighting, water,
// lava, fog, daylight, Graphics Preset, Render Distance, ...) add 20. 78 -> 98.
// MONKEY (post / dither / fog / wet / wind / volumetric light): their rows add 17, and the
// Graphics page's resolution dropdown one more. 98 -> 116.
// GFX (anisotropic): the Graphics page's own anisotropy slider, seated where the reference's
// slider table already declared it. 116 -> 117.
assert_eq!(checked, 117, "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)"
);
// Sticky Targeting's string, byte for byte off the 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."
);
}
/// Each row template needs its `$parentTip` seat: without it `SetOwner(nil, ...)` errors instead
/// of raising a plate.
#[test]
fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
// Stand-in strings for every key the rows name.
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()
);
}
// Every tipped row: the same count the key census above arrives at, by construction — a row
// with a key raises a plate and a row without one does not, which is what this asserts
// per-row; the total is here so a row losing its key is a failure rather than a smaller pass.
// MONKEY (graphics programme): the Advanced Graphics page's described rows add 19. 78 -> 97.
// MONKEY (post / dither / fog / wet / wind / volumetric light / resolution): 97 -> 116.
// GFX (anisotropic): the Graphics page's anisotropy slider. 116 -> 117.
assert_eq!(raised, 117, "every row but Auto Loot raises a description");
}
/// 1.12's AdvancedOptionsCombatText box as saved-global rows: a click writes the global, never a
/// CVar, and runs the family's apply, `CombatText_UpdateOrLoad`.
#[test]
fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
benilla_formats::wow_data_or_skip!();
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());
// Each box reads its global at `UIOptionsFrame_Init`'s value, the reference's default.
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"
);
}
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyCombatRowFloatModeDropdownText:GetText()"
)
.unwrap(),
"Scroll Up"
);
// The master first: it ships "0", which greys the other rows, and a greyed box eats its click.
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 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());
}
/// 1.12's rule (`UIOptionsFrame.lua:728-763`): the master off greys the floating-text rows and the
/// dropdown, and Combo Points stays greyed for all but rogues and druids.
#[test]
fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
benilla_formats::wow_data_or_skip!();
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()
};
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"));
// No player class in this VM, so Combo Points stays greyed while the rest wake.
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 restores each global to the value captured at its row's OnLoad, before the saved
/// chunk runs: `UIOptionsFrame_Init`'s, with no second copy in the window.
#[test]
fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
benilla_formats::wow_data_or_skip!();
let s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
// Four moved, both ways and both row kinds; the master first, as a greyed box eats its click.
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"
);
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());
}
/// A toggle is saved under its own name, and a fresh VM running the saved text comes up on it.
#[test]
fn what_the_combat_page_writes_survives_a_restart() {
benilla_formats::wow_data_or_skip!();
let s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
// The master first: it ships off, and a greyed box eats its click.
s.run("BenillaOptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
.unwrap();
s.run("BenillaOptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
.unwrap();
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
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 defaults, then the saved chunk over it.
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"
);
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 lock row's `LOCK_ACTIONBAR` write reaches the stock bar's drag guard in the same VM.
#[test]
fn the_action_bars_page_locks_the_real_bar() {
benilla_formats::wow_data_or_skip!();
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"
);
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 `UIOptionsFrame_Init`'s "0", 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());
}
/// Three saved-global rows with no apply: each consumer reads its global as it acts, so the write
/// alone changes behaviour.
#[test]
fn the_interface_page_writes_the_three_stock_globals() {
benilla_formats::wow_data_or_skip!();
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());
// `UIOptionsFrame_Init`'s values, but for our window's string "1" in `AUTO_QUEST_WATCH`.
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"
);
}
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"
);
// The questgiver's fade arm reads the global on each show (`QuestFrame.lua:85`).
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 picker is dead while the switch is off (`UIOptionsFrame.lua:697-701`), both rows only write
/// their globals, as 1.12's options do (`UIOptionsFrame.lua:65`), and the five entries are the
/// reference's, in its order.
#[test]
fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
// Off, at Always: `UIOptionsFrame_Init`'s two values (`UIOptionsFrame.lua:116-117`).
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"
);
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 == nil \
and BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetMode.applyFunc == nil"
)
.unwrap(),
"the frames' own OnUpdates re-decide the frame, as in 1.12"
);
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());
}
/// With a target's target up, the switch raises nothing and the frame follows on the next frame,
/// from `TargetFrame_OnUpdate` and `TargetofTarget_OnUpdate` (`TargetFrame.lua:492-501`). Run from
/// the row, `TargetofTarget_Update` refreshed the row's own debuffs (`RefreshBuffs(this, …)`,
/// `TargetFrame.lua:538`) and raised `BuffFrame.lua:281: attempt to index local 'debuffBorder'`.
#[test]
fn the_target_of_target_switch_leaves_the_frame_to_its_own_updates() {
use benilla_ui::script::UnitState;
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
let unit = |name: &str, guid: u64| UnitState {
exists: true,
is_connected: true,
name: Some(name.into()),
guid,
health: 80,
max_health: 100,
level: 60,
..UnitState::default()
};
s.set_unit("player", Some(unit("Tri", 0x100)));
s.set_unit("target", Some(unit("Kobold Miner", 0x200)));
s.set_unit("targettarget", Some(unit("Tri", 0x100)));
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
let shown = |s: &mut UiScript| {
s.eval::<bool>("return TargetofTargetFrame:IsShown() and true or false")
.unwrap()
};
s.run("BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetCheck:Click()")
.unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.tick(0.016);
assert!(
shown(&mut s),
"on: the target frame's OnUpdate brings it up"
);
s.run("BenillaOptionsFrameContainerBodyInterfaceRowTargetOfTargetCheck:Click()")
.unwrap();
s.tick(0.016);
assert!(!shown(&mut s), "off: its own OnUpdate takes it down");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Show Cloak and Show Helm are bits of the character's `PLAYER_FLAGS`, read and written through
/// 1.12's `func`/`setFunc` pair (`UIOptionsFrame.lua:18-19`). The getter follows the click before
/// the server answers: the wire verb is a blind flip, so a second click must see the new state.
#[test]
fn the_equipment_display_rows_read_and_write_through_the_api_not_a_store() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
// Both ship shown, the reference's hand-written default (`UIOptionsFrame.lua:631-634`).
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"
);
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"
);
// A descriptor edge that disagrees wins over the optimistic flip.
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' entry is a bare `{ index = 28 }` (`UIOptionsFrame.lua:37`): its store is the
/// special arms, `TutorialsEnabled()` to read and `ClearTutorials()`/`ResetTutorials()` to write.
#[test]
fn show_tutorials_reads_the_bank_and_writes_through_clear_and_reset() {
benilla_formats::wow_data_or_skip!();
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();
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 pushes 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"
);
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 (`UIOptionsFrame.lua:319`): writing the value the row already holds
/// sends nothing, so Defaults cannot re-arm dismissed tutorials.
#[test]
fn a_no_op_write_does_not_re_arm_the_tutorials() {
benilla_formats::wow_data_or_skip!();
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());
}
/// The setter is a set over a wire verb that toggles, so Defaults flips only the row that moved.
#[test]
fn defaults_sends_a_flip_only_for_the_row_that_moved() {
benilla_formats::wow_data_or_skip!();
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 restores what each global's definer assigned, captured at the row's OnLoad, so the
/// window keeps no second copy of a default.
#[test]
fn defaults_on_the_interface_page_restores_the_definers_own_assignment() {
benilla_formats::wow_data_or_skip!();
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 toggle is saved under the reference's global name, `AUTO_QUEST_WATCH`, and a fresh tree
/// replaying the text comes up on it.
#[test]
fn what_the_interface_page_writes_survives_a_restart() {
benilla_formats::wow_data_or_skip!();
let s = interface_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowInterface:Click()")
.unwrap();
s.run("BenillaOptionsFrameContainerBodyInterfaceRowAutoQuestWatchCheck:Click()")
.unwrap();
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
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()
);
}
/// The saved chunk runs after the whole UI has loaded, so a saved-on `SHOW_COMBAT_TEXT` takes
/// effect through the stock `VARIABLES_LOADED` ladder, which loads `Blizzard_CombatText` for it
/// (`UIOptionsFrame.lua:204-226`).
#[test]
fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
benilla_formats::wow_data_or_skip!();
let mut s = combat_harness();
// What the saved chunk does: assign over the default, then the event.
s.run("SHOW_COMBAT_TEXT = \"1\"").unwrap();
s.fire_event("VARIABLES_LOADED", vec![]);
// A frame passes before any message can arrive, and the stock placement reads the screen
// positions 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 row pitch is set once, by `BuffButtons_UpdatePositions` (`BuffFrame.lua:152-160`): the
/// second row hangs 15 below the first with timers and 5 without. The click re-runs it, and a
/// saved value needs the stock `VARIABLES_LOADED` arm (`UIOptionsFrame.lua:206`).
#[test]
fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
benilla_formats::wow_data_or_skip!();
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 chunk replaces the value, and `VARIABLES_LOADED` moves the bar to match.
let saved = String::from_utf8(s.saved_variables_bytes()).unwrap();
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 has rows but Keybindings and AddOns, which run their own pages, and each
/// row-backed category arms Defaults; a key with no rows behind it leaves Defaults dead.
#[test]
fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
benilla_formats::wow_data_or_skip!();
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 of the eleven, 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. The eleventh is
// AddOns, the era's own per-addon enable list, rebuilt on show from the AddOn API.
assert_eq!(
keys.len(),
11,
"the nine 1.15.9 categories + AdvancedGraphics + AddOns: {keys:?}"
);
for key in &keys {
// Keybindings and AddOns own their pages: Keybindings builds its search rows on show and
// AddOns builds one row per installed addon, so neither is a fixed OPTIONS_PAGE_ROWS list.
let owns_its_page = matches!(key.as_str(), "Keybindings" | "AddOns");
let has_rows = s
.eval::<bool>(&format!(
"return OPTIONS_PAGE_ROWS[\"{key}\"] ~= nil and \
getn(OPTIONS_PAGE_ROWS[\"{key}\"]) > 0"
))
.unwrap();
assert!(
has_rows || owns_its_page,
"{key} opens onto nothing — every category leads somewhere"
);
s.run(&format!("BenillaOptionsFrameCategoryListRow{key}:Click()"))
.unwrap();
// Defaults resets the CVars behind a page. The AddOns page holds none: an addon's enabled
// state is EnableAddOn/DisableAddOn, which the host persists to `AddOns.txt` at logout, so
// there is nothing there for Defaults to write back — the button stays dead on it.
if key == "AddOns" {
assert!(
!s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"AddOns resets nothing, so Defaults is dead there"
);
continue;
}
assert!(
s.eval::<bool>("return BenillaOptionsFrameContainerDefaults:IsEnabled() ~= 0")
.unwrap(),
"{key}: Defaults is live on a page that has something to reset"
);
}
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());
}
/// `spamFilter` registers "1" and the label says Disable: 1.12 checks the box on "0" and flips the
/// write (`UIOptionsFrame.lua:253-256`, `:337-343`), so it boots unchecked.
#[test]
fn the_disable_spam_filter_row_is_inverted() {
benilla_formats::wow_data_or_skip!();
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"));
s.run("BenillaOptionsFrameContainerBodyChatRowSpamFilterCheck:Click()")
.unwrap();
assert_eq!(s.cvar("spamFilter").as_deref(), Some("1"));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// 1.12 files Profanity Filter in the Display box, CheckButton5 under 66
/// (`UIOptionsFrame.xml:407-409`), not with chat, so the row is on the Interface page.
#[test]
fn the_profanity_filter_row_sits_on_the_interface_page_and_writes_its_cvar() {
benilla_formats::wow_data_or_skip!();
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() {
benilla_formats::wow_data_or_skip!();
// No host override: the page reads the registered pair, bubbles on and party bubbles off.
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());
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())));
// Say and yell bubbles off leaves party bubbles, which the client gates on their own CVar.
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: the reference's registered pair, `ChatBubbles` "1" and `ChatBubblesParty` "0".
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());
}
/// Remove Chat Hover Delay is a saved global whose apply moves two fade constants nothing re-reads;
/// Detailed Loot Information is a CVar the loot-roll composer reads per line, so it needs none.
#[test]
fn the_chat_page_writes_the_hover_delay_global_and_the_loot_spam_cvar() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowChat:Click()")
.unwrap();
let _ = s.take_sounds();
let _ = s.take_cvar_changes();
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"
);
// Its apply is the reference's `SetChatMouseOverDelay`, which zeroes both fade constants.
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 a one-way apply 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)
);
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());
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());
}
/// A saved "1" takes effect through the stock `VARIABLES_LOADED` arm (`UIOptionsFrame.lua:216`).
#[test]
fn a_saved_hover_delay_is_applied_when_the_variables_land() {
benilla_formats::wow_data_or_skip!();
let mut s = chat_harness();
// What the saved chunk does: assign the global, then the event.
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"
);
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());
}
/// The row's `cvarEvent` is 1.12's third `SetCVar` argument (`UIOptionsFrame.lua:345`), handed
/// back as `CVAR_UPDATE`'s arg1, so `TextStatusBar.lua:15` shows the numerals on the click.
#[test]
fn the_status_bar_text_row_pins_the_numerals_the_moment_it_is_clicked() {
benilla_formats::wow_data_or_skip!();
let mut s = interface_harness();
// A real span first: the bar's update hides the numerals while `valueMax` is 0.
s.set_player_xp(1000, 10000);
s.run("this = MainMenuExpBar; MainMenuExpBar_Update()")
.unwrap();
// Registered "0": the numerals show only on hover.
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 and no XP tick: only the queued `CVAR_UPDATE`.
s.tick(0.0);
assert!(
s.eval::<bool>("return MainMenuBarExpText:IsShown()")
.unwrap(),
"the watcher woke on the click, not on the next value change"
);
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());
}
/// 1.12's row (`UIOptionsFrame.lua:87`): 0.5 to 1.5 by 0.05, a multiplier; 1 reads 100%.
#[test]
fn the_mouse_sensitivity_slider_snaps_to_the_reference_step() {
benilla_formats::wow_data_or_skip!();
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();
assert!(s
.eval::<bool>(
"return math.abs(BenillaOptionsFrameContainerBodyControlsRowMouseSpeedControlSlider:GetValue() \
- 1.25) < 0.0001"
)
.unwrap());
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMouseSpeedControlValue:GetText()"
)
.unwrap(),
"125%"
);
s.run("BenillaOptionsFrameContainerBodyControlsRowMouseSpeedControlSlider:SetValue(1.42)")
.unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("mousespeed".to_string(), "1.4".to_string())]
);
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());
}
/// 1.12 stores a factor, 1.0 to 2.0 (`UIOptionsFrame.lua:90`), over `cameraDistanceMax`'s 15 yd
/// base: the CVar carries the factor and the readout the distance.
#[test]
fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
assert_eq!(
s.eval::<String>(
"return BenillaOptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
)
.unwrap(),
"15 yd",
"vanilla's own out-of-box ceiling"
);
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"
);
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());
}
/// 1.12's dropdown writes Smart 1, Always 2 and Never 0 for `cameraSmoothStyle`
/// (`UIOptionsFrame.lua:525,536,547`); the validator (`0x50c060`) also accepts 3, which the
/// terrain-tilt consumer (`0x50dbc0`) reads past its table. The entries carry those values in the
/// reference's order, a stray 3 reads Never, and the plate follows the selection.
#[test]
fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
benilla_formats::wow_data_or_skip!();
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowControls:Click()")
.unwrap();
// The registered default, Smart, is the reference's.
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"
);
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());
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"
);
s.run("BenillaOptionsFrameContainerBodyControlsRowCameraFollowStyleDropdownButton:Click()")
.unwrap();
s.run("DropDownList1Button2:Click()").unwrap();
assert_eq!(
s.take_cvar_changes(),
vec![("cameraSmoothStyle".to_string(), "2".to_string())]
);
// A stored "3" is in range but no entry's value: it shows as Never, not the nearest, Always.
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"
);
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 bar rows are API rows over the character's `PLAYER_FIELD_BYTES` byte 2, re-sent whole with
/// four arguments; Always Show ActionBars is a saved global, as `SetActionBarToggles` drops a
/// fifth (`0x4e770e`). The writes move the real bars in the same VM.
#[test]
fn the_action_bars_page_toggles_the_real_bars() {
benilla_formats::wow_data_or_skip!();
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();
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 off (`UIOptionsFrame.lua: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"
);
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"
);
// The managed stack rises too: `CONTAINER_OFFSET_Y`'s base 70 plus `bottomEither` 27
// (`UIParent.lua:1587`).
assert_eq!(s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap(), 97.0);
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"
);
// MultiBarLeft needs bar 3 as well as its own flag (`MultiActionBars.lua:73`).
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 a saved global: no packet, and its apply shows every bar's empty slots.
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"
);
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 ─────────────────────────────────────────────────────────────
/// A fixed-width measurer whose glyph advances round to whole physical pixels, as the app's do:
/// `round(6 * scale) / scale` units a character.
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)
}
}
/// The era's tab width, label + 40 (its `MinimalTab.lua:7`), fit on each show as 1.12's
/// `PanelTemplates_TabResize` is: `GetStringWidth` answers in the region's own units, and the
/// window's OnShow sets its 0.78 scale after every OnLoad has run.
#[test]
fn the_two_option_tabs_fit_their_labels_at_the_drawn_scale() {
benilla_formats::wow_data_or_skip!();
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"
);
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"
);
}
// At 0.78 a 6-unit glyph rasters at 5 px and reads back 5/0.78 units: "Game" is 25.64 and
// "AddOns" 38.46, where a fit at scale 1 would give tabs of 64 and 76.
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
));
// Frames run afterwards change nothing.
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, as the stock template does.
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());
}
/// With no measurer a label measures 0 and each tab is the bare 40; the app has seated
/// `AtlasMeasurer` before any window shows.
#[test]
fn without_a_seated_measurer_the_same_fit_reads_zero() {
benilla_formats::wow_data_or_skip!();
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 tables' CVar census ────────────────────────────────────────────
//
// Every CVar the stock option tables name (`UIOptionsFrame.lua:4-91`) is registered, or listed
// here with the feature it waits on: `SetCVar` on a name this client never registered stores
// nothing and reads back nil, so its box would offer a setting benilla does not have. A CVar is
// registered only once something reads it.
const UNBACKED_REFERENCE_CVARS: &[(&str, &str)] = &[
(
"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",
),
// No slider CVar may land here: `UIOptionsFrame_Load` hands `GetCVar` to `Slider:SetValue`
// (`UIOptionsFrame.lua:271-275`), which raises on nil (`0x790980`) and stops the stock
// window's load. A check button's `GetCVar(x) == "1"` is nil-safe.
];
/// Checked both ways: an unregistered name missing from the list fails, and so does a listed name
/// that is registered now.
#[test]
fn every_cvar_the_reference_table_names_is_registered_or_listed_with_its_blocker() {
benilla_formats::wow_data_or_skip!();
let s = harness();
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"
);
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:?}"
);
}
/// Every registered CVar the reference's tables name has a row on our window, or a reason on
/// `UNREACHABLE_REFERENCE_CVARS`: the stock windows load hidden, so ours is the only way in.
#[test]
fn every_registered_reference_cvar_has_a_row_on_our_own_window() {
benilla_formats::wow_data_or_skip!();
let s = harness();
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 rows move: each row's `cvar`, and the `partner` a player's control writes with it.
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();
// Floors: unloaded tables or an unbuilt window would read nothing and pass.
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"
);
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:?}"
);
}
/// Registered reference-table CVars with no row on our window, each with what the player cannot
/// do and why; empty, as every one has a row.
const UNREACHABLE_REFERENCE_CVARS: &[(&str, &str)] = &[];
#[test]
fn the_four_camera_toggles_read_their_shipped_defaults_and_write_on_the_click() {
benilla_formats::wow_data_or_skip!();
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 reference's registered values: pivot and water collision on, 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"));
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());
}
/// Two rows also write a CVar neither table names, as `UIOptionsFrame_Save`'s special arms do:
/// `PetSpellDamage` (`UIOptionsFrame.lua:334-336`) and `cameraPitchMoveSpeed` (`:355-356`).
#[test]
fn the_pet_damage_box_and_the_look_slider_each_write_their_unnamed_twin() {
benilla_formats::wow_data_or_skip!();
let mut s = combat_harness();
s.run("ShowUIPanel(BenillaOptionsFrame)").unwrap();
s.run("BenillaOptionsFrameCategoryListRowCombat:Click()")
.unwrap();
let _ = s.take_cvar_changes();
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"
);
// The pitch twin at half the yaw, the ratio of their registered 180 and 90.
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());
}
/// Two separate masters, as the reference keeps them: CheckButton19 greys 9 and 11, and 52 greys
/// twelve boxes from 53 to 69 and the dropdown (`UIOptionsFrame.lua:710-716`, `:728-756`).
#[test]
fn show_target_damage_greys_its_own_pair_and_the_floating_text_master_leaves_it_alone() {
benilla_formats::wow_data_or_skip!();
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": the floating-text rows arrive greyed, the damage trio live.
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"));
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 reference's rules: Player Guild Names is dead while player names are off
/// (`UIOptionsFrame.lua:703-709`), Auto-Follow Speed while the style is Never (`:717-721`).
#[test]
fn the_guild_line_greys_with_player_names_and_the_follow_speed_with_the_style() {
benilla_formats::wow_data_or_skip!();
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"
);
// The groove also takes no press: ours turns its mouse off, where the stock disable only
// hides the thumb (`OptionsFrame.lua:481-487`).
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());
}
/// MONKEY (reviewfix-a): the Advanced Graphics farclip slider's range is `view::FARCLIP_RANGE`,
/// and its 60-yd grid lands exactly on the maximum (the 1.12 grid the page advertises).
#[test]
fn farclip_slider_range_is_the_view_range() {
let xml = include_str!("../../assets/ui/OptionsFrame.xml");
let row = xml
.split("<Frame name=\"$parentRowRenderDistance\"")
.nth(1)
.expect("the farclip row");
let args = row
.split("OptionsSliderRow_Setup(self, ")
.nth(1)
.and_then(|rest| rest.split(')').next())
.expect("the slider setup call");
let nums: Vec<f32> = args
.split(',')
.take(3)
.map(|v| v.trim().parse().expect("numeric slider bound"))
.collect();
let range = benilla_world::view::FARCLIP_RANGE;
assert_eq!(nums[0], *range.start(), "slider min");
assert_eq!(nums[1], benilla_world::view::FARCLIP_MAX, "slider max");
assert_eq!(nums[1], *range.end());
assert_eq!(
(nums[1] - nums[0]) % nums[2],
0.0,
"the step lands on the maximum"
);
}