Files
Ferrous-Solitaire/solitaire_engine/src/replay_overlay/tests.rs
T
funman300 4cb4212829
Test / test (pull_request) Successful in 36m34s
fix(replay): store the deal via upstream card_game serializers (schema v4)
Replays previously persisted only seed + moves and re-dealt the board
from the seed at playback time, so any change to the seed->deal mapping
(RNG bumps, upstream upgrades) silently invalidated every existing
replay. Schema v4 instead embeds a SessionRecording - the upstream
card_game Session serde ({config, initial_state, instructions}) - so
playback rebuilds the exact recorded board; seed/draw_mode/mode remain
caption metadata only.

- core: SessionRecording newtype delegating to Session<Klondike> serde;
  GameState::recording() / from_recording(); from_instructions_unchecked
  fixture helper; serde_json added to dev-deps (tests only)
- data: Replay v4 (recording replaces moves); v1-v3 files rejected by
  the existing version gate
- engine: win-recording and sync upload freeze game.recording();
  playback rebuilds from the recording; Playing carries the extracted
  move list (+ Box<Replay> for clippy large_enum_variant)
- wasm: replay_export() builds the full v4 upload payload so JS never
  hand-assembles it (the old game.js path hardcoded schema_version: 2
  and corrupted u64 seeds via Math.round); ReplayPlayer::from_json
  enforces schema_version == 4 with a descriptive error
- web: game.js/play.html use replay_export; replay.js surfaces player
  construction errors in the caption instead of dying silently
- server: mode validation accepts data-carrying GameMode variants
  (Difficulty uploads previously 400'd against the String field)

Both replays on prod are May-era v1 rows with empty move lists - every
shared replay was already unplayable; the viewer now says why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:10:38 -07:00

2097 lines
67 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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::<ReplayPlaybackState>();
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<ReplayOverlayProgressText>>();
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<ReplayOverlayBannerText>>();
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::<Entity, With<ReplayStopButton>>();
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::<ReplayPlaybackState>();
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<ReplayOverlayScrubFill>>();
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<ReplayOverlayGameCaption>>();
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<ReplayOverlayWinMoveMarker>>();
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<ReplayOverlayScrubNotch>>()
.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::<Entity, With<ReplayOverlayScrubFill>>();
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<String> {
let world = app.world_mut();
let mut q = world.query_filtered::<&Text, With<ReplayOverlayScrubNotchLabel>>();
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<String> = 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<String> {
let world = app.world_mut();
// Find the footer entity, then walk its descendants for `Text`.
let mut footer_q = world.query_filtered::<Entity, With<ReplayOverlayKeybindFooter>>();
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<Entity> = 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<ReplayOverlayKeybindFooter>>();
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<ReplayOverlayMoveLogHeader>>();
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<ReplayOverlayMoveLogActiveRow>>();
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::<Entity, With<ReplayOverlayMoveLogActiveRow>>();
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<ReplayOverlayMoveLogActiveRow>>();
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<ReplayPauseButton>>();
let children: Vec<Entity> = 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<M: Component>(app: &mut App) -> Entity {
let world = app.world_mut();
let mut q = world.query_filtered::<Entity, With<M>>();
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::<ReplayPauseButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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::<ReplayStepButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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::<ReplayStepButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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<Res<ButtonInput<KeyCode>>>`
// and no-ops when missing — provide it for this test.
app.init_resource::<ButtonInput<KeyCode>>();
set_state(&mut app, running_state(5, 0));
app.update();
assert_eq!(overlay_root_count(&mut app), 1);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
assert!(
matches!(
app.world().resource::<ReplayPlaybackState>(),
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::<ButtonInput<KeyCode>>();
// Resource defaults to Inactive — no replay attached.
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Escape);
app.update();
// State stays Inactive — no spurious mutation.
assert!(matches!(
app.world().resource::<ReplayPlaybackState>(),
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::<ButtonInput<KeyCode>>();
set_state(&mut app, pressed_paused_state(5, 0));
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowRight);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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::<ButtonInput<KeyCode>>();
set_state(&mut app, running_state(5, 0));
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowRight);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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::<ButtonInput<KeyCode>>();
// 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::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowLeft);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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::<ButtonInput<KeyCode>>();
set_state(&mut app, pressed_paused_state(5, 0));
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowLeft);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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::<ButtonInput<KeyCode>>();
// 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::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowRight);
app.update();
let cursor_after_press = match app.world().resource::<ReplayPlaybackState>() {
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::<ButtonInput<KeyCode>>()
.clear_just_pressed(KeyCode::ArrowRight);
app.update();
let cursor_after_hold = match app.world().resource::<ReplayPlaybackState>() {
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::<ButtonInput<KeyCode>>();
// 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::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowRight);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.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::<ButtonInput<KeyCode>>()
.release(KeyCode::ArrowRight);
app.update();
let hold = app.world().resource::<ReplayScrubKeyHold>();
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::<ButtonInput<KeyCode>>();
set_state(&mut app, running_state(5, 3));
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::ArrowLeft);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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<Res<ButtonInput<KeyCode>>>`
// and no-ops when missing — provide it for this test.
app.init_resource::<ButtonInput<KeyCode>>();
set_state(&mut app, running_state(5, 0));
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Space);
app.update();
match app.world().resource::<ReplayPlaybackState>() {
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+2660U+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:?}",
);
}