From 18d7c121a312721a27c87fd9d75bd0f9ec37e8cc Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 30 Apr 2026 03:24:32 +0000 Subject: [PATCH] feat(engine): convert OnboardingPlugin to 3-slide modal flow Replace the single-screen first-run banner with a 3-slide flow built on the ui_modal scaffold: 1. Welcome 2. How to play (drag-and-drop / double-click / right-click hints) 3. Keyboard shortcuts (8 rows mirroring help_plugin's canonical list) Navigation: primary Next button (advances; final slide reads "Start playing" and writes first_run_complete), secondary Back button (slide >0), tertiary Skip on slide 0. Arrow / Enter / Esc keep working as accelerators. OnboardingSlideIndex resource persists across despawn/respawn so the rebuild system always knows which slide to show next. All colours, spacing, typography come from ui_theme tokens; no literals in the new code. cargo build / cargo clippy --workspace -- -D warnings / cargo test --workspace all green (813 passed, 0 failed, 8 ignored). --- solitaire_engine/src/onboarding_plugin.rs | 752 ++++++++++++++++++---- 1 file changed, 610 insertions(+), 142 deletions(-) diff --git a/solitaire_engine/src/onboarding_plugin.rs b/solitaire_engine/src/onboarding_plugin.rs index cec3a78..53f643f 100644 --- a/solitaire_engine/src/onboarding_plugin.rs +++ b/solitaire_engine/src/onboarding_plugin.rs @@ -1,155 +1,430 @@ -//! First-run onboarding banner. +//! First-run onboarding multi-slide flow. //! -//! On startup, if `Settings.first_run_complete` is `false`, spawn a centered -//! welcome banner pointing at the **F1** cheat sheet. The first key or -//! mouse-button press dismisses it, sets the flag, and persists settings — -//! so returning players never see it again. +//! On startup, if `Settings.first_run_complete` is `false`, a three-slide +//! modal flow is shown. The player navigates with a primary `Next` button +//! (`→` / `Enter` accelerators) and a secondary `Back` button (`←`). +//! The final slide's primary button is `Start playing`, which sets +//! `first_run_complete = true` and persists settings — exactly as the +//! previous single-screen implementation did. //! -//! **Key highlights** (#49): The key names **D** and **U** inside the -//! instructional text are rendered in a bright orange colour via `TextSpan` -//! children tagged with `KeyHighlightSpan`. +//! Slides: +//! +//! 1. **Welcome** — brief introduction to Solitaire Quest. +//! 2. **How to play** — drag-and-drop, double-click, and right-click hints. +//! 3. **Keyboard shortcuts** — a summary pulled from the same canonical list +//! used in `HelpScreen`. Accelerators: `Esc` anywhere in the flow skips +//! the whole thing (equivalent to `first_run_complete = true`). +//! +//! Slide state is tracked by the [`OnboardingSlideIndex`] resource (0-based, +//! max `SLIDE_COUNT - 1`). Button clicks and keyboard accelerators update the +//! resource, then `rebuild_slide` despawns the current modal and respawns the +//! next one. use std::path::PathBuf; use bevy::prelude::*; use solitaire_data::{save_settings_to, Settings}; +use crate::font_plugin::FontResource; use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; +use crate::ui_modal::{ + spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button, + spawn_modal_header, ButtonVariant, +}; +use crate::ui_theme::{ + BORDER_SUBTLE, RADIUS_SM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_CAPTION, TYPE_BODY, VAL_SPACE_1, + VAL_SPACE_2, VAL_SPACE_3, Z_ONBOARDING, +}; -/// Marker on the onboarding overlay root node. +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Total number of onboarding slides (0-based index goes 0..SLIDE_COUNT-1). +const SLIDE_COUNT: u8 = 3; + +// --------------------------------------------------------------------------- +// Components (private — never re-exported) +// --------------------------------------------------------------------------- + +/// Marker on the onboarding overlay scrim (root entity for this modal). #[derive(Component, Debug)] pub struct OnboardingScreen; -/// Marker on `TextSpan` entities that display a key name (D, U …) in the -/// onboarding banner. Colour distinct from body text; usable by tests and any -/// future flash-animation system. +/// Marker on the `Next` / `Start playing` primary button. #[derive(Component, Debug)] -pub struct KeyHighlightSpan; +struct OnboardingNextButton; -/// Body text colour — golden yellow matching the rest of the UI. -const BODY_COLOR: Color = Color::srgb(1.0, 0.87, 0.0); +/// Marker on the `Back` secondary button. +#[derive(Component, Debug)] +struct OnboardingBackButton; -/// Bright orange used for key-name spans so they stand out from body text. -const KEY_COLOR: Color = Color::srgb(1.0, 0.55, 0.1); +/// Marker on the `Skip` tertiary button (slide 0 only). +#[derive(Component, Debug)] +struct OnboardingSkipButton; -/// Shows a first-run welcome screen that introduces the controls and draw mode. -/// Sets `Settings::first_run_complete` once dismissed so it never appears again. +// --------------------------------------------------------------------------- +// Resource +// --------------------------------------------------------------------------- + +/// Which slide (0-indexed) the player is currently viewing. +/// +/// Persists across the despawn/respawn cycle so the rebuild system knows +/// which slide to spawn next. +#[derive(Resource, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct OnboardingSlideIndex(pub u8); + +// --------------------------------------------------------------------------- +// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the +// two screens stay in sync without a shared abstraction. +// --------------------------------------------------------------------------- + +/// A single `key — description` pair shown on slide 3. +struct HotkeyRow { + keys: &'static str, + description: &'static str, +} + +/// Most-used shortcuts from the `help_plugin` canonical list. +/// +/// Updating the list in `help_plugin.rs` should be mirrored here. The +/// ARCHITECTURE.md decision log calls out that we copy values rather than +/// refactor the help plugin. +const HOTKEYS: &[HotkeyRow] = &[ + HotkeyRow { keys: "D / Space", description: "Draw from stock" }, + HotkeyRow { keys: "U", description: "Undo last move" }, + HotkeyRow { keys: "N", description: "New Classic game" }, + HotkeyRow { keys: "S", description: "Stats & progression" }, + HotkeyRow { keys: "A", description: "Achievements" }, + HotkeyRow { keys: "O", description: "Settings" }, + HotkeyRow { keys: "Esc", description: "Pause / resume" }, + HotkeyRow { keys: "F1", description: "Help / controls" }, +]; + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// Drives the first-run multi-slide onboarding flow. pub struct OnboardingPlugin; impl Plugin for OnboardingPlugin { fn build(&self, app: &mut App) { - app.add_systems(PostStartup, spawn_if_first_run) - .add_systems(Update, dismiss_on_any_input); + app.init_resource::() + .add_systems(PostStartup, spawn_if_first_run) + .add_systems( + Update, + ( + handle_onboarding_buttons, + handle_onboarding_keyboard, + ) + .chain(), + ); } } -fn spawn_if_first_run(mut commands: Commands, settings: Option>) { - let Some(s) = settings else { - return; - }; +// --------------------------------------------------------------------------- +// Startup +// --------------------------------------------------------------------------- + +fn spawn_if_first_run( + mut commands: Commands, + settings: Option>, + font_res: Option>, + mut slide_index: ResMut, +) { + let Some(s) = settings else { return }; if s.0.first_run_complete { return; } - spawn_onboarding_screen(&mut commands); + slide_index.0 = 0; + spawn_slide(&mut commands, 0, font_res.as_deref()); } -fn dismiss_on_any_input( +// --------------------------------------------------------------------------- +// Button click handler +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn handle_onboarding_buttons( mut commands: Commands, - keys: Res>, - mouse: Res>, - mut settings: ResMut, - path: Option>, + next_buttons: Query<&Interaction, (With, Changed)>, + back_buttons: Query<&Interaction, (With, Changed)>, + skip_buttons: Query<&Interaction, (With, Changed)>, screens: Query>, + mut slide_index: ResMut, + mut settings: Option>, + path: Option>, + font_res: Option>, ) { - let Ok(entity) = screens.single() else { - return; - }; - let pressed = keys.get_just_pressed().next().is_some() - || mouse.get_just_pressed().next().is_some(); - if !pressed { + let next_pressed = next_buttons.iter().any(|i| *i == Interaction::Pressed); + let back_pressed = back_buttons.iter().any(|i| *i == Interaction::Pressed); + let skip_pressed = skip_buttons.iter().any(|i| *i == Interaction::Pressed); + + if !next_pressed && !back_pressed && !skip_pressed { return; } - commands.entity(entity).despawn(); - settings.0.first_run_complete = true; - persist(path.as_deref().map(|p| &p.0), &settings.0); + + if skip_pressed || (next_pressed && slide_index.0 == SLIDE_COUNT - 1) { + // Skip or final-slide "Start playing" — complete onboarding. + complete_onboarding( + &mut commands, + &screens, + settings.as_deref_mut(), + path.as_deref(), + ); + return; + } + + // Navigate between slides. + let new_index = if next_pressed { + (slide_index.0 + 1).min(SLIDE_COUNT - 1) + } else { + slide_index.0.saturating_sub(1) + }; + + if new_index != slide_index.0 { + despawn_screen(&mut commands, &screens); + slide_index.0 = new_index; + spawn_slide(&mut commands, new_index, font_res.as_deref()); + } +} + +// --------------------------------------------------------------------------- +// Keyboard accelerator handler +// --------------------------------------------------------------------------- + +fn handle_onboarding_keyboard( + mut commands: Commands, + keys: Res>, + screens: Query>, + mut slide_index: ResMut, + mut settings: Option>, + path: Option>, + font_res: Option>, +) { + if screens.is_empty() { + return; + } + + let advance = keys.just_pressed(KeyCode::ArrowRight) || keys.just_pressed(KeyCode::Enter); + let retreat = keys.just_pressed(KeyCode::ArrowLeft); + let skip = keys.just_pressed(KeyCode::Escape); + + if skip || (advance && slide_index.0 == SLIDE_COUNT - 1) { + complete_onboarding( + &mut commands, + &screens, + settings.as_deref_mut(), + path.as_deref(), + ); + return; + } + + if advance { + let new_index = (slide_index.0 + 1).min(SLIDE_COUNT - 1); + if new_index != slide_index.0 { + despawn_screen(&mut commands, &screens); + slide_index.0 = new_index; + spawn_slide(&mut commands, new_index, font_res.as_deref()); + } + } else if retreat && slide_index.0 > 0 { + let new_index = slide_index.0 - 1; + despawn_screen(&mut commands, &screens); + slide_index.0 = new_index; + spawn_slide(&mut commands, new_index, font_res.as_deref()); + } +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn despawn_screen(commands: &mut Commands, screens: &Query>) { + for entity in screens { + commands.entity(entity).despawn(); + } +} + +fn complete_onboarding( + commands: &mut Commands, + screens: &Query>, + settings: Option<&mut SettingsResource>, + path: Option<&SettingsStoragePath>, +) { + despawn_screen(commands, screens); + if let Some(s) = settings { + s.0.first_run_complete = true; + persist(path.map(|p| &p.0), &s.0); + } } fn persist(path: Option<&Option>, settings: &Settings) { - let Some(Some(target)) = path else { - return; - }; + let Some(Some(target)) = path else { return }; if let Err(e) = save_settings_to(target, settings) { warn!("failed to save settings (onboarding): {e}"); } } -fn spawn_onboarding_screen(commands: &mut Commands) { - commands - .spawn(( - OnboardingScreen, - Node { - position_type: PositionType::Absolute, - left: Val::Percent(0.0), - top: Val::Percent(0.0), - width: Val::Percent(100.0), - height: Val::Percent(100.0), - flex_direction: FlexDirection::Column, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - row_gap: Val::Px(8.0), - ..default() - }, - BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.92)), - ZIndex(230), - )) - .with_children(|b| { - // Title - b.spawn(( - Text::new("Welcome to Solitaire Quest!"), - TextFont { font_size: 40.0, ..default() }, - TextColor(BODY_COLOR), - )); +// --------------------------------------------------------------------------- +// Slide spawning +// --------------------------------------------------------------------------- - // Spacer - b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() })); - - // Instruction line: "Drag cards between piles. Press D to draw, U to undo." - // D is tagged KeyHighlightSpan; U uses KEY_COLOR but not the marker. - b.spawn(( - Text::new("Drag cards between piles. Press "), - TextFont { font_size: 22.0, ..default() }, - TextColor(BODY_COLOR), - )) - .with_children(|t| { - t.spawn(( - TextSpan::new("D"), - TextColor(KEY_COLOR), - KeyHighlightSpan, - )); - t.spawn((TextSpan::new(" to draw, "), TextColor(BODY_COLOR))); - t.spawn((TextSpan::new("U"), TextColor(KEY_COLOR))); - t.spawn((TextSpan::new(" to undo."), TextColor(BODY_COLOR))); - }); - - // Help line: "Press F1 at any time to see the full controls." - b.spawn(( - Text::new("Press F1 at any time to see the full controls."), - TextFont { font_size: 22.0, ..default() }, - TextColor(BODY_COLOR), - )); - - // Spacer - b.spawn((Text::new(""), TextFont { font_size: 20.0, ..default() })); - - // Dismiss hint - b.spawn(( - Text::new("Press any key to begin"), - TextFont { font_size: 20.0, ..default() }, - TextColor(Color::srgb(0.8, 0.8, 0.8)), - )); - }); +fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResource>) { + match index { + 0 => spawn_slide_welcome(commands, font_res), + 1 => spawn_slide_how_to_play(commands, font_res), + 2 => spawn_slide_hotkeys(commands, font_res), + _ => spawn_slide_welcome(commands, font_res), + } } +/// Slide 1 — Welcome. +fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) { + spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { + spawn_modal_header(card, "Welcome to Solitaire Quest", font_res); + spawn_modal_body_text( + card, + "Solitaire Quest is a free, offline-first Klondike Solitaire game. \ + Play classic draw-1 or draw-3 Klondike, earn XP, unlock achievements, \ + and compete on the leaderboard. Your progress is saved locally — \ + optional sync to your own server keeps it in step across all your devices.", + TEXT_SECONDARY, + font_res, + ); + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + OnboardingSkipButton, + "Skip", + Some("Esc"), + ButtonVariant::Tertiary, + font_res, + ); + spawn_modal_button( + actions, + OnboardingNextButton, + "Next", + Some("→"), + ButtonVariant::Primary, + font_res, + ); + }); + }); +} + +/// Slide 2 — How to play. +fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) { + spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { + spawn_modal_header(card, "Drag cards to play", font_res); + spawn_modal_body_text( + card, + "Left-click and drag any face-up card to move it between piles. \ + You can drag a whole column at once by grabbing the topmost card \ + you want to move. Double-click a face-up card to send it to a \ + foundation pile automatically (when the move is legal). \ + Right-click a card for a hint — valid destinations will highlight.", + TEXT_SECONDARY, + font_res, + ); + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + OnboardingBackButton, + "Back", + Some("←"), + ButtonVariant::Secondary, + font_res, + ); + spawn_modal_button( + actions, + OnboardingNextButton, + "Next", + Some("→"), + ButtonVariant::Primary, + font_res, + ); + }); + }); +} + +/// Slide 3 — Keyboard shortcuts. +fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>) { + let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); + let font_row = TextFont { + font: font_handle.clone(), + font_size: TYPE_BODY, + ..default() + }; + let font_kbd = TextFont { + font: font_handle, + font_size: TYPE_CAPTION, + ..default() + }; + + spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { + spawn_modal_header(card, "Keyboard shortcuts", font_res); + + // Vertical list of `key — description` rows, same chip style as HelpScreen. + for row in HOTKEYS { + card.spawn(Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: VAL_SPACE_3, + ..default() + }) + .with_children(|line| { + line.spawn(( + Node { + padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1), + min_width: Val::Px(64.0), + justify_content: JustifyContent::Center, + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::all(Val::Px(RADIUS_SM)), + ..default() + }, + BorderColor::all(BORDER_SUBTLE), + )) + .with_children(|chip| { + chip.spawn(( + Text::new(row.keys), + font_kbd.clone(), + TextColor(TEXT_PRIMARY), + )); + }); + line.spawn(( + Text::new(row.description), + font_row.clone(), + TextColor(TEXT_PRIMARY), + )); + }); + } + + spawn_modal_actions(card, |actions| { + spawn_modal_button( + actions, + OnboardingBackButton, + "Back", + Some("←"), + ButtonVariant::Secondary, + font_res, + ); + spawn_modal_button( + actions, + OnboardingNextButton, + "Start playing", + Some("→"), + ButtonVariant::Primary, + font_res, + ); + }); + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + #[cfg(test)] mod tests { use super::*; @@ -172,17 +447,31 @@ mod tests { .count() } + fn current_slide(app: &App) -> u8 { + app.world().resource::().0 + } + + fn press_key(app: &mut App, key: KeyCode) { + let mut input = app.world_mut().resource_mut::>(); + input.release(key); + input.clear(); + input.press(key); + } + + // ----------------------------------------------------------------------- + // Basic visibility + // ----------------------------------------------------------------------- + #[test] - fn first_run_spawns_banner() { + fn first_run_spawns_onboarding() { let mut app = headless_app(); app.update(); // PostStartup runs assert_eq!(count_screens(&mut app), 1); } #[test] - fn returning_player_does_not_see_banner() { + fn returning_player_does_not_see_onboarding() { let mut app = headless_app(); - // Mark already-completed before PostStartup runs. app.world_mut() .resource_mut::() .0 @@ -192,61 +481,240 @@ mod tests { } #[test] - fn keypress_dismisses_and_sets_flag() { + fn starts_on_slide_zero() { let mut app = headless_app(); app.update(); - assert_eq!(count_screens(&mut app), 1); + assert_eq!(current_slide(&app), 0); + } - app.world_mut() - .resource_mut::>() - .press(KeyCode::Space); + // ----------------------------------------------------------------------- + // Next / Back navigation + // ----------------------------------------------------------------------- + + #[test] + fn next_button_advances_slide() { + let mut app = headless_app(); + app.update(); + assert_eq!(current_slide(&app), 0); + + // Spawn a Next button with Pressed interaction. + app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed)); app.update(); - assert_eq!(count_screens(&mut app), 0); + assert_eq!(current_slide(&app), 1, "Next must advance to slide 1"); + assert_eq!(count_screens(&mut app), 1, "exactly one modal must be visible"); + } + + #[test] + fn back_button_retreats_slide() { + let mut app = headless_app(); + app.update(); + // Manually move to slide 2. + app.world_mut().resource_mut::().0 = 2; + // Despawn the old screen and respawn slide 2. + { + let entities: Vec = app + .world_mut() + .query_filtered::>() + .iter(app.world()) + .collect(); + for e in entities { + app.world_mut().despawn(e); + } + } + + app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed)); + app.update(); + + assert_eq!(current_slide(&app), 1, "Back must retreat from slide 2 to slide 1"); + } + + #[test] + fn back_on_first_slide_does_not_underflow() { + let mut app = headless_app(); + app.update(); + assert_eq!(current_slide(&app), 0); + + // Pressing Back on slide 0 must be a no-op (no underflow to u8::MAX). + app.world_mut().spawn((OnboardingBackButton, Button, Interaction::Pressed)); + app.update(); + + assert_eq!(current_slide(&app), 0, "Back on slide 0 must not underflow"); + // The screen must still be present (we didn't skip or complete). + assert_eq!(count_screens(&mut app), 1); + } + + #[test] + fn next_cannot_advance_past_last_slide() { + let mut app = headless_app(); + app.update(); + app.world_mut().resource_mut::().0 = SLIDE_COUNT - 1; + + // Next on the last slide should complete onboarding, not advance further. + app.world_mut().spawn((OnboardingNextButton, Button, Interaction::Pressed)); + app.update(); + + // first_run_complete must be set. assert!( - app.world() - .resource::() - .0 - .first_run_complete, - "first_run_complete should flip to true" + app.world().resource::().0.first_run_complete, + "Next on last slide must set first_run_complete" ); + assert_eq!(count_screens(&mut app), 0, "modal must be gone after completion"); + } + + // ----------------------------------------------------------------------- + // Skip + // ----------------------------------------------------------------------- + + #[test] + fn skip_button_completes_onboarding_from_slide_zero() { + let mut app = headless_app(); + app.update(); + + app.world_mut().spawn((OnboardingSkipButton, Button, Interaction::Pressed)); + app.update(); + + assert!( + app.world().resource::().0.first_run_complete, + "Skip must set first_run_complete" + ); + assert_eq!(count_screens(&mut app), 0); + } + + // ----------------------------------------------------------------------- + // Keyboard accelerators + // ----------------------------------------------------------------------- + + #[test] + fn arrow_right_advances_slide() { + let mut app = headless_app(); + app.update(); + assert_eq!(current_slide(&app), 0); + + press_key(&mut app, KeyCode::ArrowRight); + app.update(); + + assert_eq!(current_slide(&app), 1); } #[test] - fn mouseclick_dismisses_banner() { + fn enter_advances_slide() { + let mut app = headless_app(); + app.update(); + + press_key(&mut app, KeyCode::Enter); + app.update(); + + assert_eq!(current_slide(&app), 1); + } + + #[test] + fn arrow_left_retreats_slide() { + let mut app = headless_app(); + app.update(); + app.world_mut().resource_mut::().0 = 1; + // Re-spawn a screen so the keyboard handler finds one. + app.world_mut().spawn(OnboardingScreen); + + press_key(&mut app, KeyCode::ArrowLeft); + app.update(); + + assert_eq!(current_slide(&app), 0); + } + + #[test] + fn esc_skips_onboarding() { let mut app = headless_app(); app.update(); assert_eq!(count_screens(&mut app), 1); - app.world_mut() - .resource_mut::>() - .press(MouseButton::Left); + press_key(&mut app, KeyCode::Escape); app.update(); + assert_eq!(count_screens(&mut app), 0, "Esc must dismiss onboarding"); + assert!( + app.world().resource::().0.first_run_complete, + "Esc must set first_run_complete" + ); + } + + #[test] + fn enter_on_last_slide_completes_onboarding() { + let mut app = headless_app(); + app.update(); + app.world_mut().resource_mut::().0 = SLIDE_COUNT - 1; + // Ensure a screen exists for the keyboard handler. + app.world_mut().spawn(OnboardingScreen); + + press_key(&mut app, KeyCode::Enter); + app.update(); + + assert!( + app.world().resource::().0.first_run_complete, + "Enter on last slide must complete onboarding" + ); assert_eq!(count_screens(&mut app), 0); } + // ----------------------------------------------------------------------- + // Slide-index bounds + // ----------------------------------------------------------------------- + #[test] - fn banner_has_key_highlight_span_for_d() { - // D must be tagged KeyHighlightSpan so its colour is distinct from body - // text and future flash-animation systems can target it. - let mut app = headless_app(); - app.update(); - let count = app - .world_mut() - .query::<&KeyHighlightSpan>() - .iter(app.world()) - .count(); - assert_eq!(count, 1, "expected KeyHighlightSpan for D"); + fn slide_count_constant_is_three() { + assert_eq!(SLIDE_COUNT, 3, "SLIDE_COUNT must be 3"); } #[test] - fn key_highlight_colour_differs_from_body_colour() { - // Regression guard: KEY_COLOR must not accidentally match BODY_COLOR. - assert_ne!( - format!("{KEY_COLOR:?}"), - format!("{BODY_COLOR:?}"), - "key highlight colour should differ from body text colour" + fn slide_index_default_is_zero() { + let idx = OnboardingSlideIndex::default(); + assert_eq!(idx.0, 0); + } + + // ----------------------------------------------------------------------- + // Completion semantics + // ----------------------------------------------------------------------- + + #[test] + fn keypress_on_last_slide_sets_first_run_complete() { + let mut app = headless_app(); + app.update(); + + // Navigate to the last slide via arrow keys. + for _ in 0..(SLIDE_COUNT - 1) { + press_key(&mut app, KeyCode::ArrowRight); + app.update(); + { + let mut input = app.world_mut().resource_mut::>(); + input.clear(); + } + } + assert_eq!(current_slide(&app), SLIDE_COUNT - 1); + + press_key(&mut app, KeyCode::Enter); + app.update(); + + assert!( + app.world().resource::().0.first_run_complete, + "completing the last slide must set first_run_complete" ); + assert_eq!(count_screens(&mut app), 0); + } + + // ----------------------------------------------------------------------- + // Hotkey list is non-empty (guards against accidental truncation) + // ----------------------------------------------------------------------- + + #[test] + fn hotkey_list_is_non_empty() { + assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty"); + } + + #[test] + fn all_hotkey_rows_have_non_empty_fields() { + for row in HOTKEYS { + assert!(!row.keys.is_empty(), "hotkey key field must not be empty"); + assert!(!row.description.is_empty(), "hotkey description must not be empty"); + } } }