use super::*; use chrono::NaiveDate; use solitaire_core::{DrawStockConfig, game_state::GameMode}; use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau}; use solitaire_core::{Rank, Suit}; use solitaire_data::Replay; /// Build a minimal but well-formed [`Replay`] with `move_count` no-op /// `RotateStock` entries. Tests only ever read `replay.moves.len()` /// (denominator of the progress indicator), so the move kind is /// irrelevant beyond producing the right count. fn synthetic_replay(move_count: usize) -> Replay { Replay::new( 42, DrawStockConfig::DrawOne, GameMode::Classic, 120, 1_000, NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"), SessionRecording::from_instructions_unchecked( 42, DrawStockConfig::DrawOne, (0..move_count).map(|_| KlondikeInstruction::RotateStock), ), ) } /// Build a test app that has the overlay plugin but **not** the /// playback plugin — tests insert `ReplayPlaybackState` manually so /// they can drive every state transition deterministically. fn headless_app() -> App { let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(ReplayOverlayPlugin); app.init_resource::(); app } /// Count `ReplayOverlayRoot` entities in the world — the overlay's /// presence/absence is the spawn-test's primary observable. fn overlay_root_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayRoot>() .iter(app.world()) .count() } /// Read the current text content of the unique progress-text entity. fn progress_text(app: &mut App) -> String { let mut q = app .world_mut() .query_filtered::<&Text, With>(); q.iter(app.world()) .next() .map(|t| t.0.clone()) .unwrap_or_default() } /// Read the current text content of the unique banner-label entity. fn banner_text(app: &mut App) -> String { let mut q = app .world_mut() .query_filtered::<&Text, With>(); q.iter(app.world()) .next() .map(|t| t.0.clone()) .unwrap_or_default() } /// Set the playback resource without going through the playback core. fn set_state(app: &mut App, state: ReplayPlaybackState) { app.world_mut().insert_resource(state); } /// Find the unique `ReplayStopButton` entity for the click-handler /// test. There must be exactly one. fn stop_button_entity(app: &mut App) -> Entity { let mut q = app .world_mut() .query_filtered::>(); q.iter(app.world()) .next() .expect("Stop button must exist while overlay is spawned") } /// Going `Inactive → Playing` spawns exactly one overlay root and /// the banner label reads "▌ replay". #[test] fn overlay_spawns_when_playback_starts() { let mut app = headless_app(); // First update with the default `Inactive` resource — overlay // must not exist yet. app.update(); assert_eq!(overlay_root_count(&mut app), 0); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( overlay_root_count(&mut app), 1, "exactly one ReplayOverlayRoot must spawn on Inactive → Playing", ); assert_eq!(banner_text(&mut app), "\u{258C} replay"); } /// The progress-text entity reads `"Move {cursor} of {total}"` for a /// well-formed `Playing` state. #[test] fn overlay_progress_text_reflects_cursor() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); assert_eq!(progress_text(&mut app), "MOVE 5/10"); } /// Pressing the Stop button resets the state back to `Inactive` and /// the next frame's `react_to_state_change` despawns the overlay. /// Mirrors the synthetic `Interaction::Pressed` insertion pattern /// used elsewhere in the engine for headless click tests. #[test] fn overlay_stop_button_click_clears_playback() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(overlay_root_count(&mut app), 1); let stop = stop_button_entity(&mut app); app.world_mut() .entity_mut(stop) .insert(Interaction::Pressed); // Tick once: the click handler runs late in the frame and resets // the state to `Inactive`. app.update(); // State must be back to Inactive. let state = app.world().resource::(); assert!( matches!(state, ReplayPlaybackState::Inactive), "Stop click must reset ReplayPlaybackState to Inactive; got {state:?}", ); // One more tick — `react_to_state_change` sees the resource // change to Inactive and despawns the overlay. app.update(); assert_eq!( overlay_root_count(&mut app), 0, "overlay must despawn the frame after state returns to Inactive", ); } /// Lifecycle: the floating progress chip spawns alongside the /// banner overlay when playback starts, and despawns when /// playback ends. (Position correctness needs `LayoutResource`, /// which isn't set up in this headless fixture; the lifecycle /// test below is what's load-bearing for the spawn/despawn /// pairing.) #[test] fn floating_chip_spawns_and_despawns_with_overlay() { let mut app = headless_app(); // Inactive → no chip. app.update(); assert_eq!( app.world_mut() .query::<&ReplayFloatingProgressChip>() .iter(app.world()) .count(), 0, "no floating chip while playback is Inactive", ); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ); app.update(); assert_eq!( app.world_mut() .query::<&ReplayFloatingProgressChip>() .iter(app.world()) .count(), 1, "floating chip must spawn when playback starts", ); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( app.world_mut() .query::<&ReplayFloatingProgressChip>() .iter(app.world()) .count(), 0, "floating chip must despawn when playback ends", ); } /// Manually flipping the resource back to `Inactive` (e.g. via the /// playback core's auto-clear after `Completed`) tears the overlay /// down without any further input. #[test] fn overlay_despawns_when_playback_returns_to_inactive() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false), ); app.update(); assert_eq!(overlay_root_count(&mut app), 1); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( overlay_root_count(&mut app), 0, "overlay must despawn on Playing → Inactive transition", ); } /// On `Playing → Completed` the banner label updates in place rather /// than respawning. The overlay must still be present, and the label /// must read "▌ replay complete". #[test] fn overlay_text_changes_on_completed() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false), ); app.update(); assert_eq!(banner_text(&mut app), "\u{258C} replay"); set_state(&mut app, ReplayPlaybackState::Completed); app.update(); assert_eq!( overlay_root_count(&mut app), 1, "overlay must remain spawned while in Completed state", ); assert_eq!( banner_text(&mut app), "\u{258C} replay complete", "banner label must swap on Playing → Completed", ); } /// Read the current `Node.width` of the unique scrub-fill entity as /// a percentage. Assertions can then compare against expected /// `cursor / total` ratios without poking ECS internals at the call /// site. fn scrub_fill_pct(app: &mut App) -> f32 { let mut q = app .world_mut() .query_filtered::<&Node, With>(); let node = q .iter(app.world()) .next() .expect("scrub-fill node must exist while overlay is spawned"); match node.width { Val::Percent(p) => p, other => panic!("scrub fill width must be Val::Percent; got {other:?}"), } } /// Pure-helper guard. Locks in the four corners of `scrub_pct` so a /// future refactor of `ReplayPlaybackState::progress()` can't /// silently regress the visual cue: `Inactive → 0 %`, /// `Playing { cursor: 0, total: N } → 0 %`, /// `Playing { cursor: N/2, total: N } → 50 %`, /// `Completed → 100 %`. #[test] fn scrub_pct_covers_state_corners() { assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0); assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0); assert_eq!( scrub_pct(&ReplayPlaybackState::playing( synthetic_replay(10), 0, 0.5, false, )), 0.0, ); assert_eq!( scrub_pct(&ReplayPlaybackState::playing( synthetic_replay(10), 5, 0.5, false, )), 50.0, ); assert_eq!( scrub_pct(&ReplayPlaybackState::playing( synthetic_replay(10), 10, 0.5, false, )), 100.0, ); } /// Read the current text content of the unique GAME-caption entity. fn game_caption_text(app: &mut App) -> String { let mut q = app .world_mut() .query_filtered::<&Text, With>(); q.iter(app.world()) .next() .map(|t| t.0.clone()) .unwrap_or_default() } /// Pure-helper guard. `Inactive` / `Completed` carry no replay /// reference so the caption is `None`; `Playing` formats the /// recorded-date as `GAME #YYYY-DDD` with a 3-digit zero-padded /// ordinal. Locks all three branches so a future refactor can't /// silently regress the identifier shape. #[test] fn format_game_caption_covers_state_corners() { assert_eq!(format_game_caption(&ReplayPlaybackState::Inactive), None); assert_eq!(format_game_caption(&ReplayPlaybackState::Completed), None); // 2026-05-02 is the 122nd day of 2026 (Jan = 31, Feb = 28, // Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always // uses this date so the assertion is stable. assert_eq!( format_game_caption(&ReplayPlaybackState::playing( synthetic_replay(10), 5, 0.5, false, )), Some("GAME #2026-122".to_string()), ); // Single-digit ordinal must zero-pad to three digits — pin // the format string in case someone simplifies to `{}-{}`. let mut early_january = synthetic_replay(10); early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date"); assert_eq!( format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)), Some("GAME #2026-005".to_string()), ); } /// End-to-end: spawning the overlay paints the GAME caption with /// the active replay's recorded date in `YYYY-DDD` form. Caption /// is empty for `Completed` since the replay is consumed. #[test] fn overlay_game_caption_shows_replay_date() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(game_caption_text(&mut app), "GAME #2026-122"); // Caption empties out on Playing → Completed because // `format_game_caption` returns None and the spawn-path // helper falls through to `unwrap_or_default()`. // The overlay itself stays spawned in `Completed`. set_state(&mut app, ReplayPlaybackState::Completed); app.update(); assert_eq!( overlay_root_count(&mut app), 1, "overlay must remain spawned while in Completed state", ); } /// End-to-end: the spawn path must paint the scrub fill at the /// initial cursor's percentage, and the per-frame `update_scrub_fill` /// system must repaint it as the cursor advances. Mirrors the shape /// of `overlay_progress_text_reflects_cursor`. #[test] fn overlay_scrub_fill_tracks_cursor() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false), ); app.update(); assert_eq!( scrub_fill_pct(&mut app), 25.0, "spawn-time fill must reflect the initial cursor", ); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false), ); app.update(); assert_eq!( scrub_fill_pct(&mut app), 75.0, "update_scrub_fill must repaint width on cursor advance", ); set_state(&mut app, ReplayPlaybackState::Completed); app.update(); assert_eq!( scrub_fill_pct(&mut app), 100.0, "Completed state must read as a fully-filled track", ); } // ----------------------------------------------------------------------- // win_move_marker_pct + ReplayOverlayWinMoveMarker spawn behaviour // ----------------------------------------------------------------------- fn win_marker_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayWinMoveMarker>() .iter(app.world()) .count() } #[test] fn win_move_marker_pct_is_none_for_inactive() { assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Inactive), None); } #[test] fn win_move_marker_pct_is_none_for_completed() { // `Completed` carries no replay so the marker has no data to // anchor against — the overlay treats this as "no marker". assert_eq!(win_move_marker_pct(&ReplayPlaybackState::Completed), None); } #[test] fn win_move_marker_pct_is_none_when_replay_lacks_field() { // Synthetic replay constructor leaves win_move_index as None // (legacy / pre-`ab857bb` path). let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); assert_eq!(win_move_marker_pct(&state), None); } #[test] fn win_move_marker_pct_is_some_at_correct_position() { // 10 moves, win at index 9 → marker sits at 90 % of the track. // Matches the recording semantic: cursor reaches the marker // exactly when the about-to-apply move IS the win move. let state = ReplayPlaybackState::playing( synthetic_replay(10).with_win_move_index(Some(9)), 0, 0.5, false, ); assert_eq!(win_move_marker_pct(&state), Some(90.0)); } #[test] fn win_move_marker_pct_clamps_to_track_bounds() { // Defensive: if a malformed replay carried `win_move_index >= // total`, the marker must still sit on the track, not past it. let state = ReplayPlaybackState::playing( synthetic_replay(5).with_win_move_index(Some(99)), 0, 0.5, false, ); assert_eq!(win_move_marker_pct(&state), Some(100.0)); } #[test] fn marker_spawned_when_replay_has_win_move_index() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing( synthetic_replay(8).with_win_move_index(Some(7)), 0, 0.5, false, ), ); app.update(); assert_eq!( win_marker_count(&mut app), 1, "marker entity must spawn when replay carries Some(win_move_index)" ); } #[test] fn marker_not_spawned_when_replay_lacks_win_move_index() { let mut app = headless_app(); // Default constructor → win_move_index: None (legacy replay). set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false), ); app.update(); assert_eq!( win_marker_count(&mut app), 0, "no marker should spawn for a replay pre-dating the field" ); } #[test] fn marker_despawns_when_replay_state_returns_to_inactive() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing( synthetic_replay(8).with_win_move_index(Some(7)), 0, 0.5, false, ), ); app.update(); assert_eq!(win_marker_count(&mut app), 1); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( win_marker_count(&mut app), 0, "marker must despawn with the rest of the overlay tree" ); } /// The WIN MOVE marker carries `HighContrastBackground::with_hc( /// STATE_SUCCESS, STATE_SUCCESS_HC)` so the lime bumps to brighter /// lime under HC mode rather than to a neutral gray. Pin the /// presence of the marker so a future refactor can't accidentally /// drop it and silently regress HC legibility. #[test] fn win_move_marker_carries_hc_background_marker() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing( synthetic_replay(8).with_win_move_index(Some(7)), 0, 0.5, false, ), ); app.update(); let mut q = app .world_mut() .query_filtered::<&HighContrastBackground, With>(); let marker = q .iter(app.world()) .next() .expect("WIN MOVE marker must carry HighContrastBackground"); assert_eq!( marker.default_color, STATE_SUCCESS, "default colour must be STATE_SUCCESS" ); assert_eq!( marker.hc_color, STATE_SUCCESS_HC, "HC colour must be STATE_SUCCESS_HC (brighter lime, not gray)" ); } // ----------------------------------------------------------------------- // scrub_notch_positions + ReplayOverlayScrubNotch spawn behaviour // ----------------------------------------------------------------------- fn scrub_notch_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayScrubNotch>() .iter(app.world()) .count() } /// Pure-helper guard. Locks in the five-notch ladder at the /// quarter-marks. A future simplification to fewer notches (or a /// shift to non-quarter spacing) must touch this test, surfacing /// the visual change at review time. #[test] fn scrub_notch_positions_are_quarter_marks() { assert_eq!( scrub_notch_positions(), [0.0, 25.0, 50.0, 75.0, 100.0], "scrub notches must sit at the five quarter-mark percentages", ); } /// Five notch entities spawn alongside the rest of the overlay /// tree on `Inactive → Playing`. Cardinality matches /// `scrub_notch_positions().len()`. #[test] fn scrub_notches_spawn_with_overlay() { let mut app = headless_app(); app.update(); assert_eq!(scrub_notch_count(&mut app), 0); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( scrub_notch_count(&mut app), scrub_notch_positions().len(), "exactly one notch entity per quarter-mark must spawn", ); } /// Each spawned notch carries `HighContrastBackground` so the /// existing `update_high_contrast_backgrounds` system bumps /// `BORDER_SUBTLE` → `BORDER_SUBTLE_HC` under HC mode. /// Five-of-five — every notch tagged. #[test] fn scrub_notches_carry_high_contrast_background_marker() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); let count = app .world_mut() .query_filtered::<&HighContrastBackground, With>() .iter(app.world()) .count(); assert_eq!( count, scrub_notch_positions().len(), "every notch must carry HighContrastBackground for HC repaint coverage", ); } /// The 1 px scrub track also carries `HighContrastBackground` so /// the unfilled portion bumps under HC. The fill (ACCENT_PRIMARY, /// brick-red) doesn't need a marker — accent colours are /// already saturated and don't need an HC variant. #[test] fn scrub_track_carries_high_contrast_background_marker() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); // Track is the parent Node of the scrub-fill. Find it by // walking up from `ReplayOverlayScrubFill` to its parent. let world = app.world_mut(); let mut fill_q = world.query_filtered::>(); let fill = fill_q .iter(world) .next() .expect("scrub fill must exist while overlay is spawned"); let mut parent_q = world.query::<&ChildOf>(); let parent = parent_q .get(world, fill) .map(|p| p.parent()) .expect("scrub fill must have a parent (the track)"); let mut hc_q = world.query::<&HighContrastBackground>(); assert!( hc_q.get(world, parent).is_ok(), "scrub track Node (parent of scrub fill) must carry HighContrastBackground", ); } /// Notches share the overlay tree's lifecycle — they despawn on /// `Playing → Inactive` along with the banner root. #[test] fn scrub_notches_despawn_with_overlay() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(scrub_notch_count(&mut app), 5); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( scrub_notch_count(&mut app), 0, "notches must despawn with the rest of the overlay tree", ); } fn scrub_notch_label_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayScrubNotchLabel>() .iter(app.world()) .count() } /// Returns the rendered text of every `ReplayOverlayScrubNotchLabel` /// in left-to-right order — the iteration order isn't guaranteed by /// the ECS query, so callers needing a stable order must sort. fn scrub_notch_label_texts(app: &mut App) -> Vec { let world = app.world_mut(); let mut q = world.query_filtered::<&Text, With>(); q.iter(world).map(|t| t.0.clone()).collect() } /// Pure-helper guard for the label strings. Pairs with /// `scrub_notch_positions_are_quarter_marks` — same length, same /// order, so `labels[i]` belongs at `positions[i]`. #[test] fn scrub_notch_labels_are_quarter_mark_percents() { assert_eq!( scrub_notch_labels(), ["0%", "25%", "50%", "75%", "100%"], "scrub notch labels must read as the five quarter-mark percentages", ); assert_eq!( scrub_notch_labels().len(), scrub_notch_positions().len(), "labels and positions must remain paired one-to-one", ); } /// Five label entities spawn alongside the rest of the overlay. /// Cardinality matches `scrub_notch_labels().len()`. #[test] fn scrub_notch_labels_spawn_with_overlay() { let mut app = headless_app(); app.update(); assert_eq!(scrub_notch_label_count(&mut app), 0); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( scrub_notch_label_count(&mut app), scrub_notch_labels().len(), "exactly one label entity per notch must spawn", ); } /// Each spawned label carries one of the helper's strings — pins /// the spawn-path against drift between the helper and the actual /// painted text. #[test] fn scrub_notch_labels_carry_helper_strings() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); let mut texts = scrub_notch_label_texts(&mut app); texts.sort(); let mut expected: Vec = scrub_notch_labels().iter().map(|s| s.to_string()).collect(); expected.sort(); assert_eq!( texts, expected, "spawned label texts must equal the helper's strings (set equality, ECS order is not guaranteed)", ); } /// Labels share the overlay tree's lifecycle — they despawn on /// `Playing → Inactive` along with the banner root. #[test] fn scrub_notch_labels_despawn_with_overlay() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(scrub_notch_label_count(&mut app), 5); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( scrub_notch_label_count(&mut app), 0, "labels must despawn with the rest of the overlay tree", ); } fn keybind_footer_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayKeybindFooter>() .iter(app.world()) .count() } /// Returns every `Text` rendered as a descendant of the /// keybind-footer row. Used to assert the mode + hint texts /// appear inside the footer without requiring per-text markers. fn keybind_footer_text_set(app: &mut App) -> Vec { let world = app.world_mut(); // Find the footer entity, then walk its descendants for `Text`. let mut footer_q = world.query_filtered::>(); let Some(footer) = footer_q.iter(world).next() else { return Vec::new(); }; let mut child_q = world.query::<&Children>(); let Ok(children) = child_q.get(world, footer) else { return Vec::new(); }; let child_entities: Vec = children.iter().collect(); let mut text_q = world.query::<&Text>(); child_entities .into_iter() .filter_map(|e| text_q.get(world, e).ok().map(|t| t.0.clone())) .collect() } /// Pure-helper guards for the static text strings. Pin both /// helpers so a future refactor that reformats the mode line /// or extends the hint with un-wired keybinds fails at the /// helper test rather than at visual review. #[test] fn keybind_footer_helpers_carry_expected_text() { assert_eq!( keybind_footer_mode_text(), "\u{258C} NORMAL \u{2502} replay", "mode line must read as the cursor-block + NORMAL + bar + replay motif", ); assert_eq!( keybind_footer_hint_text(), "[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step", "hint text must list all three wired keybind groups (Space → pause/resume, Esc → stop, ←→ → step) separated by middle dots", ); } /// Footer entity spawns alongside the rest of the overlay tree /// on `Inactive → Playing`. Cardinality is exactly one — the /// footer is a singleton row, not a per-keybind multiple. #[test] fn keybind_footer_spawns_with_overlay() { let mut app = headless_app(); app.update(); assert_eq!(keybind_footer_count(&mut app), 0); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( keybind_footer_count(&mut app), 1, "exactly one keybind-footer row must spawn with the overlay", ); } /// Spawned footer carries both helper strings as direct-child /// `Text` content — pins the spawn-path against drift between /// the helpers and the actual painted text. #[test] fn keybind_footer_paints_helper_strings() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); let texts = keybind_footer_text_set(&mut app); assert!( texts.contains(&keybind_footer_mode_text().to_string()), "footer must contain the mode-line text; got {texts:?}", ); assert!( texts.contains(&keybind_footer_hint_text().to_string()), "footer must contain the keybind-hint text; got {texts:?}", ); } /// Spawned footer carries `HighContrastBorder` so the existing /// `apply_high_contrast_borders` system bumps the 1 px top /// border under HC mode. Without this the footer reads as /// floating loose under HC. #[test] fn keybind_footer_carries_high_contrast_border_marker() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); let mut q = app .world_mut() .query_filtered::<&HighContrastBorder, With>(); let marker = q.iter(app.world()).next(); assert!( marker.is_some(), "footer must carry HighContrastBorder so `apply_high_contrast_borders` picks it up under HC mode", ); } /// Footer shares the overlay tree's lifecycle — it despawns on /// `Playing → Inactive` along with the banner root. #[test] fn keybind_footer_despawns_with_overlay() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(keybind_footer_count(&mut app), 1); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( keybind_footer_count(&mut app), 0, "footer must despawn with the rest of the overlay tree", ); } /// Notches are independent of `win_move_index` — a replay with no /// win marker still gets the full five-notch ladder (notches give /// quarter-mark anchor points; the win marker is an additional /// overlay on top of them, not a replacement). #[test] fn scrub_notches_spawn_even_without_win_marker() { let mut app = headless_app(); // Default constructor → win_move_index: None. set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false), ); app.update(); assert_eq!( scrub_notch_count(&mut app), 5, "notches and win marker are independent — no marker doesn't drop the notches", ); } // ----------------------------------------------------------------------- // Move Log panel: helpers + spawn cardinality + lifecycle // ----------------------------------------------------------------------- fn move_log_panel_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayMoveLogPanel>() .iter(app.world()) .count() } fn move_log_header_text(app: &mut App) -> String { let mut q = app .world_mut() .query_filtered::<&Text, With>(); q.iter(app.world()) .next() .map(|t| t.0.clone()) .unwrap_or_default() } fn move_log_active_row_text(app: &mut App) -> String { let mut q = app .world_mut() .query_filtered::<&Text, With>(); q.iter(app.world()) .next() .map(|t| t.0.clone()) .unwrap_or_default() } /// Pile formatter pins the "lowercase + 1-indexed" contract. /// `Foundation(2)` displays as `"foundation 3"` rather than /// the underlying 0-index — players see human-friendly numbers. #[test] fn format_pile_uses_one_indexed_lowercase_names() { assert_eq!(format_pile(&KlondikePile::Stock), "waste"); assert_eq!( format_pile(&KlondikePile::Foundation(Foundation::Foundation1)), "foundation 1" ); assert_eq!( format_pile(&KlondikePile::Foundation(Foundation::Foundation3)), "foundation 3" ); assert_eq!( format_pile(&KlondikePile::Tableau(Tableau::Tableau1)), "tableau 1" ); assert_eq!( format_pile(&KlondikePile::Tableau(Tableau::Tableau7)), "tableau 7" ); } /// Move-body formatter renders `RotateStock` as a label. The /// `Dst*` variants render as a `→ to` arrow, but their pile-stack /// source types are runtime-only and not constructible from this /// crate, so only the stock-cycle label is asserted here; the /// arrow path is exercised end-to-end through the move-log /// integration tests. #[test] fn format_move_body_handles_stock_cycle() { assert_eq!( format_move_body(&KlondikeInstruction::RotateStock), "stock cycle" ); } /// Header text covers all three state branches: /// `Playing` → `▌ MOVE LOG · N/M`, /// `Completed` → `▌ MOVE LOG · COMPLETE`, /// `Inactive` → empty. #[test] fn format_move_log_header_covers_state_branches() { let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); assert_eq!( format_move_log_header(&playing), "\u{258C} MOVE LOG \u{00B7} 3/10" ); assert_eq!( format_move_log_header(&ReplayPlaybackState::Completed), "\u{258C} MOVE LOG \u{00B7} COMPLETE", ); assert_eq!(format_move_log_header(&ReplayPlaybackState::Inactive), ""); } /// Active-row text is empty at cursor 0 (no move applied yet) /// and populated otherwise. The displayed index is 1-based — /// when cursor=N, the most-recently-applied move is at /// `replay.moves[N - 1]` and the row reads `"N | ..."`. #[test] fn format_active_move_row_handles_cursor_zero_and_positive() { let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); assert_eq!( format_active_move_row(&cursor_zero), "", "cursor=0 means no move applied yet; row stays empty", ); let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); // synthetic_replay produces all StockClicks, so the body // is "stock cycle". The displayed index is 3 (cursor), // matching the most-recently-applied move at moves[2]. // Active row carries the `▶` focus prefix; prev rows // (kth-recent for k>1) don't. assert_eq!( format_active_move_row(&cursor_three), "\u{25B6} 3 \u{2502} stock cycle", "active row must read `▶ cursor │ {{move body}}` with the 1-based displayed index", ); } /// Move-log panel spawns alongside the rest of the overlay /// tree on `Inactive → Playing`. Cardinality is exactly one /// (singleton bottom-edge panel). #[test] fn move_log_panel_spawns_with_overlay() { let mut app = headless_app(); app.update(); assert_eq!(move_log_panel_count(&mut app), 0); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( move_log_panel_count(&mut app), 1, "exactly one move-log panel must spawn with the overlay", ); } /// Spawned panel's header reads `▌ MOVE LOG · N/M` matching /// the helper output for the active state. Pins the spawn-path /// against drift between the helper and the actual painted /// text. #[test] fn move_log_panel_header_paints_helper_string() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false), ); app.update(); assert_eq!( move_log_header_text(&mut app), "\u{258C} MOVE LOG \u{00B7} 2/8", ); } /// Active-row text repaints when the cursor advances. Drives /// the resource through cursor=0 → cursor=2 transitions and /// asserts the row text follows. #[test] fn move_log_active_row_repaints_on_cursor_advance() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!( move_log_active_row_text(&mut app), "", "cursor=0 must paint an empty row", ); // Advance cursor to 2 (most-recently-applied move is moves[1]). set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false), ); app.update(); assert_eq!( move_log_active_row_text(&mut app), "\u{25B6} 2 \u{2502} stock cycle", "active row must repaint to the cursor's position when state changes (with ▶ prefix)", ); } /// `format_kth_recent_row` covers the active-row helper for /// `k=1` and the prev-row helpers for `k>1`. Pins the "k larger /// than cursor returns empty" branch so under-filled panels /// early in a replay don't paint stale text. #[test] fn format_kth_recent_row_handles_in_range_and_out_of_range() { let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); // k=1 → active (most recent applied). cursor=3 → display=3. assert_eq!( format_kth_recent_row(&state_at_three, 1), "3 \u{2502} stock cycle", ); // k=2 → row above active. display=2. assert_eq!( format_kth_recent_row(&state_at_three, 2), "2 \u{2502} stock cycle", ); // k=3 → second-prev row. display=1. assert_eq!( format_kth_recent_row(&state_at_three, 3), "1 \u{2502} stock cycle", ); // k=4 — exceeds cursor, no history that far back. assert_eq!( format_kth_recent_row(&state_at_three, 4), "", "k > cursor must return empty (panel under-fills gracefully)", ); // k=0 — degenerate, no kth-most-recent for k=0. assert_eq!(format_kth_recent_row(&state_at_three, 0), ""); } fn move_log_prev_row_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayMoveLogPrevRow>() .iter(app.world()) .count() } fn move_log_prev_row_text_at_offset(app: &mut App, offset: u8) -> String { let world = app.world_mut(); let mut q = world.query::<(&ReplayOverlayMoveLogPrevRow, &Text)>(); for (row, text) in q.iter(world) { if row.offset == offset { return text.0.clone(); } } String::new() } /// `MOVE_LOG_PREV_ROWS` prev rows spawn with the panel — one /// per offset 1..=N. Cardinality matches the constant. #[test] fn move_log_prev_rows_spawn_with_panel() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); assert_eq!( move_log_prev_row_count(&mut app), MOVE_LOG_PREV_ROWS, "exactly MOVE_LOG_PREV_ROWS prev rows must spawn with the panel", ); } /// Each prev row's text at spawn time matches the helper /// output for its offset. Pins the spawn path against drift /// between marker offset and rendered text. #[test] fn move_log_prev_rows_paint_helper_strings_at_spawn() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); // offset 1 → k=2 → display=4 assert_eq!( move_log_prev_row_text_at_offset(&mut app, 1), "4 \u{2502} stock cycle", ); // offset 2 → k=3 → display=3 assert_eq!( move_log_prev_row_text_at_offset(&mut app, 2), "3 \u{2502} stock cycle", ); } /// Prev rows repaint as the cursor advances. Drives the /// resource through cursor=2 → cursor=5 and asserts the texts /// follow. #[test] fn move_log_prev_rows_repaint_on_cursor_advance() { let mut app = headless_app(); // Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor). set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false), ); app.update(); assert_eq!( move_log_prev_row_text_at_offset(&mut app, 1), "1 \u{2502} stock cycle", ); assert_eq!( move_log_prev_row_text_at_offset(&mut app, 2), "", "offset 2 (k=3) must be empty when cursor=2 (no history that far back)", ); // Advance to cursor=5 — both offsets now have history. set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); assert_eq!( move_log_prev_row_text_at_offset(&mut app, 1), "4 \u{2502} stock cycle", "offset 1 must repaint to k=2 of new cursor (display=4)", ); assert_eq!( move_log_prev_row_text_at_offset(&mut app, 2), "3 \u{2502} stock cycle", ); } fn move_log_next_row_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayOverlayMoveLogNextRow>() .iter(app.world()) .count() } fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String { let world = app.world_mut(); let mut q = world.query::<(&ReplayOverlayMoveLogNextRow, &Text)>(); for (row, text) in q.iter(world) { if row.offset == offset { return text.0.clone(); } } String::new() } /// `format_kth_next_row` covers the about-to-apply preview /// for `k=1` (the very next move) and beyond. Pins the /// "k=0 returns empty" + "out-of-range returns empty" cases /// alongside in-range correctness. #[test] fn format_kth_next_row_handles_in_range_and_out_of_range() { let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false); // k=1 → moves[3], display=4 assert_eq!( format_kth_next_row(&state_at_three, 1), "4 \u{2502} stock cycle", ); // k=2 → moves[4], display=5 assert_eq!( format_kth_next_row(&state_at_three, 2), "5 \u{2502} stock cycle", ); // k=8 — moves[10], out of range for a 10-move replay. assert_eq!( format_kth_next_row(&state_at_three, 8), "", "k beyond moves.len() must return empty (panel under-fills late in replay)", ); // k=0 — degenerate. assert_eq!(format_kth_next_row(&state_at_three, 0), ""); } /// `MOVE_LOG_NEXT_ROWS` next rows spawn with the panel — /// one per offset 1..=N. Cardinality matches the constant. #[test] fn move_log_next_rows_spawn_with_panel() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); assert_eq!( move_log_next_row_count(&mut app), MOVE_LOG_NEXT_ROWS, "exactly MOVE_LOG_NEXT_ROWS next rows must spawn with the panel", ); } /// Each next row's text at spawn time matches the helper /// output for its offset. #[test] fn move_log_next_rows_paint_helper_strings_at_spawn() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false), ); app.update(); // offset 1 → moves[5], display=6 assert_eq!( move_log_next_row_text_at_offset(&mut app, 1), "6 \u{2502} stock cycle", ); // offset 2 → moves[6], display=7 assert_eq!( move_log_next_row_text_at_offset(&mut app, 2), "7 \u{2502} stock cycle", ); } /// Next rows under-fill late in the replay. With a 10-move /// replay at cursor=9: offset 1 → moves[9] (display 10), /// offset 2 → moves[10] (out of range, empty). #[test] fn move_log_next_rows_underfill_at_replay_end() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false), ); app.update(); assert_eq!( move_log_next_row_text_at_offset(&mut app, 1), "10 \u{2502} stock cycle", "offset 1 (k=1) must populate when cursor < moves.len()", ); assert_eq!( move_log_next_row_text_at_offset(&mut app, 2), "", "offset 2 (k=2) must be empty when cursor + k - 1 >= moves.len()", ); } /// Active row sits inside a wrapper Node with /// `BackgroundColor(ACCENT_PRIMARY)` so it reads as "current /// focus" against the panel background. Validates the wrapper /// is present and carries the expected colour. #[test] fn active_row_wrapper_carries_accent_primary_background() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); // Find the active-row Text entity, then walk to its // parent — that's the wrapper Node which should carry // the highlight BackgroundColor. let world = app.world_mut(); let mut row_q = world.query_filtered::>(); let row = row_q .iter(world) .next() .expect("active row Text entity must exist while overlay is spawned"); let mut parent_q = world.query::<&ChildOf>(); let parent = parent_q .get(world, row) .map(|p| p.parent()) .expect("active row must have a parent (the highlight wrapper)"); let mut bg_q = world.query::<&BackgroundColor>(); let bg = bg_q .get(world, parent) .expect("active row's parent must carry BackgroundColor (highlight)"); assert_eq!( bg.0, ACCENT_PRIMARY, "active-row wrapper background must be ACCENT_PRIMARY for the focus highlight", ); } /// Active-row Text uses TEXT_PRIMARY_HC for legible contrast /// against the brick-red ACCENT_PRIMARY background. Without /// this the default TEXT_PRIMARY (#d0d0d0) on red would have /// borderline contrast; the HC variant (#f5f5f5) keeps the /// row readable. #[test] fn active_row_text_uses_high_contrast_color_for_highlight() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false), ); app.update(); let world = app.world_mut(); let mut q = world.query_filtered::<&TextColor, With>(); let color = q .iter(world) .next() .expect("active row TextColor must exist"); assert_eq!( color.0, TEXT_PRIMARY_HC, "active row text colour must be TEXT_PRIMARY_HC for contrast against the highlight", ); } /// Active-row text starts with the `▶` focus marker prefix. /// Pure-helper guard — pins the prefix so a future refactor /// dropping it has to also update this test. #[test] fn active_row_format_includes_focus_prefix() { let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false); let row = format_active_move_row(&state); assert!( row.starts_with('\u{25B6}'), "active-row format must start with ▶ focus marker; got {row:?}", ); // Cursor=0 still returns empty, never just the prefix. let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false); assert_eq!( format_active_move_row(&cursor_zero), "", "cursor=0 must return empty (no stray prefix on empty row)", ); } /// Panel shares the overlay tree's lifecycle — it despawns on /// `Playing → Inactive` along with the banner root. #[test] fn move_log_panel_despawns_with_overlay() { let mut app = headless_app(); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false), ); app.update(); assert_eq!(move_log_panel_count(&mut app), 1); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( move_log_panel_count(&mut app), 0, "panel must despawn with the rest of the overlay tree", ); } // ----------------------------------------------------------------------- // pause_button_label + pause / step click handlers + keyboard accelerator // ----------------------------------------------------------------------- /// Read the current text content of the unique pause / resume button. fn pause_button_text(app: &mut App) -> String { let world = app.world_mut(); let mut button_q = world.query_filtered::<&Children, With>(); let children: Vec = button_q .iter(world) .next() .map(|c| c.iter().collect()) .unwrap_or_default(); let mut text_q = world.query::<&Text>(); for child in children { if let Ok(text) = text_q.get(world, child) { return text.0.clone(); } } String::new() } /// Find the unique entity carrying the given button marker. fn unique_button(app: &mut App) -> Entity { let world = app.world_mut(); let mut q = world.query_filtered::>(); q.iter(world).next().expect("button entity must exist") } fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true) } fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState { ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false) } #[test] fn pause_button_label_reads_pause_when_running() { assert_eq!(pause_button_label(&running_state(5, 0)), "Pause"); } #[test] fn pause_button_label_reads_resume_when_paused() { assert_eq!(pause_button_label(&pressed_paused_state(5, 0)), "Resume"); } #[test] fn pause_button_label_is_empty_off_state() { assert_eq!(pause_button_label(&ReplayPlaybackState::Inactive), ""); assert_eq!(pause_button_label(&ReplayPlaybackState::Completed), ""); } #[test] fn pause_button_text_swaps_when_state_pauses() { let mut app = headless_app(); set_state(&mut app, running_state(5, 0)); app.update(); assert_eq!(pause_button_text(&mut app), "Pause"); set_state(&mut app, pressed_paused_state(5, 0)); app.update(); assert_eq!( pause_button_text(&mut app), "Resume", "label must repaint to Resume on the frame the state pauses" ); } #[test] fn pause_button_click_toggles_paused_flag() { let mut app = headless_app(); set_state(&mut app, running_state(5, 0)); app.update(); let button = unique_button::(&mut app); app.world_mut() .entity_mut(button) .insert(Interaction::Pressed); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { paused, .. } => { assert!(*paused, "click must flip running → paused"); } other => panic!("expected Playing, got {other:?}"), } } #[test] fn step_button_click_advances_cursor_while_paused() { let mut app = headless_app(); set_state(&mut app, pressed_paused_state(5, 0)); app.update(); let button = unique_button::(&mut app); app.world_mut() .entity_mut(button) .insert(Interaction::Pressed); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { cursor, paused, .. } => { assert_eq!(*cursor, 1, "step must advance the cursor by exactly one"); assert!(*paused, "step must leave the paused flag untouched"); } other => panic!("expected Playing, got {other:?}"), } } #[test] fn step_button_click_is_noop_while_running() { let mut app = headless_app(); set_state(&mut app, running_state(5, 0)); app.update(); let button = unique_button::(&mut app); app.world_mut() .entity_mut(button) .insert(Interaction::Pressed); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { cursor, paused, .. } => { assert_eq!(*cursor, 0, "running-step must not race the tick loop"); assert!(!*paused); } other => panic!("expected Playing, got {other:?}"), } } /// Pressing Esc while a replay is playing resets the state to /// `Inactive` (same end-state as clicking the Stop button). /// Mirrors `space_keyboard_toggles_paused_flag` for the stop /// accelerator. #[test] fn esc_keyboard_stops_active_replay() { let mut app = headless_app(); // The keyboard handler reads `Option>>` // and no-ops when missing — provide it for this test. app.init_resource::>(); set_state(&mut app, running_state(5, 0)); app.update(); assert_eq!(overlay_root_count(&mut app), 1); app.world_mut() .resource_mut::>() .press(KeyCode::Escape); app.update(); assert!( matches!( app.world().resource::(), ReplayPlaybackState::Inactive ), "Esc must reset state to Inactive while replay is Playing", ); // One more tick — `react_to_state_change` despawns the overlay // in response to the state going Inactive. app.update(); assert_eq!( overlay_root_count(&mut app), 0, "overlay must despawn the frame after Esc stops the replay", ); } /// Esc is a no-op when the replay isn't `Playing` — covers /// `Inactive` (no replay attached) and `Completed` (auto-clear /// underway). The handler must stay quiet so the global Esc /// listeners (pause modal, etc.) own those frames. #[test] fn esc_keyboard_is_noop_when_not_playing() { let mut app = headless_app(); app.init_resource::>(); // Resource defaults to Inactive — no replay attached. app.update(); app.world_mut() .resource_mut::>() .press(KeyCode::Escape); app.update(); // State stays Inactive — no spurious mutation. assert!(matches!( app.world().resource::(), ReplayPlaybackState::Inactive )); } /// The keybind-footer hint text now lists both wired /// accelerators (Space + Esc). Lock the format so a future edit /// that drops one or the other has to also update this test. #[test] fn keybind_footer_hint_lists_space_and_esc() { let hint = keybind_footer_hint_text(); assert!( hint.contains("[SPACE]"), "hint must surface the Space accelerator; got {hint:?}", ); assert!( hint.contains("[ESC]"), "hint must surface the Esc accelerator; got {hint:?}", ); } /// Hint must also list the arrow-key step accelerators. /// Pinned separately from the Space + Esc test so a future /// regression that drops only the arrows is caught here even /// if the Space + Esc check still passes. #[test] fn keybind_footer_hint_lists_arrow_steps() { let hint = keybind_footer_hint_text(); assert!( hint.contains("\u{2190}\u{2192}"), "hint must surface the ←→ step accelerators; got {hint:?}", ); assert!( hint.contains("step"), "hint must label the arrow accelerators as 'step' \ (matches what's wired — single-move step, not continuous scrub); got {hint:?}", ); } /// Pressing → while paused advances the cursor by exactly one /// — same end-state as clicking the on-screen Step button. #[test] fn arrow_right_keyboard_advances_cursor_while_paused() { let mut app = headless_app(); app.init_resource::>(); set_state(&mut app, pressed_paused_state(5, 0)); app.update(); app.world_mut() .resource_mut::>() .press(KeyCode::ArrowRight); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { cursor, paused, .. } => { assert_eq!( *cursor, 1, "→ must advance the cursor by exactly one while paused", ); assert!(*paused, "→ must leave the paused flag untouched",); } other => panic!("expected Playing, got {other:?}"), } } /// Pressing → while running is a no-op — the existing /// `step_replay_playback` guard prevents racing the tick loop. #[test] fn arrow_right_keyboard_is_noop_while_running() { let mut app = headless_app(); app.init_resource::>(); set_state(&mut app, running_state(5, 0)); app.update(); app.world_mut() .resource_mut::>() .press(KeyCode::ArrowRight); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { cursor, paused, .. } => { assert_eq!(*cursor, 0, "→ must not race the tick loop"); assert!(!*paused); } other => panic!("expected Playing, got {other:?}"), } } /// Pressing ← while paused with cursor > 0 decrements the /// cursor by exactly one. The corresponding game-state reversal /// happens when `handle_undo` reads the dispatched /// `UndoRequestEvent` — that's covered in the playback core's /// integration test, not here. #[test] fn arrow_left_keyboard_decrements_cursor_while_paused() { let mut app = headless_app(); app.init_resource::>(); // Start paused at cursor=3 so there's room to step backwards. set_state(&mut app, pressed_paused_state(5, 3)); app.update(); app.world_mut() .resource_mut::>() .press(KeyCode::ArrowLeft); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { cursor, paused, .. } => { assert_eq!( *cursor, 2, "← must decrement the cursor by exactly one while paused", ); assert!(*paused, "← must leave the paused flag untouched",); } other => panic!("expected Playing, got {other:?}"), } } /// Pressing ← at cursor 0 is a no-op (nothing to rewind past). #[test] fn arrow_left_keyboard_is_noop_at_cursor_zero() { let mut app = headless_app(); app.init_resource::>(); set_state(&mut app, pressed_paused_state(5, 0)); app.update(); app.world_mut() .resource_mut::>() .press(KeyCode::ArrowLeft); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { cursor, .. } => { assert_eq!(*cursor, 0, "← at cursor 0 must be a no-op"); } other => panic!("expected Playing, got {other:?}"), } } /// Holding → for one full repeat interval fires a second step /// after the initial just_pressed. Drives `Time::delta_secs` /// via `TimeUpdateStrategy::ManualDuration` so the test is /// deterministic. #[test] fn arrow_right_keyboard_repeats_while_held() { use bevy::time::TimeUpdateStrategy; use std::time::Duration; let mut app = headless_app(); app.init_resource::>(); // Drive each frame as a SCRUB_REPEAT_INTERVAL_SECS step so // every update past the just_pressed crosses the threshold. app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32( SCRUB_REPEAT_INTERVAL_SECS, ))); // Start paused at cursor 0 so there's room to step forward. set_state(&mut app, pressed_paused_state(10, 0)); app.update(); // Press the key (just_pressed fires once → cursor 1). app.world_mut() .resource_mut::>() .press(KeyCode::ArrowRight); app.update(); let cursor_after_press = match app.world().resource::() { ReplayPlaybackState::Playing { cursor, .. } => *cursor, _ => panic!("expected Playing"), }; assert_eq!( cursor_after_press, 1, "just_pressed must fire once on the press frame", ); // Hold (no new just_pressed; held → accumulator crosses // threshold next frame → second fire). app.world_mut() .resource_mut::>() .clear_just_pressed(KeyCode::ArrowRight); app.update(); let cursor_after_hold = match app.world().resource::() { ReplayPlaybackState::Playing { cursor, .. } => *cursor, _ => panic!("expected Playing"), }; assert!( cursor_after_hold >= 2, "held key must fire at least one repeat after the threshold; got cursor={cursor_after_hold}", ); } /// Releasing the key resets the per-key accumulator so the /// next fresh press fires immediately rather than at half- /// interval. Validates the `else { reset to 0 }` branch. #[test] fn arrow_keyboard_release_resets_accumulator() { use bevy::time::TimeUpdateStrategy; use std::time::Duration; let mut app = headless_app(); app.init_resource::>(); // Drive sub-threshold ticks so the accumulator builds but // never fires while held. let half_interval = SCRUB_REPEAT_INTERVAL_SECS * 0.5; app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32( half_interval, ))); set_state(&mut app, pressed_paused_state(10, 5)); app.update(); // Hold for a sub-threshold tick (no fire expected: no // just_pressed, accumulator at 0.05s < 0.1s threshold). app.world_mut() .resource_mut::>() .press(KeyCode::ArrowRight); app.world_mut() .resource_mut::>() .clear_just_pressed(KeyCode::ArrowRight); app.update(); // Release (the else-branch should reset right_held_secs // to 0). Then verify by holding for another sub-threshold // tick — if the accumulator reset properly, no fire. app.world_mut() .resource_mut::>() .release(KeyCode::ArrowRight); app.update(); let hold = app.world().resource::(); assert_eq!( hold.right_held_secs, 0.0, "release must reset the per-key accumulator to 0", ); } /// Pressing ← while running is a no-op — same hard-gate /// rationale as the forward-step paused-only check. #[test] fn arrow_left_keyboard_is_noop_while_running() { let mut app = headless_app(); app.init_resource::>(); set_state(&mut app, running_state(5, 3)); app.update(); app.world_mut() .resource_mut::>() .press(KeyCode::ArrowLeft); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { cursor, paused, .. } => { assert_eq!(*cursor, 3, "← must not race the tick loop"); assert!(!*paused); } other => panic!("expected Playing, got {other:?}"), } } #[test] fn space_keyboard_toggles_paused_flag() { let mut app = headless_app(); // The keyboard handler reads `Option>>` // and no-ops when missing — provide it for this test. app.init_resource::>(); set_state(&mut app, running_state(5, 0)); app.update(); app.world_mut() .resource_mut::>() .press(KeyCode::Space); app.update(); match app.world().resource::() { ReplayPlaybackState::Playing { paused, .. } => { assert!(*paused, "Space must toggle running → paused"); } other => panic!("expected Playing, got {other:?}"), } } /// The tableau dim layer spawns alongside the banner when playback /// starts and despawns when the replay ends. Mirrors /// `floating_chip_spawns_and_despawns_with_overlay` for the dim layer. #[test] fn dim_layer_spawns_and_despawns_with_overlay() { let mut app = headless_app(); // Inactive → no dim layer yet. app.update(); assert_eq!( app.world_mut() .query::<&ReplayTableauDimLayer>() .iter(app.world()) .count(), 0, "no dim layer while playback is Inactive", ); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ); app.update(); assert_eq!( app.world_mut() .query::<&ReplayTableauDimLayer>() .iter(app.world()) .count(), 1, "dim layer must spawn when playback starts", ); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( app.world_mut() .query::<&ReplayTableauDimLayer>() .iter(app.world()) .count(), 0, "dim layer must despawn when playback ends", ); } /// The dim layer is a full-screen node (100 % × 100 %) at a lower /// z-index than the replay chrome (z = Z_REPLAY_DIM < Z_REPLAY_OVERLAY). /// Lock the z-ordering so a future refactor of the z constants can't /// silently flip the intended stacking. #[test] fn dim_layer_z_is_below_replay_chrome() { const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) } } // ----------------------------------------------------------------------- // Mini-tableau preview tests // ----------------------------------------------------------------------- fn mini_tableau_panel_count(app: &mut App) -> usize { app.world_mut() .query::<&ReplayMiniTableauPanel>() .iter(app.world()) .count() } /// Mini-tableau panel spawns alongside the other overlay surfaces /// when playback starts and despawns when it ends. #[test] fn mini_tableau_panel_spawns_and_despawns_with_overlay() { let mut app = headless_app(); app.update(); assert_eq!( mini_tableau_panel_count(&mut app), 0, "no mini-tableau panel while playback is Inactive", ); set_state( &mut app, ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false), ); app.update(); assert_eq!( mini_tableau_panel_count(&mut app), 1, "mini-tableau panel must spawn when playback starts", ); set_state(&mut app, ReplayPlaybackState::Inactive); app.update(); assert_eq!( mini_tableau_panel_count(&mut app), 0, "mini-tableau panel must despawn when playback ends", ); } /// `format_rank_short` maps every `Rank` variant to a single ASCII /// character except Ten which maps to `"T"`. #[test] fn format_rank_short_all_ranks() { assert_eq!(format_rank_short(Rank::Ace), "A"); assert_eq!(format_rank_short(Rank::Two), "2"); assert_eq!(format_rank_short(Rank::Three), "3"); assert_eq!(format_rank_short(Rank::Four), "4"); assert_eq!(format_rank_short(Rank::Five), "5"); assert_eq!(format_rank_short(Rank::Six), "6"); assert_eq!(format_rank_short(Rank::Seven), "7"); assert_eq!(format_rank_short(Rank::Eight), "8"); assert_eq!(format_rank_short(Rank::Nine), "9"); assert_eq!(format_rank_short(Rank::Ten), "T"); assert_eq!(format_rank_short(Rank::Jack), "J"); assert_eq!(format_rank_short(Rank::Queen), "Q"); assert_eq!(format_rank_short(Rank::King), "K"); } /// `format_suit_glyph` returns the FiraMono-covered Unicode suit /// glyphs for each `Suit` variant (U+2660–U+2666 confirmed on Android). #[test] fn format_suit_glyph_all_suits() { assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}"); assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}"); assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}"); assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}"); } /// `format_foundations_row` with a freshly-dealt game (all empty). #[test] fn format_foundations_row_empty_board() { let game = solitaire_core::game_state::GameState::new_with_mode( 42, DrawStockConfig::DrawOne, GameMode::Classic, ); assert_eq!(format_foundations_row(&game), "F: -- -- -- --"); } /// `format_stock_waste_row` with a freshly-dealt game: stock has /// 24 cards, waste is empty. #[test] fn format_stock_waste_row_initial_state() { let game = solitaire_core::game_state::GameState::new_with_mode( 42, DrawStockConfig::DrawOne, GameMode::Classic, ); let text = format_stock_waste_row(&game); assert!( text.starts_with("STK:"), "row must start with STK: prefix; got {text:?}", ); assert!( text.contains("WST:--"), "waste must show -- on a fresh deal; got {text:?}", ); }