4cb4212829
Test / test (pull_request) Successful in 36m34s
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>
2097 lines
67 KiB
Rust
2097 lines
67 KiB
Rust
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+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:?}",
|
||
);
|
||
}
|