refactor(engine): move hud/settings/game/input plugin tests into tests.rs files

Continues #118 after card_plugin (PR #124): the four remaining
oversized plugin files become module directories with their trailing
#[cfg(test)] blocks extracted verbatim into sibling tests.rs files,
following the replay_overlay/ pattern.

  hud_plugin:      3,598 -> 2,725 + 872   (44 tests)
  settings_plugin: 3,512 -> 2,857 + 654   (25 tests)
  game_plugin:     2,562 -> 1,310 + 1,251 (39 tests)
  input_plugin:    2,540 -> 1,832 + 707   (31 tests)

Pure mechanical moves via git mv — no runtime code changes; all 139
tests preserved and passing.

Refs #118

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-06 13:19:56 -07:00
parent 3f5e4f4290
commit a2375d2fd9
8 changed files with 3488 additions and 3492 deletions
@@ -0,0 +1,654 @@
use super::*;
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"
);
}
#[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"
);
}
// 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();
// Open the panel.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
// Two more ticks: the first runs `sync_settings_panel_visibility`
// and queues the spawn commands; the second flushes them and
// runs `attach_focusable_to_settings_buttons`.
app.update();
app.update();
// Every bespoke `SettingsButton` (not `Done`, which is also a
// `ModalButton`) must carry a `Focusable`.
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 must carry Focusable; missing: {:?}",
untagged
);
// And there must be at least one tagged `SettingsButton` so the
// assertion above isn't vacuously true (the panel really did
// spawn).
let tagged_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Focusable>>()
.iter(app.world())
.count();
assert!(
tagged_count >= 6,
"expected the panel to spawn many bespoke buttons (volume up/down ×2, toggles ×4, sync, swatches…); got {tagged_count}"
);
}
/// 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();
// Open the panel and let spawn + child-flush run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// No bespoke `SettingsButton` (i.e. excluding `Done`, which is
// also a `ModalButton`) may be missing a `Tooltip`.
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 must carry Tooltip; missing: {:?}",
untipped
);
// And there must be at least 6 tipped buttons so the assertion
// above isn't vacuously true: SFX +/, Music +/, Draw Mode,
// Anim Speed, Theme, Color-blind, Sync Now, plus at least one
// card-back and one background swatch — well over the floor.
let tipped_count = app
.world_mut()
.query_filtered::<&SettingsButton, With<Tooltip>>()
.iter(app.world())
.count();
assert!(
tipped_count >= 6,
"expected the panel to spawn many tooltipped buttons; got {tipped_count}"
);
// Spot-check: 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();
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// 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}"
);
}
/// 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 and let the spawn + child-flush systems run.
app.world_mut().resource_mut::<SettingsScreen>().0 = true;
app.update();
app.update();
app.update();
// 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}"
);
}