1ca1efb3b6
Second phase of #118 for settings_plugin: the 2,857-line mod.rs becomes four focused submodules along existing system boundaries — mod.rs 561 types, markers, SettingsButton, plugin build, persistence input.rs 632 button/hotkey handlers, focus attachment, scrolling updates.rs 495 per-frame value-text updater systems + label helpers ui.rs 1,231 spawn_settings_panel + row builders + thumbnails Moved items are pub(super); mod.rs glob-imports the submodules so system registration and tests keep their bare names. No behaviour change; all 29 settings tests pass unchanged. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
656 lines
20 KiB
Rust
656 lines
20 KiB
Rust
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"
|
||
);
|
||
}
|
||
|
||
#[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}"
|
||
);
|
||
}
|