settings boot at 1.12's defaults, and a cold loot no longer errors
- a setting's default is the reference's, enforced: eight player-facing options booted at something a stock client does not — `UnitNameNPC`, `UnitNameOwn`, `nameplateShowEnemies`, `ChatBubblesParty`, `cameraDistanceMaxFactor`, `QUEST_FADING_DISABLE`, `SHOW_BUFF_DURATIONS`, `SHOW_COMBAT_TEXT` — each a defensible "turn this on so I can see it while you build it" on its day and invisible as a set; the CVar table grows a mandatory `Reference` column (Same / Overridden / Deviates-with-reason) that a test checks in both directions, the FrameXML saved-variable store is compared against the reference's own `UIOptionsFrame.lua` off the chain (which found `SHOW_COMBAT_TEXT`, declared inside a table walked by a loop where a grep could not see it), and the wow-re census of all 214 `CVar::Register` sites folded in — `WorldDetail` back to `same`, since `GetWorldDetail` reads only `SmallCull` and the reference's own slider reads Medium at boot - looting on a cold item cache raised `LootFrame.lua:85: attempt to index local 'color'` out of the stock window: `GetLootSlotInfo` answered nil where the reference answers cache-miss SENTINELS — quality −1 (which is exactly why stock `UIParent.lua` builds `ITEM_QUALITY_COLORS` over `for i = -1, 6`), "" for a name in flight, `INV_Misc_QuestionMark` for a display-info miss — and four values on every leg (`mov eax,4` at `0x4c2d07` is unconditional); `GetLootRollItemInfo` takes its own miss tail (`nil, nil, 1.0, 1.0, nil`), `GetItemQualityColor(-1)` reads Common because the compare is unsigned, and the window now WAITS for every item template before opening as `0x4c1cb0` does — there is no repaint path in 1.12 to fall back on, so deferring IS the mechanism - the coin row is quality 0 (grey), not white — `0x4c23a0`'s coin leg falls to `xor eax,eax` before the item-cache read — and its icon walks the reference's six-step ladder (`0x6c6307`: one icon per decade of copper; two of the six were not in our set at all, and the pairs do not fall where "highest nonzero denomination" put them) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -37,9 +37,11 @@ fn harness() -> UiScript {
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
|
||||
// The timer switch, seeded BEFORE the bar loads. 1.12 declares it in UIOptionsFrame.lua
|
||||
// (default "0"); we have no counterpart to that file, so it lives with the row that drives it
|
||||
// (OptionsFrame.xml) and ships as "1" — the director's call, 0255/1139. Order matters: the
|
||||
// The timer switch, PLANTED ON — not the shipped value. 1.12 declares it in
|
||||
// UIOptionsFrame.lua (default "0"); we have no counterpart to that file, so it lives with the
|
||||
// row that drives it (OptionsFrame.xml), where it shipped "1" from 0255/1139 until 1804 put
|
||||
// it back on the reference's "0". These tests are about the timer text and the geometry it
|
||||
// buys, so the harness turns it on the way the Interface page's row does. Order matters: the
|
||||
// reference's `BuffFrame_OnLoad` calls `BuffButtons_UpdatePositions`, which seats the debuff
|
||||
// row 20px differently depending on this value, so setting it afterwards leaves the bar laid
|
||||
// out for the wrong one.
|
||||
@@ -436,10 +438,11 @@ fn a_refreshed_duration_reaches_the_bar_with_no_aura_event() {
|
||||
|
||||
/// **`SHOW_BUFF_DURATIONS`** (decision 1139) — 0255 shipped the durations-shown geometry with no
|
||||
/// switch because there was no panel to hang one on, and said the other branch was already there
|
||||
/// waiting. This is it: the global (ours "1", the reference's "0") hides the timer text and closes
|
||||
/// the 15px gutter each row leaves for it, down to the 5px the columns use. What it does NOT touch
|
||||
/// is the warning flash — with the numbers gone, the pulse is the only thing left saying an aura is
|
||||
/// about to drop, and the reference pulses from `BuffButton_OnUpdate` regardless of the setting.
|
||||
/// waiting. This is it: the global — the reference's "0" since 1804, planted "1" by this file's
|
||||
/// harness — hides the timer text and closes the 15px gutter each row leaves for it, down to the
|
||||
/// 5px the columns use. What it does NOT touch is the warning flash — with the numbers gone, the
|
||||
/// pulse is the only thing left saying an aura is about to drop, and the reference pulses from
|
||||
/// `BuffButton_OnUpdate` regardless of the setting.
|
||||
#[test]
|
||||
fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
@@ -458,7 +461,10 @@ fn the_duration_switch_hides_the_timers_and_closes_their_gutter() {
|
||||
)],
|
||||
);
|
||||
frame(&mut s, 0.1);
|
||||
assert!(shown(&s, "BuffButton0Duration"), "timers on by default");
|
||||
assert!(
|
||||
shown(&s, "BuffButton0Duration"),
|
||||
"timers on — the harness planted the switch"
|
||||
);
|
||||
assert_eq!(text(&s, "BuffButton0Duration"), "19 s");
|
||||
s.resolve();
|
||||
let shown_gap = s
|
||||
|
||||
@@ -6,12 +6,23 @@ use benilla_ui::script::{ScriptValue, UiScript, UnitState};
|
||||
|
||||
use super::test_ui::load_ui as load_xml;
|
||||
|
||||
/// The window loaded **with the feature switched on** — which is not how it ships.
|
||||
///
|
||||
/// `SHOW_COMBAT_TEXT` boots at the reference's `"0"` since 1804 (it was `"1"` from 0578, when the
|
||||
/// director's ask for scrolling combat text was read as the shipped experience). The master is
|
||||
/// enforced at the source: `CombatText_UpdateDisplayedMessages` registers no events at all while
|
||||
/// it is off, so a fresh VM answers every question below with "nothing happened". These tests are
|
||||
/// about what the feature *does* once a player ticks the Combat page's box, so the harness ticks
|
||||
/// it for them exactly the way that row does — assign the global, then re-run the family's
|
||||
/// applyFunc. The gating itself is [`combat_text_master_toggle_unregisters`]'s subject, not theirs.
|
||||
fn load_combat_text() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Fonts.xml");
|
||||
load_xml(&s, "UIParent.xml");
|
||||
load_xml(&s, "CombatText.xml");
|
||||
s.run("SHOW_COMBAT_TEXT = \"1\"; CombatText_UpdateDisplayedMessages()")
|
||||
.unwrap();
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
s
|
||||
@@ -245,13 +256,31 @@ fn combat_text_crit_peak_is_uncapped() {
|
||||
}
|
||||
|
||||
/// The master toggle: SHOW_COMBAT_TEXT = "0" + CombatText_UpdateDisplayedMessages unregisters
|
||||
/// everything — a subsequent damage event paints nothing (ref-identical gating; our default is
|
||||
/// "1", the ref ships "0" — decision 0578's named divergence).
|
||||
/// everything — a subsequent damage event paints nothing (ref-identical gating). Since 1804 that
|
||||
/// "0" is also what a fresh client ships (it was 0578's named divergence, `"1"`, until then), so
|
||||
/// the test walks the switch **down from the harness's planted ON**: it fires a damage message
|
||||
/// first and watches it paint, because "nothing painted" only means the toggle worked if
|
||||
/// something would otherwise have.
|
||||
#[test]
|
||||
fn combat_text_master_toggle_unregisters() {
|
||||
let mut s = load_combat_text();
|
||||
s.fire_event(
|
||||
"COMBAT_TEXT_UPDATE",
|
||||
vec![
|
||||
ScriptValue::Str("DAMAGE".into()),
|
||||
ScriptValue::Str("17".into()),
|
||||
],
|
||||
);
|
||||
let painted: bool = s
|
||||
.eval("return getn(COMBAT_TEXT_TO_ANIMATE) == 1 and CombatText1:IsShown() ~= nil")
|
||||
.unwrap();
|
||||
assert!(painted, "enabled: the message paints ({:?})", s.errors());
|
||||
|
||||
// The switch, then the file's own list clear — the message already in flight belongs to the
|
||||
// planted ON state, and the claim under test is about what arrives AFTER the gate closes.
|
||||
s.run("SHOW_COMBAT_TEXT = \"0\"; CombatText_UpdateDisplayedMessages()")
|
||||
.unwrap();
|
||||
s.run("CombatText_ClearAnimationList()").unwrap();
|
||||
s.fire_event(
|
||||
"COMBAT_TEXT_UPDATE",
|
||||
vec![
|
||||
|
||||
@@ -650,3 +650,55 @@ fn the_roll_template_carries_the_reference_name_and_the_parts_addons_reach_for()
|
||||
.eval::<bool>(r#"return getglobal("BenillaGroupLootFrameTemplate") == nil"#)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// The **stock** `GroupLootFrame_OnShow` over an in-flight roll — the same defect 1805 fixed one
|
||||
/// window over, proven at the file that has not been migrated yet.
|
||||
///
|
||||
/// `GroupLootFrame1..4`, their template and `GroupLootFrame_OnShow` all live in the stock
|
||||
/// `LootFrame.xml`/`.lua`, which has been on the player's chain since 1800; our `GroupLootFrame.xml`
|
||||
/// loads after it and shadows the lot. So the stock body is dormant, not absent — and it does
|
||||
/// `local color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:275`) then `color.r` (`:276`) off
|
||||
/// `GetLootRollItemInfo`, with none of the `or ITEM_QUALITY_COLORS[1]` our copy carries.
|
||||
///
|
||||
/// This test loads the stock file WITHOUT ours, so the dormant body runs. It is the guard that stops
|
||||
/// the eventual GroupLootFrame migration from shipping the loot bug a second time.
|
||||
#[test]
|
||||
fn the_stock_group_loot_frame_survives_an_in_flight_roll() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in super::test_ui::LOOT_UI {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
|
||||
s.set_loot_rolls(rolls());
|
||||
|
||||
// Roll 9 is the in-flight one; roll 99 does not exist at all (a stale id from a torn-down
|
||||
// frame). Both take the reference's miss tail, so both must paint rather than raise.
|
||||
for roll in [9, 99] {
|
||||
s.run(&format!("GroupLootFrame_OpenNewFrame({roll}, 55000)"))
|
||||
.unwrap_or_else(|e| panic!("stock OpenNewFrame raised on roll {roll}: {e}"));
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"stock GroupLootFrame_OnShow raised on roll {roll}: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
}
|
||||
assert!(s
|
||||
.eval::<bool>("return GroupLootFrame1:IsVisible() and GroupLootFrame2:IsVisible()")
|
||||
.unwrap());
|
||||
|
||||
// …and the colour it painted is the miss tail's Common, read back off the FontString that
|
||||
// `:276` set — not a nil, and not the Epic of the resolved roll sitting beside it.
|
||||
let painted: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = GroupLootFrame1Name:GetTextColor()\nreturn r, g, b")
|
||||
.unwrap();
|
||||
let common: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = GetItemQualityColor(1)\nreturn r, g, b")
|
||||
.unwrap();
|
||||
assert!(
|
||||
(painted.0 - common.0).abs() < 1e-6
|
||||
&& (painted.1 - common.1).abs() < 1e-6
|
||||
&& (painted.2 - common.2).abs() < 1e-6,
|
||||
"the stock roll popup paints the miss tail's Common, got {painted:?} want {common:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1072,3 +1072,122 @@ fn a_row_click_takes_rather_than_continues() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **The state every loot on a cold item cache opens in** — the rows are on the wire, the item
|
||||
/// templates are not back yet — driven through the STOCK `LootFrame.lua`. Decision 1805.
|
||||
///
|
||||
/// The hole this closes: every loot fixture in this file (and in `benilla-ui`'s own) handed the
|
||||
/// window fully-resolved rows, so nothing in the suite ever ran `LootFrame_Update` over a row whose
|
||||
/// template was still in flight. Our deleted `LootFrame.xml` could not have noticed either — it
|
||||
/// carried an `ITEM_QUALITY_COLORS[quality] or ITEM_QUALITY_COLORS[1]` guard, the way
|
||||
/// `GroupLootFrame.xml` and `AuctionFrame.xml` still do. The stock file has no guard: it does
|
||||
/// `color = ITEM_QUALITY_COLORS[quality]` (`LootFrame.lua:82`) and dereferences `color.r` on the
|
||||
/// next line, so a nil quality raised
|
||||
/// `LootFrame.lua:85: attempt to index local 'color' (a nil value)` out of `LootFrame_OnShow` —
|
||||
/// on the director's screen, on an ordinary corpse.
|
||||
///
|
||||
/// The window must therefore come up clean, paint the row, and paint it in the cache-miss colour —
|
||||
/// `ITEM_QUALITY_COLORS[-1]`, the row `UIParent.lua`'s `for i = -1, 6` exists to provide.
|
||||
#[test]
|
||||
fn loot_row_awaiting_its_template_opens_clean() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
for f in super::test_ui::LOOT_UI {
|
||||
load_xml(&s, f);
|
||||
}
|
||||
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
|
||||
|
||||
// One row, in flight: the wire gave us the icon (it rides the display id) and the stack size,
|
||||
// and nothing else yet.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
item_id: 2582,
|
||||
name: None,
|
||||
texture: Some("Interface\\Icons\\INV_Gauntlets_17".into()),
|
||||
quantity: 1,
|
||||
quality: None,
|
||||
is_coin: false,
|
||||
link: None,
|
||||
random_property_id: 0,
|
||||
})],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(
|
||||
s.errors().is_empty(),
|
||||
"an in-flight loot row must not raise in LootFrame_Update: {:?}",
|
||||
s.errors()
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return LootFrame:IsVisible() and LootButton1:IsVisible()")
|
||||
.unwrap(),
|
||||
"the window opened and drew the row"
|
||||
);
|
||||
|
||||
// The binding answered the reference's sentinels rather than nils, and the quality one indexes
|
||||
// `ITEM_QUALITY_COLORS` — which is the whole of what `LootFrame.lua:82` needs to be true.
|
||||
let (item, quantity, quality) = s
|
||||
.eval::<(String, i64, i64)>("local _, i, n, q = GetLootSlotInfo(1)\nreturn i, n, q")
|
||||
.unwrap();
|
||||
assert_eq!((item.as_str(), quantity, quality), ("", 1, -1));
|
||||
assert!(
|
||||
s.eval::<bool>("return ITEM_QUALITY_COLORS[select(4, GetLootSlotInfo(1))] ~= nil")
|
||||
.unwrap(),
|
||||
"the cache-miss quality must be a real row of ITEM_QUALITY_COLORS"
|
||||
);
|
||||
|
||||
// And line 85 — the line that raised — actually ran: the row text wears the cache-miss colour,
|
||||
// read back off the FontString rather than off the table. `ITEM_QUALITY_COLORS[-1]` is Common
|
||||
// (`GetItemQualityColor`'s clamp is unsigned, so -1 takes the same branch as 7-and-up), not the
|
||||
// Poor it used to be here — 1805 corrects 1199 on that.
|
||||
let painted: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = LootButton1Text:GetTextColor()\nreturn r, g, b")
|
||||
.unwrap();
|
||||
let miss: (f64, f64, f64) = s
|
||||
.eval("local r, g, b = GetItemQualityColor(-1)\nreturn r, g, b")
|
||||
.unwrap();
|
||||
assert_eq!(miss, (1.0, 1.0, 1.0), "the cache-miss row is Common/white");
|
||||
// (f32 region storage vs the f64 the binding computes — compare, don't equate.)
|
||||
assert!(
|
||||
(painted.0 - miss.0).abs() < 1e-6
|
||||
&& (painted.1 - miss.1).abs() < 1e-6
|
||||
&& (painted.2 - miss.2).abs() < 1e-6,
|
||||
"LootFrame_Update:85 must paint the -1 colour, got {painted:?} want {miss:?}"
|
||||
);
|
||||
|
||||
// The template lands and the window is reopened: the row reads its name in its own quality's
|
||||
// colour, so the sentinel state poisons nothing downstream.
|
||||
//
|
||||
// It is a REOPEN and not a repaint on purpose. The app fires `LOOT_UPDATE` when an open
|
||||
// window's content changes, and `LOOT_UPDATE` is not a 1.12 event — it appears nowhere in the
|
||||
// reference's FrameXML and `LootFrame_OnLoad` does not register it — so nothing in the stock
|
||||
// file listens. What the real client does when a template arrives mid-window is out at wow-re
|
||||
// (the arrival callback `0x4c2ac0` is recorded as clearing the pending flag and nothing else);
|
||||
// this test deliberately asserts only what is settled.
|
||||
s.fire_event("LOOT_CLOSED", vec![]);
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
item_id: 2582,
|
||||
name: Some("Thin Cloth Gloves".into()),
|
||||
texture: Some("Interface\\Icons\\INV_Gauntlets_17".into()),
|
||||
quantity: 1,
|
||||
quality: Some(1),
|
||||
is_coin: false,
|
||||
link: None,
|
||||
random_property_id: 0,
|
||||
})],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
s.resolve();
|
||||
let white = text_color(&s.extract(), "Thin Cloth Gloves").expect("the resolved name renders");
|
||||
assert!(
|
||||
(white[0] - 1.0).abs() < 0.02
|
||||
&& (white[1] - 1.0).abs() < 0.02
|
||||
&& (white[2] - 1.0).abs() < 0.02,
|
||||
"the resolved row is white, got {white:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -670,7 +670,7 @@ fn a_slider_rows_readout_sits_on_its_labels_line() {
|
||||
/// the app's boot order (register → seed → load → select).
|
||||
fn audio_harness() -> UiScript {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s
|
||||
}
|
||||
|
||||
@@ -1219,30 +1219,34 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
|
||||
assert!(s
|
||||
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
// The plate pair leads the page and rides `VPlateMode::default()`: enemy on (the 0167 boot
|
||||
// divergence), friendly off (faithful, 0599).
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyNameplatesRowEnemyPlatesCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Enemy Name Plates defaults checked"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyNameplatesRowFriendlyPlatesCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Friendly Name Plates defaults unchecked"
|
||||
);
|
||||
// All three name rows ride their registered "1" defaults.
|
||||
for row in ["RowPlayerNames", "RowNpcNames", "RowOwnName"] {
|
||||
// The plate pair leads the page and rides `VPlateMode::default()`: since 1804 both are OFF,
|
||||
// which is the reference's own boot state (its bitmask starts clear and FrameXML's
|
||||
// `NAMEPLATES_ON`/`FRIENDNAMEPLATES_ON` start nil). Enemy was 0167's pin until then.
|
||||
for row in ["RowEnemyPlates", "RowFriendlyPlates"] {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!(
|
||||
!s.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
|
||||
))
|
||||
.unwrap(),
|
||||
"{row} defaults checked"
|
||||
"{row} defaults unchecked"
|
||||
);
|
||||
}
|
||||
// The name rows ride their registered defaults, which are the binary's: player "1", NPC and
|
||||
// own "0" (1804 — the last two were director pins from 2026-07-12 until then).
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyNameplatesRowPlayerNamesCheck:GetChecked()"
|
||||
)
|
||||
.unwrap(),
|
||||
"Player Names defaults checked"
|
||||
);
|
||||
for row in ["RowNpcNames", "RowOwnName"] {
|
||||
assert!(
|
||||
!s.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodyNameplates{row}Check:GetChecked()"
|
||||
))
|
||||
.unwrap(),
|
||||
"{row} defaults unchecked"
|
||||
);
|
||||
}
|
||||
let _ = s.take_sounds();
|
||||
@@ -1259,12 +1263,22 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![(crate::vplates::CVAR_ENEMIES.to_string(), "0".to_string())]
|
||||
vec![(crate::vplates::CVAR_ENEMIES.to_string(), "1".to_string())]
|
||||
);
|
||||
let _ = s.take_sounds();
|
||||
|
||||
// Unchecking NPC Names queues the flag off and plays the OFF kit (no quirk here — the
|
||||
// 1.12 interface panel's PlayClickSound mapping, not the sound panel's inverted one).
|
||||
// Checking NPC Names queues the flag on and plays the ON kit; unchecking plays OFF (no quirk
|
||||
// here — the 1.12 interface panel's PlayClickSound mapping, not the sound panel's inverted
|
||||
// one).
|
||||
s.run("OptionsFrameContainerBodyNameplatesRowNpcNamesCheck: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("OptionsFrameContainerBodyNameplatesRowNpcNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -1274,12 +1288,6 @@ fn the_nameplates_page_toggles_the_unit_name_cvars() {
|
||||
assert!(s
|
||||
.take_sounds()
|
||||
.contains(&SoundRequest::KitName("igMainMenuOptionCheckBoxOff".into())));
|
||||
s.run("OptionsFrameContainerBodyNameplatesRowNpcNamesCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("UnitNameNPC".to_string(), "1".to_string())]
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -2281,9 +2289,10 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
|
||||
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
|
||||
// Read: each box shows its global, at CombatText.xml's own file-scope value.
|
||||
// Read: each box shows its global, at CombatText.xml's own file-scope value — the whole
|
||||
// family at the reference's own defaults since 1804, master included (it was "1" from 0578).
|
||||
for (row, checked) in [
|
||||
("RowCombatText", true),
|
||||
("RowCombatText", false),
|
||||
("RowAuras", true),
|
||||
("RowAuraFade", false),
|
||||
("RowDodgeParryMiss", false),
|
||||
@@ -2307,7 +2316,20 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
|
||||
);
|
||||
|
||||
// Write: the global moves, the CVar table is untouched, and the applyFunc lands.
|
||||
//
|
||||
// The master goes first, and it is a write under test in its own right — but it is also the
|
||||
// gate: every other row on this page is greyed while `SHOW_COMBAT_TEXT` is "0" (the shipped
|
||||
// state since 1804; the dependency rule is pinned next door in
|
||||
// `the_combat_master_greys_the_family_and_combo_points_is_class_gated`), and a greyed control
|
||||
// eats its click. So the two clicks below only mean anything with this one ahead of them.
|
||||
let _ = s.take_cvar_changes();
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"1",
|
||||
"the master writes its own global"
|
||||
);
|
||||
s.run("OptionsFrameContainerBodyCombatRowDodgeParryMissCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -2347,6 +2369,10 @@ fn the_combat_page_writes_saved_variable_globals_and_applies_them() {
|
||||
/// The master's dependency rule, 1.12's verbatim: `SHOW_COMBAT_TEXT` off greys **every** other
|
||||
/// row on the page including the dropdown, and back on wakes them — except Combo Points, which
|
||||
/// carries the reference's second gate and stays greyed for anyone who is not a rogue or druid.
|
||||
///
|
||||
/// Since 1804 the page **opens** in the greyed state, because the master ships at the reference's
|
||||
/// "0" (0578's `"1"` was the pin). So the walk here is off → on → off, and the sub-rows' first
|
||||
/// wake is the master's own click rather than the load: the same rule, entered from the other end.
|
||||
#[test]
|
||||
fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
let mut s = combat_harness();
|
||||
@@ -2359,8 +2385,18 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
// No player class in this VM, so Combo Points is greyed from the start while its siblings
|
||||
// The shipped state: the master is off, so the whole family is greyed on arrival — and the
|
||||
// master itself is not, because it is the way in.
|
||||
assert_eq!(s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(), "0");
|
||||
assert!(!enabled(&mut s, "RowAuras", "Check"));
|
||||
assert!(!enabled(&mut s, "RowFloatMode", "DropdownButton"));
|
||||
assert!(enabled(&mut s, "RowCombatText", "Check"));
|
||||
|
||||
// On: the siblings wake. No player class in this VM, so Combo Points stays greyed while they
|
||||
// are live — the two gates are independent.
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck: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"));
|
||||
@@ -2380,14 +2416,9 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
enabled(&mut s, "RowCombatText", "Check"),
|
||||
"the master itself stays live — it is the way back"
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert!(enabled(&mut s, "RowAuras", "Check"));
|
||||
assert!(enabled(&mut s, "RowFloatMode", "DropdownButton"));
|
||||
assert!(
|
||||
!enabled(&mut s, "RowComboPoints", "Check"),
|
||||
"the class gate outlives the master's return"
|
||||
"the class gate outlives the master's departure too"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
@@ -2395,20 +2426,26 @@ fn the_combat_master_greys_the_family_and_combo_points_is_class_gated() {
|
||||
/// Defaults on a saved-variable page restores each global to **the value its own XML assigned at
|
||||
/// file scope** — captured at the row's OnLoad, which the load order guarantees runs before the
|
||||
/// saved chunk (1128). The panel restates no defaults of its own, so it cannot drift from
|
||||
/// CombatText.xml the way the reference's hand-copied `default` field can.
|
||||
/// CombatText.xml the way the reference's hand-copied `default` field can. Since 1804 that
|
||||
/// assignment is the reference's own value for all fourteen, so this is now also the check that
|
||||
/// Defaults lands a player back on a stock 1.12 combat-text family.
|
||||
#[test]
|
||||
fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
|
||||
let s = combat_harness();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
|
||||
// Move three of them off their shipped values, in both directions and both flavors.
|
||||
// Move FOUR of them off their shipped values, in both directions and both flavors. The master
|
||||
// leads because the page ships with it off (1804) and a greyed row eats its click — so it is
|
||||
// both the way in and the fourth value Defaults has to walk back.
|
||||
s.run(
|
||||
"OptionsFrameContainerBodyCombatRowAurasCheck:Click() \
|
||||
"OptionsFrameContainerBodyCombatRowCombatTextCheck:Click() \
|
||||
OptionsFrameContainerBodyCombatRowAurasCheck:Click() \
|
||||
OptionsFrameContainerBodyCombatRowReputationCheck:Click() \
|
||||
OptionsFrameContainerBodyCombatRowFloatModeDropdownButton: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"
|
||||
@@ -2424,6 +2461,11 @@ fn defaults_resets_the_combat_page_to_the_shipped_assignments() {
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"0",
|
||||
"the master walks back too — the reference's own value since 1804"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return COMBAT_TEXT_SHOW_AURAS").unwrap(),
|
||||
"1"
|
||||
@@ -2456,6 +2498,11 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
let s = combat_harness();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowCombat:Click()").unwrap();
|
||||
// The master first: the page ships with it off (1804) and a greyed row eats its click, so
|
||||
// without this the Honor Gained click below would be a silent no-op. Both writes are then
|
||||
// held to the same standard — they have to come back after the restart.
|
||||
s.run("OptionsFrameContainerBodyCombatRowCombatTextCheck:Click()")
|
||||
.unwrap();
|
||||
s.run("OptionsFrameContainerBodyCombatRowHonorGainedCheck:Click()")
|
||||
.unwrap();
|
||||
|
||||
@@ -2464,6 +2511,10 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
saved.contains("COMBAT_TEXT_SHOW_HONOR_GAINED = \"0\""),
|
||||
"the toggle is in the saved text:\n{saved}"
|
||||
);
|
||||
assert!(
|
||||
saved.contains("SHOW_COMBAT_TEXT = \"1\""),
|
||||
"and so is the master:\n{saved}"
|
||||
);
|
||||
|
||||
// The restart: a fresh tree at its shipped defaults, then the saved chunk over the top.
|
||||
let fresh = combat_harness();
|
||||
@@ -2473,6 +2524,10 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
.unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert_eq!(
|
||||
fresh.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"0"
|
||||
);
|
||||
fresh.run(&saved).unwrap();
|
||||
assert_eq!(
|
||||
fresh
|
||||
@@ -2481,6 +2536,11 @@ fn what_the_combat_page_writes_survives_a_restart() {
|
||||
"0",
|
||||
"the saved value wins over the file-scope default"
|
||||
);
|
||||
assert_eq!(
|
||||
fresh.eval::<String>("return SHOW_COMBAT_TEXT").unwrap(),
|
||||
"1",
|
||||
"…and so does the master the player turned on"
|
||||
);
|
||||
// And the page paints the restored value the next time it is opened.
|
||||
fresh.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
fresh
|
||||
@@ -2582,15 +2642,22 @@ fn the_interface_page_writes_the_three_stock_globals() {
|
||||
.eval::<bool>("return OptionsFrameContainerDefaults:IsEnabled() ~= 0")
|
||||
.unwrap());
|
||||
|
||||
// Read: all three ship on — QuestFrame.xml's "1" (the director's pin), QuestLogFrame.xml's "1"
|
||||
// (the reference's advertised default, as a STRING — see that file on 1.12's own number/string
|
||||
// break), GameTooltip.xml's "1".
|
||||
for row in ["RowInstantQuestText", "RowAutoQuestWatch", "RowNewbieTips"] {
|
||||
assert!(
|
||||
// Read: each box shows its definer's own file-scope value. Two ship on — QuestLogFrame.xml's
|
||||
// "1" (the reference's advertised default, as a STRING — see that file on 1.12's own
|
||||
// number/string break) and GameTooltip.xml's "1" — and Instant Quest Text ships OFF, which is
|
||||
// QuestFrame.xml's `QUEST_FADING_DISABLE = "0"`: the reference's value, and ours since 1804
|
||||
// (it was pinned "1" by direction on 2026-07-17).
|
||||
for (row, checked) in [
|
||||
("RowInstantQuestText", false),
|
||||
("RowAutoQuestWatch", true),
|
||||
("RowNewbieTips", true),
|
||||
] {
|
||||
assert_eq!(
|
||||
s.eval::<bool>(&format!(
|
||||
"return OptionsFrameContainerBodyInterface{row}Check:GetChecked() and true or false"
|
||||
))
|
||||
.unwrap(),
|
||||
checked,
|
||||
"{row} reads its global"
|
||||
);
|
||||
}
|
||||
@@ -2609,12 +2676,12 @@ fn the_interface_page_writes_the_three_stock_globals() {
|
||||
);
|
||||
|
||||
// No applyFunc, because the write IS the apply: the questgiver's fade arm reads the global as
|
||||
// the panel shows, so turning instant text OFF makes the very next show write on.
|
||||
// the panel shows, so turning instant text ON makes the very next show land its text whole.
|
||||
s.run("OptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
|
||||
"0"
|
||||
"1"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>(
|
||||
@@ -2843,12 +2910,18 @@ fn defaults_sends_a_flip_only_for_the_row_that_moved() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Defaults here restores **our** shipped assignments, which is the visible payoff of capturing the
|
||||
/// default at OnLoad instead of restating it (1134 §1): `QUEST_FADING_DISABLE` ships `"1"` by
|
||||
/// direction (2026-07-17) where 1.12's own table hand-writes `default = "0"`. The page walks back
|
||||
/// to the value QuestFrame.xml actually assigns — there is no second copy to disagree with it.
|
||||
/// Defaults here restores the value **each global's own definer file assigns**, which is the
|
||||
/// visible payoff of capturing the default at OnLoad instead of restating it (1134 §1): the page
|
||||
/// holds no second copy that could disagree with QuestFrame.xml or GameTooltip.xml.
|
||||
///
|
||||
/// This test was `defaults_on_the_interface_page_restores_our_pin_not_the_references` until 1804,
|
||||
/// and its premise is now inverted. `QUEST_FADING_DISABLE` shipped `"1"` by direction
|
||||
/// (2026-07-17) where 1.12's own table hand-writes `default = "0"`, so Defaults used to walk the
|
||||
/// page back to a value the reference did not have. It walks back to `"0"` now — not because the
|
||||
/// mechanism changed, but because the assignment it reads **is** the reference's. That is the
|
||||
/// property worth holding: the capture follows the definer wherever the definer stands.
|
||||
#[test]
|
||||
fn defaults_on_the_interface_page_restores_our_pin_not_the_references() {
|
||||
fn defaults_on_the_interface_page_restores_the_definers_own_assignment() {
|
||||
let s = interface_harness();
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowInterface:Click()")
|
||||
@@ -2860,18 +2933,18 @@ fn defaults_on_the_interface_page_restores_our_pin_not_the_references() {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
|
||||
"0"
|
||||
"1"
|
||||
);
|
||||
assert_eq!(s.eval::<String>("return SHOW_NEWBIE_TIPS").unwrap(), "0");
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return QUEST_FADING_DISABLE").unwrap(),
|
||||
"1",
|
||||
"back to OUR default, not the reference's \"0\""
|
||||
"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
|
||||
assert!(!s
|
||||
.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyInterfaceRowInstantQuestTextCheck:GetChecked() \
|
||||
and true or false"
|
||||
@@ -2929,16 +3002,23 @@ fn what_the_interface_page_writes_survives_a_restart() {
|
||||
|
||||
/// **A saved value that has a SIDE EFFECT has to be applied when the chunk lands.** The whole UI's
|
||||
/// XML runs before `benilla-config/saved-variables.lua` executes over it (1128), so every file-scope
|
||||
/// consumer of a global ran against the *shipped* default: `CombatText_OnLoad` armed its six event
|
||||
/// registrations from `SHOW_COMBAT_TEXT = "1"`, and nothing re-ran when the saved `"0"` replaced it.
|
||||
/// The player's "off" came back on at every restart. 1.12 closes this with a hand-written ladder in
|
||||
/// `UIOptionsFrame`'s `VARIABLES_LOADED` arm ("Option specific function calls", UIOptionsFrame.lua
|
||||
/// l.204-220); we hold each side effect on its own row already, so the window runs them there.
|
||||
/// consumer of a global ran against the *shipped* default: `CombatText_OnLoad` decides its six
|
||||
/// event registrations from `SHOW_COMBAT_TEXT` once, at load, and nothing re-runs when the saved
|
||||
/// value replaces it. The player's choice came back undone at every restart. 1.12 closes this with
|
||||
/// a hand-written ladder in `UIOptionsFrame`'s `VARIABLES_LOADED` arm ("Option specific function
|
||||
/// calls", UIOptionsFrame.lua l.204-220); we hold each side effect on its own row already, so the
|
||||
/// window runs them there.
|
||||
///
|
||||
/// **The saved value here is `"1"`, and the direction is the whole point.** Until 1804 the master
|
||||
/// shipped `"1"`, so the bug was a saved `"0"` coming back ON and the test drove the switch down.
|
||||
/// The master ships at the reference's `"0"` now, so driving it down would assert nothing — the
|
||||
/// file-scope default already leaves the six events unregistered. The load-bearing case is the
|
||||
/// other one: the registrations were never armed at all, and only the walk can arm them.
|
||||
#[test]
|
||||
fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
|
||||
let mut s = combat_harness();
|
||||
// What the saved chunk does, verbatim: assign over the file-scope default, then the event.
|
||||
s.run("SHOW_COMBAT_TEXT = \"0\"").unwrap();
|
||||
s.run("SHOW_COMBAT_TEXT = \"1\"").unwrap();
|
||||
s.fire_event("VARIABLES_LOADED", vec![]);
|
||||
|
||||
s.fire_event(
|
||||
@@ -2949,9 +3029,9 @@ fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
|
||||
],
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return getn(COMBAT_TEXT_TO_ANIMATE) == 0")
|
||||
s.eval::<bool>("return getn(COMBAT_TEXT_TO_ANIMATE) == 1")
|
||||
.unwrap(),
|
||||
"the saved-off master is armed at load: the registrations were never re-run"
|
||||
"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());
|
||||
}
|
||||
@@ -2962,6 +3042,12 @@ fn a_saved_switch_with_a_side_effect_is_applied_when_the_variables_land() {
|
||||
/// timers the buff bar's three rows sit 45px apart, without them 35px — the reference's own two
|
||||
/// geometries (`BuffButtons_UpdatePositions`). The click has to move it, and so does the saved
|
||||
/// value landing at load, which is what the VARIABLES_LOADED walk is for.
|
||||
///
|
||||
/// The walk runs OFF → ON, because the bar ships without timers: `SHOW_BUFF_DURATIONS` is the
|
||||
/// reference's `"0"` since 1804. It shipped `"1"` from 0255, which put the durations-shown
|
||||
/// geometry in on the director's call, through 1139, which turned it into a setting without
|
||||
/// re-weighing what it ships as. Nothing about the two geometries or the apply hook changed —
|
||||
/// only which of them a fresh install opens on.
|
||||
#[test]
|
||||
fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
let gap = |s: &mut UiScript| -> f64 {
|
||||
@@ -2971,17 +3057,17 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
};
|
||||
|
||||
let mut s = interface_harness();
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "1");
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "0");
|
||||
assert!(
|
||||
(gap(&mut s) - 15.0).abs() < 1e-3,
|
||||
"the shipped default is the durations-SHOWN geometry: {}",
|
||||
(gap(&mut s) - 5.0).abs() < 1e-3,
|
||||
"the shipped default is the durations-HIDDEN geometry: {}",
|
||||
gap(&mut s)
|
||||
);
|
||||
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowInterface:Click()")
|
||||
.unwrap();
|
||||
assert!(s
|
||||
assert!(!s
|
||||
.eval::<bool>(
|
||||
"return OptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:GetChecked() \
|
||||
and true or false"
|
||||
@@ -2990,10 +3076,10 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
|
||||
s.run("OptionsFrameContainerBodyInterfaceRowBuffDurationsCheck:Click()")
|
||||
.unwrap();
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "0");
|
||||
assert_eq!(s.eval::<String>("return SHOW_BUFF_DURATIONS").unwrap(), "1");
|
||||
assert!(
|
||||
(gap(&mut s) - 5.0).abs() < 1e-3,
|
||||
"the click closed the timer gutter: {}",
|
||||
(gap(&mut s) - 15.0).abs() < 1e-3,
|
||||
"the click opened the timer gutter: {}",
|
||||
gap(&mut s)
|
||||
);
|
||||
|
||||
@@ -3001,18 +3087,18 @@ fn the_buff_durations_row_repitches_the_bar_and_the_pitch_survives_a_restart() {
|
||||
// VARIABLES_LOADED is what puts the bar where the value says.
|
||||
let saved = s.saved_variables_text();
|
||||
assert!(
|
||||
saved.contains("SHOW_BUFF_DURATIONS = \"0\""),
|
||||
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) - 15.0).abs() < 1e-3,
|
||||
(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) - 5.0).abs() < 1e-3,
|
||||
(gap(&mut fresh) - 15.0).abs() < 1e-3,
|
||||
"the apply walk re-derives the geometry from the saved value: {}",
|
||||
gap(&mut fresh)
|
||||
);
|
||||
@@ -3087,9 +3173,9 @@ fn the_defaults_button_is_armed_by_rows_not_by_a_category() {
|
||||
/// say/yell bubbles off).
|
||||
#[test]
|
||||
fn the_chat_page_toggles_the_chat_bubble_cvars() {
|
||||
let mut s = audio_harness();
|
||||
s.set_cvar_host("ChatBubblesParty", "0");
|
||||
let mut s = harness_on(s);
|
||||
// No host override: since 1804 the registered pair IS bubbles-on / party-off, so the page's
|
||||
// read below is of the shipped table rather than of a value this test planted.
|
||||
let mut s = harness_on(audio_harness());
|
||||
s.run("ShowUIPanel(OptionsFrame)").unwrap();
|
||||
s.run("OptionsFrameCategoryListRowChat:Click()").unwrap();
|
||||
|
||||
@@ -3129,13 +3215,14 @@ fn the_chat_page_toggles_the_chat_bubble_cvars() {
|
||||
.eval::<bool>("return OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()")
|
||||
.unwrap());
|
||||
|
||||
// Defaults walks the page back to the registered pair — including the ChatBubblesParty "1"
|
||||
// that deliberately disagrees with the binary's registered "0" (the director's /p ask, 0598).
|
||||
// Defaults walks the page back to the registered pair, which is the binary's own since 1804:
|
||||
// `ChatBubbles` "1", `ChatBubblesParty` "0" (the party half was the director's /p ask, 0598).
|
||||
// The party row was clicked ON above, so this is a real walk-back, not a no-op.
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert!(s
|
||||
.eval::<bool>("return OptionsFrameContainerBodyChatRowChatBubblesCheck:GetChecked()")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
assert!(!s
|
||||
.eval::<bool>("return OptionsFrameContainerBodyChatRowPartyChatBubblesCheck:GetChecked()")
|
||||
.unwrap());
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
@@ -3364,8 +3451,12 @@ fn the_mouse_sensitivity_slider_snaps_to_the_reference_step() {
|
||||
/// **Max Camera Distance** (decision 1140) — the fourth frozen constant, and the one with a wrinkle
|
||||
/// worth pinning: 1.12 stores a FACTOR over `cameraDistanceMax`'s 15 yd base, so the value that
|
||||
/// persists is `1.0 … 2.0` while the thing the player is choosing is a distance. The readout shows
|
||||
/// the distance; the CVar carries the factor. Our default is the factor fully raised — the 30 yd
|
||||
/// ceiling benilla has always had — where the reference's registrar ships 1.0.
|
||||
/// the distance; the CVar carries the factor.
|
||||
///
|
||||
/// The slider's RANGE is unchanged and still reaches the 30 yd ceiling benilla used to ship at;
|
||||
/// what moved is where it rests. Since 1804 the default is the registrar's own `1.0` — 15 yd, the
|
||||
/// distance a fresh 1.12 client stops the wheel at — so the walk here is up the range and back
|
||||
/// rather than down it.
|
||||
#[test]
|
||||
fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
|
||||
let mut s = harness_on(audio_harness());
|
||||
@@ -3379,15 +3470,46 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
|
||||
"return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"30 yd"
|
||||
"15 yd",
|
||||
"vanilla's own out-of-box ceiling"
|
||||
);
|
||||
|
||||
// Down to the reference's own default: the factor is what persists, the yards are the label.
|
||||
s.run("OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(1.0)")
|
||||
// A move off the notch: the factor is what persists, the yards are the label — and the two
|
||||
// disagree numerically, which is why a mid-range stop is worth a step of its own.
|
||||
s.run("OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(1.4)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("cameraDistanceMaxFactor".to_string(), "1".to_string())]
|
||||
vec![("cameraDistanceMaxFactor".to_string(), "1.4".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"21 yd"
|
||||
);
|
||||
|
||||
// All the way up — the slider's top, which is where this row's default sat until 1804.
|
||||
s.run("OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlSlider:SetValue(2.0)")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.take_cvar_changes(),
|
||||
vec![("cameraDistanceMaxFactor".to_string(), "2".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
"return OptionsFrameContainerBodyControlsRowMaxCameraDistanceControlValue:GetText()"
|
||||
)
|
||||
.unwrap(),
|
||||
"30 yd"
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCVar(\"cameraDistanceMaxFactor\")")
|
||||
.unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>(
|
||||
@@ -3395,14 +3517,7 @@ fn the_max_camera_distance_slider_stores_a_factor_and_reads_out_yards() {
|
||||
)
|
||||
.unwrap(),
|
||||
"15 yd",
|
||||
"vanilla's own out-of-box ceiling"
|
||||
);
|
||||
|
||||
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetCVar(\"cameraDistanceMaxFactor\")")
|
||||
.unwrap(),
|
||||
"2"
|
||||
"the readout follows the reset"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ fn hovered_pet_bar() -> UiScript {
|
||||
// them — so `GetBindingKey("BONUSACTIONBUTTON1")` answers CTRL-1 (its byte-real default) and
|
||||
// `GetCVar("UberTooltips")` answers "1" (its byte-read registrar value) rather than nil.
|
||||
s.register_bindings(&crate::bindings::registry_commands());
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
load_pet_bar_with_tooltip(&s);
|
||||
declare_token_strings(&s);
|
||||
s.set_pet_actions(true, true, true, hunter_slots());
|
||||
|
||||
@@ -330,6 +330,12 @@ fn greeting_goodbye_button_closes_the_window() {
|
||||
/// is a virtual template declared `hidden="true"` (never itself drawn) — an instance that doesn't
|
||||
/// explicitly `:Show()` stays invisible even though it isn't declared `hidden="true"` itself. Every
|
||||
/// panel's action buttons must show, extracted at real on-window rects (never dropped/hidden).
|
||||
///
|
||||
/// It runs the **instant-text** arm, which is not the shipped one: `QUEST_FADING_DISABLE` boots at
|
||||
/// the reference's `"0"` since 1804 (it was pinned `"1"` by direction from 2026-07-17 until then),
|
||||
/// so the flag is planted below. That arm is the one this guard wants — it is the shorter path to
|
||||
/// a settled panel, and its one-frame Accept-disabled window is a state the rects have to survive.
|
||||
/// The write-on arm is `write_on_still_fades_when_instant_text_is_off`'s subject.
|
||||
#[test]
|
||||
fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -340,6 +346,8 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
|
||||
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
|
||||
load_xml(&s, "QuestFrame.xml");
|
||||
// Planted, not shipped: instant text is OFF out of the box (see the doc comment).
|
||||
s.eval::<()>(r#"QUEST_FADING_DISABLE = "1""#).unwrap();
|
||||
s.set_quest(Some(QuestState {
|
||||
panel: QuestPanel::Detail,
|
||||
title: "A Threat Within".into(),
|
||||
@@ -374,8 +382,8 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
v
|
||||
};
|
||||
|
||||
// The instant-text arm (QUEST_FADING_DISABLE pinned "1"): OnShow still disables Accept and
|
||||
// zeroes the block for the ref's one-frame window — the reveal edge just starts at 1024.
|
||||
// The instant-text arm (QUEST_FADING_DISABLE planted "1" above): OnShow still disables Accept
|
||||
// and zeroes the block for the ref's one-frame window — the reveal edge just starts at 1024.
|
||||
let writing = button_art(&mut s);
|
||||
assert_eq!(
|
||||
writing.len(),
|
||||
@@ -435,10 +443,11 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The ref write-on survives verbatim behind the flag: with QUEST_FADING_DISABLE = "0" (the
|
||||
/// ref's own default, our pin is "1"), the description writes on at 40 chars/s scratching the
|
||||
/// quill each tick, Accept stays dead mid-write, and the objectives/rewards block FADES in over
|
||||
/// QUESTINFO_FADE_IN rather than snapping.
|
||||
/// The ref write-on runs verbatim: with QUEST_FADING_DISABLE = "0" — the ref's own default, and
|
||||
/// ours too since 1804 (it was pinned "1" from 2026-07-17 until then, which is why this file
|
||||
/// still says the value out loud rather than leaning on the shipped one) — the description writes
|
||||
/// on at 40 chars/s scratching the quill each tick, Accept stays dead mid-write, and the
|
||||
/// objectives/rewards block FADES in over QUESTINFO_FADE_IN rather than snapping.
|
||||
#[test]
|
||||
fn write_on_still_fades_when_instant_text_is_off() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -123,7 +123,7 @@ fn load_addon_files(script: &UiScript, root: &Path, name: &str) -> Vec<String> {
|
||||
fn seat_a_hunter(root: &Path) -> UiScript {
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.set_realm_name("Harness");
|
||||
s.set_unit(
|
||||
"player",
|
||||
|
||||
@@ -152,6 +152,105 @@ fn is_word(c: char) -> bool {
|
||||
mod tests {
|
||||
use benilla_ui::script::UiScript;
|
||||
|
||||
/// **Every saved UI global we declare defaults to what the reference's own file declares**
|
||||
/// (decision 1804) — the FrameXML half of the "a default is the reference's" standard that
|
||||
/// `cvars::REGISTERED`'s [`crate::cvars::Reference`] column holds for the CVar half.
|
||||
///
|
||||
/// Two stores carry a player's settings in this client and the standard has to cover both.
|
||||
/// CVars are the engine's, and their table now states the reference's value per row. The other
|
||||
/// store is FrameXML's own: the `RegisterForSave`'d globals — *Instant Quest Text*, *Show Buff
|
||||
/// Durations*, *Lock Action Bar* — whose default is a plain assignment in whichever of our
|
||||
/// `assets/ui` files still owns that window. Nothing tied those to anything, and two had
|
||||
/// drifted: `QUEST_FADING_DISABLE` and `SHOW_BUFF_DURATIONS` both shipped `"1"` where 1.12
|
||||
/// ships `"0"`, each a reasonable call on its own day (2026-07-17 and 0255) and neither
|
||||
/// visible as a *divergence from the reference* without opening its file.
|
||||
///
|
||||
/// The reference's declarations are read off the player's own chain rather than copied here,
|
||||
/// so this cannot rot the way a transcribed list would: `UIOptionsFrame.lua`'s
|
||||
/// `UIOptionsFrame_Init` assigns every options-panel uvar its factory value, and that file is
|
||||
/// the authority. A name we persist that the reference declares **somewhere else**
|
||||
/// (`SHOW_OFFLINE_GUILD_MEMBERS` lives in `FriendsFrame.lua`) or not at all (our own
|
||||
/// `TRAINER_FILTER_*`) is reported as uncovered, not failed — and the covered count is
|
||||
/// asserted, so the day our last options window migrates and this covers nothing, it says so
|
||||
/// instead of passing vacuously.
|
||||
///
|
||||
/// **This test retires as `assets/ui` does** (1751): a migrated window runs the reference's
|
||||
/// own file, whose assignment IS the reference's value, and the question stops existing.
|
||||
///
|
||||
/// Skips without client data, like every other test that reads the install.
|
||||
#[test]
|
||||
fn our_saved_ui_globals_default_to_the_references_own_values() {
|
||||
let _data = benilla_formats::wow_data_or_skip!();
|
||||
|
||||
// The reference's own factory assignments, parsed out of the file that makes them. Only
|
||||
// `NAME = "literal"` / `NAME = number` at the head of a line — an assignment guarded by an
|
||||
// `if`, or one that copies another global, is not a factory default and must not be read
|
||||
// as one.
|
||||
let src = String::from_utf8_lossy(
|
||||
&super::read("Interface\\FrameXML\\UIOptionsFrame.lua")
|
||||
.expect("the reference's own UIOptionsFrame.lua"),
|
||||
)
|
||||
.into_owned();
|
||||
let mut theirs = std::collections::BTreeMap::new();
|
||||
for line in src.lines() {
|
||||
let line = line.trim();
|
||||
let Some((name, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim();
|
||||
if name.is_empty() || !name.chars().all(|c| super::is_word(c) && !c.is_lowercase()) {
|
||||
continue; // a uvar is SHOUTED; anything else is a local, a field or a comparison
|
||||
}
|
||||
let value = value.trim().trim_end_matches(';').trim();
|
||||
let literal = value.strip_prefix('"').and_then(|v| v.strip_suffix('"'));
|
||||
let Some(v) = literal.or_else(|| value.parse::<i64>().ok().map(|_| value)) else {
|
||||
continue; // not a literal — an expression, so not a factory default
|
||||
};
|
||||
theirs.insert(name.to_string(), v.to_string());
|
||||
}
|
||||
assert!(
|
||||
theirs.len() > 20,
|
||||
"parsed only {} declarations out of UIOptionsFrame.lua — the parse is broken, not the \
|
||||
reference",
|
||||
theirs.len()
|
||||
);
|
||||
|
||||
let mut s = UiScript::new().expect("VM");
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
let failures = super::super::manifest::load_default_ui(&s);
|
||||
assert!(failures.is_empty(), "the default UI: {failures:#?}");
|
||||
|
||||
let (mut checked, mut uncovered, mut wrong) = (0usize, Vec::new(), Vec::new());
|
||||
for name in s.saved_variable_names() {
|
||||
let Some(want) = theirs.get(&name) else {
|
||||
uncovered.push(name);
|
||||
continue;
|
||||
};
|
||||
// Through `tostring`, because the reference is inconsistent about it itself:
|
||||
// `AUTO_QUEST_WATCH` is declared as the number 1 and every other uvar as a string.
|
||||
let got = s
|
||||
.eval::<String>(&format!("return tostring({name})"))
|
||||
.unwrap_or_else(|e| panic!("{name} is registered for save but unreadable: {e}"));
|
||||
checked += 1;
|
||||
if &got != want {
|
||||
wrong.push(format!("{name}: ours {got:?}, the reference's {want:?}"));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
wrong.is_empty(),
|
||||
"saved UI globals that do not default to the reference's own value — either match it, \
|
||||
or make the divergence explicit at the assignment the way `cvars::REGISTERED` does:\n \
|
||||
{}",
|
||||
wrong.join("\n "),
|
||||
);
|
||||
assert!(
|
||||
checked >= 9,
|
||||
"only {checked} of our saved globals are declared in the reference's \
|
||||
UIOptionsFrame.lua (uncovered: {uncovered:?}) — if a window migrated, lower this; if \
|
||||
the parse broke, fix it",
|
||||
);
|
||||
}
|
||||
|
||||
/// **A chain entry really loads off the player's install, and order really decides.**
|
||||
///
|
||||
/// The two halves of this module's rule, asserted rather than described, because nothing else
|
||||
|
||||
@@ -185,7 +185,7 @@ fn your_own_portrait_explains_the_menu_instead_of_showing_your_health() {
|
||||
#[test]
|
||||
fn action_button_hover_takes_the_default_corner() {
|
||||
let mut s = harness(&["Cooldown.xml", "ActionBar.xml"]);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
|
||||
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
|
||||
let ok: bool = s
|
||||
@@ -214,7 +214,7 @@ fn action_button_hover_takes_the_default_corner() {
|
||||
#[test]
|
||||
fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
|
||||
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "MultiBars.xml"]);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.set_cvar_engine("UberTooltips", "0");
|
||||
|
||||
let seat = |s: &UiScript| {
|
||||
@@ -271,7 +271,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
|
||||
#[test]
|
||||
fn ubertooltips_off_seats_stance_plates_beside_the_button() {
|
||||
let mut s = harness(&["Cooldown.xml", "ActionBar.xml", "StanceBar.xml"]);
|
||||
s.register_cvars(crate::cvars::REGISTERED.iter().copied());
|
||||
s.register_cvars(crate::cvars::registered_pairs());
|
||||
s.set_shapeshift_forms(vec![benilla_ui::script::ShapeshiftFormView {
|
||||
spell_id: 5487,
|
||||
name: "Bear Form".into(),
|
||||
|
||||
Reference in New Issue
Block a user