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:
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user