Files
Ferrous-Solitaire/solitaire_engine/src/settings_plugin/tests.rs
T
funman300 4700bd7912
Test / test (pull_request) Successful in 10m16s
fix(engine): close Settings, Help, Leaderboard, and theme store on Esc
Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):

- Settings: Esc clears SettingsScreen, gated on being the topmost
  modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
  claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
  stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)

Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.

Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:31:55 -07:00

739 lines
23 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.
use super::*;
use crate::ui_focus::{FocusRow, Focusable};
use crate::ui_modal::ModalButton;
use crate::ui_tooltip::Tooltip;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(SettingsPlugin::headless());
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn press(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
#[test]
fn defaults_are_loaded() {
let app = headless_app();
assert_eq!(
app.world().resource::<SettingsResource>().0,
Settings::default()
);
}
#[test]
fn pressing_left_bracket_decreases_volume_and_emits_event() {
let mut app = headless_app();
let before = app.world().resource::<SettingsResource>().0.sfx_volume;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after < before);
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 1);
}
#[test]
fn pressing_right_bracket_increases_volume() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.5;
press(&mut app, KeyCode::BracketRight);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!((after - 0.6).abs() < 1e-3);
}
#[test]
fn clamped_change_does_not_emit_event() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 1.0;
press(&mut app, KeyCode::BracketRight);
app.update();
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(cursor.read(events).count(), 0);
}
#[test]
fn volume_clamped_at_zero_does_not_emit_event() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.sfx_volume = 0.0;
press(&mut app, KeyCode::BracketLeft);
app.update();
let after = app.world().resource::<SettingsResource>().0.sfx_volume;
assert!(after >= 0.0, "volume must not go below zero");
let events = app.world().resource::<Messages<SettingsChangedEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
0,
"no event when clamped at floor"
);
}
/// Opens the Settings panel showing `tab`, then waits out the
/// spawn / child-flush / marker-attach frames.
fn open_settings_on(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
}
/// Switches the already-open panel to `tab` (drives the
/// `rebuild_panel_on_tab_change` path) and waits out the rebuild frames.
fn switch_settings_tab(app: &mut App, tab: SettingsTab) {
app.world_mut().resource_mut::<ActiveSettingsTab>().0 = tab;
app.update();
app.update();
app.update();
}
#[test]
fn pressing_o_toggles_settings_screen_flag() {
let mut app = headless_app();
assert!(
!app.world().resource::<SettingsScreen>().0,
"screen is closed initially"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(
app.world().resource::<SettingsScreen>().0,
"O opens settings"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"second O closes settings"
);
}
/// Esc closes the Settings panel like O / Done do (Phase C dismissal
/// audit). Esc while the panel is closed must NOT open it.
#[test]
fn escape_closes_settings_screen_flag() {
let mut app = headless_app();
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc on a closed panel stays closed"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc closes settings"
);
}
// cycle_unlocked pure-function tests
#[test]
fn cycle_unlocked_wraps_at_end() {
// [0, 1, 2] → cycling from 2 wraps to 0
assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0);
}
#[test]
fn cycle_unlocked_advances_normally() {
assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1);
assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2);
}
#[test]
fn cycle_unlocked_single_element_stays() {
// Only one unlockable — cycling always returns it.
assert_eq!(cycle_unlocked(&[0], 0), 0);
}
#[test]
fn cycle_unlocked_current_not_in_list_falls_back_to_second() {
// current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1
assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1);
}
#[test]
fn cycle_unlocked_empty_returns_zero() {
assert_eq!(cycle_unlocked(&[], 0), 0);
}
#[test]
fn scroll_is_noop_when_settings_panel_closed() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Panel starts closed (SettingsScreen(false)); spawn a scrollable entity.
let entity = app
.world_mut()
.spawn((SettingsPanelScrollable, ScrollPosition::default()))
.id();
// Send a downward scroll event while the panel is closed.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -3.0,
window: Entity::PLACEHOLDER,
});
app.update();
// ScrollPosition must remain at 0.0 — panel was closed.
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0
.y;
assert_eq!(offset, 0.0, "scroll must not move when panel is closed");
}
#[test]
fn scroll_moves_offset_when_panel_open() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Spawn a scrollable entity with an existing offset so we can distinguish clamping.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition(Vec2::new(0.0, 100.0)),
))
.id();
// Scroll down by 2 lines (50 px/line → +100 px added to offset_y).
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: -2.0,
window: Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0
.y;
assert!(
(offset - 200.0).abs() < 1e-3,
"scrolling down should increase offset_y; got {offset}"
);
}
// -----------------------------------------------------------------------
// Phase 3 — keyboard focus ring, Settings buttons + FocusRow
// -----------------------------------------------------------------------
/// Headless app that runs the *real* (UI-enabled) `SettingsPlugin`
/// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn /
/// auto-tag systems fire end-to-end without writing to disk.
fn headless_app_with_focus() -> App {
use crate::ui_focus::UiFocusPlugin;
use crate::ui_modal::UiModalPlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin)
.add_plugins(SettingsPlugin {
// No persistence — keep the test isolated.
storage_path: None,
ui_enabled: true,
});
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
#[test]
fn settings_buttons_get_focusable_marker() {
let mut app = headless_app_with_focus();
// Walk every tab: each rebuild must leave no bespoke
// `SettingsButton` (not `Done`, which is also a `ModalButton`)
// without a `Focusable`.
let mut total_tagged = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
let untagged: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Focusable>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untagged.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Focusable; missing: {untagged:?}"
);
total_tagged += app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true (audio 4 + gameplay 8 + appearance ≥5 +
// accessibility 6 + account ≥1).
assert!(
total_tagged >= 20,
"expected the five tabs to spawn many bespoke buttons; got {total_tagged}"
);
}
/// Every bespoke `SettingsButton` (volume +/, toggles, swatches,
/// Sync Now) must spawn with a `Tooltip` so the glyph-only icons and
/// indexed swatches carry hover-reveal context. Mirrors
/// `settings_buttons_get_focusable_marker` (Phase 3 focus test) so
/// the invariant — every interactive Settings element except the
/// `Done` modal button has a tooltip — is asserted consistently.
#[test]
fn settings_buttons_carry_tooltip() {
let mut app = headless_app_with_focus();
// Walk every tab: no bespoke `SettingsButton` (i.e. excluding
// `Done`, which is also a `ModalButton`) may be missing a
// `Tooltip` on any of them.
let mut total_tipped = 0usize;
for (i, tab) in SettingsTab::ALL.into_iter().enumerate() {
if i == 0 {
open_settings_on(&mut app, tab);
} else {
switch_settings_tab(&mut app, tab);
}
let untipped: Vec<&SettingsButton> = app
.world_mut()
.query_filtered::<&SettingsButton, (With<Button>, Without<Tooltip>, Without<ModalButton>)>()
.iter(app.world())
.collect();
assert!(
untipped.is_empty(),
"every bespoke Settings button on tab {tab:?} must carry Tooltip; missing: {untipped:?}"
);
total_tipped += app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
}
// Floor across all five tabs so the assertion above isn't
// vacuously true.
assert!(
total_tipped >= 20,
"expected the five tabs to spawn many tooltipped buttons; got {total_tipped}"
);
// The loop ends on the Account tab, where with default (Local)
// settings the Connect button spawns.
// We verify its tooltip carries the canonical microcopy.
let connect_tip = app
.world_mut()
.query::<(&SettingsButton, &Tooltip)>()
.iter(app.world())
.find_map(|(btn, tip)| matches!(btn, SettingsButton::ConnectSync).then(|| tip.0.clone()))
.expect("Connect button should spawn with a Tooltip when backend is Local");
assert_eq!(
connect_tip.as_ref(),
"Connect to a self-hosted Ferrous Solitaire sync server.",
"ConnectSync tooltip must use the canonical microcopy"
);
}
#[test]
fn settings_picker_rows_get_focus_row_marker() {
let mut app = headless_app_with_focus();
// Picker rows live on the Appearance tab.
open_settings_on(&mut app, SettingsTab::Appearance);
// Two picker rows are spawned (card-back + background); each
// must carry the FocusRow marker.
let row_count = app
.world_mut()
.query_filtered::<Entity, With<FocusRow>>()
.iter(app.world())
.count();
assert!(
row_count >= 2,
"expected at least two FocusRow containers (card-back + background); got {row_count}"
);
}
/// Switching tabs despawns the old rows and spawns the new tab's —
/// exactly one panel exists afterwards (no double-spawn from the
/// visibility system racing the rebuild system).
#[test]
fn switching_tab_rebuilds_panel_with_new_rows() {
let mut app = headless_app_with_focus();
fn has_button(app: &mut App, wanted: fn(&SettingsButton) -> bool) -> bool {
app.world_mut()
.query::<&SettingsButton>()
.iter(app.world())
.any(wanted)
}
open_settings_on(&mut app, SettingsTab::Audio);
assert!(has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)));
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::ToggleDrawMode)),
"Gameplay rows must not spawn while the Audio tab is active"
);
switch_settings_tab(&mut app, SettingsTab::Gameplay);
assert!(
!has_button(&mut app, |b| matches!(b, SettingsButton::SfxUp)),
"Audio rows must despawn when leaving the Audio tab"
);
assert!(has_button(&mut app, |b| matches!(
b,
SettingsButton::ToggleDrawMode
)));
let panel_count = app
.world_mut()
.query_filtered::<Entity, With<SettingsPanel>>()
.iter(app.world())
.count();
assert_eq!(panel_count, 1, "tab switch must not stack panels");
}
/// Test 3 of the thumbnail-picker spec: when [`ThemeRegistry`] has
/// at least one theme and the [`ThemeThumbnailCache`] holds a
/// fully-populated [`ThemeThumbnailPair`] for that theme's id, the
/// rendered chip carries a [`ThemeThumbnailMarker`]-tagged
/// `ImageNode` for each preview slot.
#[test]
fn theme_picker_chip_includes_thumbnail_sprite_when_thumbnails_loaded() {
use crate::theme::{ThemeEntry, ThemeRegistry, ThemeThumbnailCache, ThemeThumbnailPair};
let mut app = headless_app_with_focus();
// Prime an Assets<Image> resource so we can mint stable handles
// for the synthetic thumbnail pair.
app.init_resource::<Assets<Image>>();
let (ace_handle, back_handle) = {
let mut images = app.world_mut().resource_mut::<Assets<Image>>();
let ace = images.add(Image::default());
let back = images.add(Image::default());
(ace, back)
};
// Inject one theme entry + a matching thumbnail pair.
app.insert_resource(ThemeRegistry {
entries: vec![ThemeEntry {
id: "test_theme".into(),
display_name: "Test Theme".into(),
manifest_url: "themes://test_theme/theme.ron".into(),
meta: crate::theme::ThemeMeta {
id: "test_theme".into(),
name: "Test Theme".into(),
author: "x".into(),
version: "x".into(),
card_aspect: (2, 3),
},
}],
});
let mut cache = ThemeThumbnailCache::default();
cache.entries.insert(
"test_theme".into(),
ThemeThumbnailPair {
ace: ace_handle.clone(),
back: back_handle.clone(),
},
);
app.insert_resource(cache);
// Open the panel on the Appearance tab (the theme picker's home)
// and let the spawn + child-flush systems run.
open_settings_on(&mut app, SettingsTab::Appearance);
// Find every ImageNode tagged with ThemeThumbnailMarker — the
// theme picker chip for "test_theme" must contribute exactly
// two of them (ace + back).
let thumbnail_count = app
.world_mut()
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
.iter(app.world())
.count();
assert!(
thumbnail_count >= 2,
"expected at least one ace + back thumbnail (2 sprites); got {thumbnail_count}"
);
// Spot-check: at least one thumbnail's image handle matches one
// of the ones we inserted into the cache. This guards against a
// future refactor that accidentally clones the wrong handle.
let any_matches = app
.world_mut()
.query_filtered::<&ImageNode, With<ThemeThumbnailMarker>>()
.iter(app.world())
.any(|node| node.image == ace_handle || node.image == back_handle);
assert!(
any_matches,
"at least one rendered thumbnail must reuse the cached handle"
);
}
// -----------------------------------------------------------------------
// Window geometry persistence
// -----------------------------------------------------------------------
#[test]
fn should_persist_geometry_respects_debounce_window() {
// Within the debounce window: not yet.
assert!(!should_persist_geometry(10.0, 9.7));
assert!(!should_persist_geometry(
10.0,
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS + 0.01
));
// Exactly the debounce window: allowed (>= comparison).
assert!(should_persist_geometry(
10.0,
10.0 - WINDOW_GEOMETRY_DEBOUNCE_SECS
));
// Well past the debounce window: allowed.
assert!(should_persist_geometry(20.0, 10.0));
}
#[test]
fn merge_geometry_uses_existing_when_event_components_missing() {
let existing = WindowGeometry {
width: 1280,
height: 800,
x: 100,
y: 50,
};
// Position-only event keeps existing size.
let merged = merge_geometry(Some(existing), None, Some((200, 75))).unwrap();
assert_eq!(merged.width, 1280);
assert_eq!(merged.height, 800);
assert_eq!(merged.x, 200);
assert_eq!(merged.y, 75);
// Size-only event keeps existing position.
let merged = merge_geometry(Some(existing), Some((1024, 768)), None).unwrap();
assert_eq!(merged.width, 1024);
assert_eq!(merged.height, 768);
assert_eq!(merged.x, 100);
assert_eq!(merged.y, 50);
}
#[test]
fn merge_geometry_returns_none_when_size_unknown() {
// No existing geometry, no size in the event → can't fabricate one.
assert!(merge_geometry(None, None, Some((10, 20))).is_none());
}
/// Drives `app.update()` past [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] using
/// `TimeUpdateStrategy::ManualDuration`. `Time<Virtual>` clamps each
/// frame's delta to `max_delta` (default 250 ms), so we step in 150 ms
/// slices and run enough ticks to comfortably exceed the debounce
/// window after the first record tick has set `last_changed_secs`.
fn advance_past_geometry_debounce(app: &mut App) {
use bevy::time::TimeUpdateStrategy;
use std::time::Duration;
app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32(
0.15,
)));
// Tick 1 sets last_changed_secs from any pending events. Each
// subsequent tick advances the clock by 150 ms; five ticks total
// buys 0.75 s of elapsed time relative to the record tick — well
// past the 0.5 s debounce window.
for _ in 0..5 {
app.update();
}
}
fn fire_resize(app: &mut App, width: f32, height: f32) {
app.world_mut().write_message(WindowResized {
window: Entity::PLACEHOLDER,
width,
height,
});
}
fn fire_move(app: &mut App, x: i32, y: i32) {
app.world_mut().write_message(WindowMoved {
window: Entity::PLACEHOLDER,
position: IVec2::new(x, y),
});
}
#[test]
fn resize_event_then_quiet_persists_window_geometry() {
let mut app = headless_app();
// Sanity: geometry starts unset (default).
assert!(
app.world()
.resource::<SettingsResource>()
.0
.window_geometry
.is_none()
);
// Fire a resize, then go quiet for past the debounce.
fire_resize(&mut app, 1500.0, 950.0);
advance_past_geometry_debounce(&mut app);
let geom = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.expect("geometry should be persisted after debounce");
assert_eq!(geom.width, 1500);
assert_eq!(geom.height, 950);
// Position not yet observed → defaults to 0, 0 since there was
// no existing geometry to fall back on.
assert_eq!(geom.x, 0);
assert_eq!(geom.y, 0);
}
#[test]
fn move_event_after_resize_updates_position_only() {
let mut app = headless_app();
// First, establish a baseline geometry via a resize event.
fire_resize(&mut app, 1280.0, 800.0);
advance_past_geometry_debounce(&mut app);
let baseline = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.unwrap();
assert_eq!(baseline.width, 1280);
// Now fire a move-only event — size must be preserved from the
// existing geometry.
fire_move(&mut app, 250, 175);
advance_past_geometry_debounce(&mut app);
let geom = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.unwrap();
assert_eq!(
geom.width, 1280,
"size must be preserved across a move-only update"
);
assert_eq!(geom.height, 800);
assert_eq!(geom.x, 250);
assert_eq!(geom.y, 175);
}
#[test]
fn rapid_resize_storm_only_persists_final_size() {
let mut app = headless_app();
// Burst of resize events on a single frame — only the last one
// should be the eventually-persisted size.
fire_resize(&mut app, 900.0, 600.0);
fire_resize(&mut app, 1100.0, 700.0);
fire_resize(&mut app, 1400.0, 850.0);
advance_past_geometry_debounce(&mut app);
let geom = app
.world()
.resource::<SettingsResource>()
.0
.window_geometry
.unwrap();
assert_eq!((geom.width, geom.height), (1400, 850));
}
#[test]
fn no_window_events_no_geometry_change() {
let mut app = headless_app();
// Just advance time — without any events, settings must stay clean.
advance_past_geometry_debounce(&mut app);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.window_geometry
.is_none()
);
}
#[test]
fn scroll_clamps_offset_to_zero_at_top() {
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
let mut app = headless_app();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
// Entity starts at 10 px offset.
let entity = app
.world_mut()
.spawn((
SettingsPanelScrollable,
ScrollPosition(Vec2::new(0.0, 10.0)),
))
.id();
// Scroll up by 5 lines → would subtract 250 px, but must clamp to 0.
app.world_mut().write_message(MouseWheel {
unit: MouseScrollUnit::Line,
x: 0.0,
y: 5.0,
window: Entity::PLACEHOLDER,
});
app.update();
let offset = app
.world()
.entity(entity)
.get::<ScrollPosition>()
.unwrap()
.0
.y;
assert_eq!(
offset, 0.0,
"scrolling past top must clamp to 0, got {offset}"
);
}