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::>(); app.update(); app } fn press(app: &mut App, key: KeyCode) { let mut input = app.world_mut().resource_mut::>(); input.release(key); input.clear(); input.press(key); } #[test] fn defaults_are_loaded() { let app = headless_app(); assert_eq!( app.world().resource::().0, Settings::default() ); } #[test] fn pressing_left_bracket_decreases_volume_and_emits_event() { let mut app = headless_app(); let before = app.world().resource::().0.sfx_volume; press(&mut app, KeyCode::BracketLeft); app.update(); let after = app.world().resource::().0.sfx_volume; assert!(after < before); let events = app.world().resource::>(); 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::() .0 .sfx_volume = 0.5; press(&mut app, KeyCode::BracketRight); app.update(); let after = app.world().resource::().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::() .0 .sfx_volume = 1.0; press(&mut app, KeyCode::BracketRight); app.update(); let events = app.world().resource::>(); 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::() .0 .sfx_volume = 0.0; press(&mut app, KeyCode::BracketLeft); app.update(); let after = app.world().resource::().0.sfx_volume; assert!(after >= 0.0, "volume must not go below zero"); let events = app.world().resource::>(); 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::().0, "screen is closed initially" ); press(&mut app, KeyCode::KeyO); app.update(); assert!( app.world().resource::().0, "O opens settings" ); press(&mut app, KeyCode::KeyO); app.update(); assert!( !app.world().resource::().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::() .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::().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::() .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::>(); 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::().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