Files
Ferrous-Solitaire/solitaire_engine/src/game_plugin/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

1263 lines
42 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 solitaire_core::{Foundation, KlondikePile, Tableau};
/// Build a minimal headless `App` with just `GamePlugin` installed.
/// Disables persistence and overrides the seed so tests are deterministic
/// and don't touch `~/.local/share/ferrous_solitaire/game_state.json`.
fn test_app(seed: u64) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
// Disable I/O — tests must not touch the real game state file or
// the real replay file. Both default to dirs::data_dir() in the
// plugin's build path; clearing them keeps tests self-contained.
app.insert_resource(GameStatePath(None));
app.insert_resource(ReplayPath(None));
// Force `PendingRestoredGame` empty so production saved-game
// state on the dev machine's disk (loaded by `GamePlugin::build`)
// can't leak into per-test world state and trip the
// `pending.0.is_some()` guard in `auto_save_game_state` /
// `save_game_state_on_exit`. Without this clear, an
// unrelated `~/.local/share/ferrous_solitaire/game_state.json`
// would silently disable the auto-save path under test.
app.insert_resource(PendingRestoredGame(None));
// Override the system-time seed with a known value.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(seed, DrawStockConfig::DrawOne);
app
}
#[test]
fn plugin_inserts_game_state_resource() {
let app = test_app(1);
assert!(app.world().get_resource::<GameStateResource>().is_some());
assert!(app.world().get_resource::<GameStatePath>().is_some());
assert!(app.world().get_resource::<DragState>().is_some());
assert!(app.world().get_resource::<SyncStatusResource>().is_some());
}
#[test]
fn draw_request_advances_game_state() {
let mut app = test_app(42);
let stock_before = app
.world()
.resource::<GameStateResource>()
.0
.stock_cards()
.len();
app.world_mut().write_message(DrawRequestEvent);
app.update();
let stock_after = app
.world()
.resource::<GameStateResource>()
.0
.stock_cards()
.len();
let waste_after = app
.world()
.resource::<GameStateResource>()
.0
.waste_cards()
.len();
assert_eq!(stock_after, stock_before - 1);
assert_eq!(waste_after, 1);
}
#[test]
fn draw_request_fires_state_changed_event() {
let mut app = test_app(42);
app.world_mut().write_message(DrawRequestEvent);
app.update();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_some());
}
#[test]
fn undo_after_draw_restores_state() {
let mut app = test_app(42);
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().write_message(UndoRequestEvent);
app.update();
let g = &app.world().resource::<GameStateResource>().0;
assert_eq!(g.stock_cards().len(), 24);
assert_eq!(g.waste_cards().len(), 0);
}
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<solitaire_core::Card> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.0.clone())
.collect();
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
mode: None,
confirmed: false,
});
app.update();
let after: Vec<solitaire_core::Card> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.0.clone())
.collect();
assert_ne!(before, after);
}
#[test]
fn settings_changed_updates_take_from_foundation_flag() {
let mut app = test_app(1);
assert!(
app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"fresh game should inherit default take_from_foundation=true",
);
let mut settings = solitaire_data::Settings {
take_from_foundation: false,
..Default::default()
};
app.world_mut()
.write_message(crate::settings_plugin::SettingsChangedEvent(
settings.clone(),
));
app.update();
assert!(
!app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=false into live game state",
);
settings.take_from_foundation = true;
app.world_mut()
.write_message(crate::settings_plugin::SettingsChangedEvent(settings));
app.update();
assert!(
app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=true into live game state",
);
}
#[test]
fn advance_elapsed_drains_accumulator_into_whole_seconds() {
let mut elapsed = 0;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 2.5, false);
assert_eq!(elapsed, 2);
// Remaining 0.5 should still be in the accumulator.
advance_elapsed(&mut elapsed, &mut acc, 0.5, false);
assert_eq!(elapsed, 3);
}
#[test]
fn advance_elapsed_is_noop_when_won() {
let mut elapsed = 100;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 5.0, true);
assert_eq!(elapsed, 100);
assert_eq!(acc, 0.0);
}
#[test]
fn advance_elapsed_saturates_at_u64_max() {
let mut elapsed = u64::MAX;
let mut acc = 0.0;
advance_elapsed(&mut elapsed, &mut acc, 5.0, false);
assert_eq!(elapsed, u64::MAX, "elapsed must not overflow past u64::MAX");
}
#[test]
fn advance_elapsed_handles_subsecond_deltas_without_skipping() {
let mut elapsed = 0;
let mut acc = 0.0;
// 4 × 0.25 = 1.0 (exactly representable in f32) — must produce 1 tick.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert_eq!(elapsed, 1);
// Repeat once more for a total of 2 seconds.
for _ in 0..4 {
advance_elapsed(&mut elapsed, &mut acc, 0.25, false);
}
assert_eq!(elapsed, 2);
}
#[test]
fn invalid_move_does_not_fire_state_changed() {
let mut app = test_app(42);
// Stock -> Waste is InvalidDestination; no state change expected.
app.world_mut().write_message(MoveRequestEvent {
from: KlondikePile::Stock,
to: KlondikePile::Stock,
count: 1,
});
app.update();
let events = app.world().resource::<Messages<StateChangedEvent>>();
let mut reader = events.get_cursor();
assert!(reader.read(events).next().is_none());
}
// -----------------------------------------------------------------------
// Persistence tests
// -----------------------------------------------------------------------
fn tmp_gs_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("engine_test_gs_{name}.json"))
}
/// save_game_state_on_exit writes to disk when AppExit fires.
#[test]
fn exit_saves_game_state() {
use solitaire_data::load_game_state_from;
let path = tmp_gs_path("exit_save");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
let mut app = test_app(7);
// Point persistence at our temp file.
app.insert_resource(GameStatePath(Some(path.clone())));
// Override the seed so we can verify it was written.
app.world_mut().resource_mut::<GameStateResource>().0 =
GameState::new(7654, DrawStockConfig::DrawOne);
app.world_mut().write_message(AppExit::Success);
app.update();
let loaded = load_game_state_from(&path).expect("file should exist after exit");
assert_eq!(loaded.seed, 7654);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
}
/// new_game_request deletes any previously saved state file.
#[test]
fn new_game_deletes_saved_state() {
use solitaire_data::save_game_state_to;
let path = tmp_gs_path("new_game_delete");
// Pre-create a saved file.
save_game_state_to(&path, &GameState::new(1, DrawStockConfig::DrawOne)).unwrap();
assert!(path.exists());
let mut app = test_app(1);
app.insert_resource(GameStatePath(Some(path.clone())));
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(2),
mode: None,
confirmed: false,
});
app.update();
assert!(
!path.exists(),
"saved file should be deleted after new game"
);
}
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
///
/// The timer is pre-seeded just past the threshold and the test
/// re-arms it before each `app.update()` in a small bounded loop:
/// under `MinimalPlugins` the first frame's `Time::delta_secs()`
/// can be 0.0 (or, under heavy parallel cargo-test load, large
/// enough that the pre-seeded margin is consumed by it), so a
/// single-frame check is fragile. Looping until the file appears
/// (or hitting the bound) makes the test robust against
/// first-frame Time variance without changing the underlying
/// behaviour contract.
#[test]
fn auto_save_writes_after_30_seconds() {
use solitaire_data::load_game_state_from;
let path = tmp_gs_path("auto_save_30s");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
let mut app = test_app(42);
app.insert_resource(GameStatePath(Some(path.clone())));
// Give the game one move so move_count > 0 (auto-save guard).
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(1);
// Re-arm the timer past the threshold every frame and pump
// updates until the save fires. Caps at 16 iterations — a
// healthy run hits it on the first or second frame; the cap
// prevents an infinite loop if a future regression skips
// the save unconditionally.
for _ in 0..16 {
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 1.0));
app.update();
if path.exists() {
break;
}
}
assert!(
path.exists(),
"auto-save file must exist after timer crosses threshold"
);
let loaded = load_game_state_from(&path).expect("file must be loadable");
assert_eq!(loaded.seed, 42);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
}
/// auto_save_game_state does NOT write to disk when no moves have been made.
#[test]
fn auto_save_skips_when_no_moves() {
let path = tmp_gs_path("auto_save_skip");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
let mut app = test_app(99);
app.insert_resource(GameStatePath(Some(path.clone())));
// move_count stays at 0 (fresh game); timer is past threshold.
app.insert_resource(AutoSaveTimer(AUTO_SAVE_INTERVAL_SECS + 0.1));
app.update();
assert!(
!path.exists(),
"auto-save must not fire when move_count == 0"
);
}
#[test]
fn moving_cards_off_face_up_card_does_not_fire_card_flipped_event() {
use solitaire_core::{Card, Deck, Rank, Suit};
let mut app = test_app(1);
// Build a tableau with two face-up cards.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card::new(Deck::Deck1, Suit::Clubs, Rank::King),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Queen),
],
);
gs.0.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
);
}
app.world_mut().write_message(MoveRequestEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.update();
let events = app.world().resource::<Messages<CardFlippedEvent>>();
let mut cursor = events.get_cursor();
let fired: Vec<_> = cursor.read(events).collect();
assert!(
fired.is_empty(),
"no flip event when exposed card was already face-up"
);
}
// -----------------------------------------------------------------------
// Task #29 — has_legal_moves pure-function tests
// -----------------------------------------------------------------------
#[test]
fn has_legal_moves_returns_true_for_fresh_game() {
// A fresh deal always has a non-empty stock (24 cards), so drawing
// is always a legal move regardless of the initial face-up tableau cards.
let game = GameState::new(42, DrawStockConfig::DrawOne);
assert!(
has_legal_moves(&game),
"fresh deal must contain at least one legal move"
);
}
#[test]
fn has_legal_moves_returns_true_when_stock_has_cards_even_if_not_immediately_placeable() {
// Drawing from a non-empty stock is always a legal move in standard
// Klondike (unlimited recycles), even if the drawn card cannot be
// immediately placed. The game is only stuck when both stock AND waste
// are exhausted and no visible card can be moved.
use solitaire_core::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
}
game.set_test_waste_cards(Vec::new());
let mut stock = Vec::new();
for r in [Rank::Two, Rank::Three, Rank::Four, Rank::Five] {
stock.push(Card::new(Deck::Deck1, Suit::Hearts, r));
}
game.set_test_stock_cards(stock);
// Stock is non-empty, so drawing is always a valid move.
assert!(
has_legal_moves(&game),
"non-empty stock means drawing is a legal move regardless of placement options",
);
}
#[test]
fn has_legal_moves_returns_true_when_ace_can_go_to_foundation() {
use solitaire_core::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
// Empty stock and waste so draw is NOT available.
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
// Clear all tableau and foundations, put Ace of Clubs on tableau 0.
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
}
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
);
assert!(
has_legal_moves(&game),
"Ace can always go to an empty foundation"
);
}
#[test]
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
// Regression: the bug only checked t.cards.last() (top face-up card).
// If the only legal move involves a face-up card that is NOT the top
// card of its column the previous code would return false (softlock)
// even though the player can still move that run.
use solitaire_core::{Card, Deck, Rank, Suit};
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
game.set_test_stock_cards(Vec::new());
game.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
game.set_test_foundation_cards(foundation, Vec::new());
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
game.set_test_tableau_cards(tableau, Vec::new());
}
// Tableau 0: face-up Queen of Spades (non-top) + face-up Jack of Hearts on top.
// King of Diamonds is on Tableau 1 (empty otherwise), so Queen→King is the
// only legal tableau move, and that move targets the Queen which is non-top.
game.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card::new(Deck::Deck1, Suit::Spades, Rank::Queen),
Card::new(Deck::Deck1, Suit::Hearts, Rank::Jack),
],
);
game.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card::new(Deck::Deck1, Suit::Diamonds, Rank::King)],
);
assert!(
has_legal_moves(&game),
"Queen (non-top face-up) should be detected as a valid move source onto King",
);
}
// -----------------------------------------------------------------------
// Task #57 — Confirm-new-game dialog tests
// -----------------------------------------------------------------------
/// Helper that also initialises `ButtonInput<KeyCode>` so the keyboard
/// systems do not panic in MinimalPlugins environments.
fn test_app_with_input(seed: u64) -> App {
let mut app = test_app(seed);
app.init_resource::<ButtonInput<KeyCode>>();
app
}
#[test]
fn new_game_request_with_moves_spawns_confirm_dialog() {
let mut app = test_app_with_input(42);
// Simulate an active game with moves made.
app.world_mut()
.resource_mut::<GameStateResource>()
.0
.set_test_move_count(5);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
let count = app
.world_mut()
.query::<&ConfirmNewGameScreen>()
.iter(app.world())
.count();
assert_eq!(
count, 1,
"ConfirmNewGameScreen must be spawned when move_count > 0"
);
}
#[test]
fn new_game_request_on_fresh_game_skips_confirm() {
let mut app = test_app_with_input(42);
// move_count stays at 0 (fresh game).
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0,
"test assumes a fresh game with no moves"
);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
let count = app
.world_mut()
.query::<&ConfirmNewGameScreen>()
.iter(app.world())
.count();
assert_eq!(
count, 0,
"ConfirmNewGameScreen must NOT appear for a fresh game"
);
}
// -----------------------------------------------------------------------
// Task #58 — Game-over overlay tests
// -----------------------------------------------------------------------
#[test]
fn game_over_screen_absent_when_moves_available() {
// A fresh game always has moves (stock is non-empty).
let mut app = test_app_with_input(42);
app.world_mut().write_message(StateChangedEvent);
app.update();
let count = app
.world_mut()
.query::<&GameOverScreen>()
.iter(app.world())
.count();
assert_eq!(
count, 0,
"GameOverScreen must not appear when moves are available"
);
}
// Verify that the game-over overlay contains the expected header text and
// action-hint strings so players understand why the overlay appeared and
// what keys to press.
// -----------------------------------------------------------------------
// Task #56 — Escape dismisses GameOverScreen and starts new game
// -----------------------------------------------------------------------
// Pressing Escape while `GameOverScreen` is visible must fire
// `NewGameRequestEvent` — identical behaviour to pressing N.
// -----------------------------------------------------------------------
// Task #48 — Undo with empty stack fires InfoToastEvent
// -----------------------------------------------------------------------
/// Sending `UndoRequestEvent` on a fresh game (empty undo stack) must fire
/// exactly one `InfoToastEvent` with the message "Nothing to undo".
#[test]
fn undo_on_empty_stack_fires_info_toast() {
let mut app = test_app(42);
// Fresh game — undo stack is empty, so undo() returns UndoStackEmpty.
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert_eq!(
fired.len(),
1,
"exactly one InfoToastEvent must fire on empty-stack undo"
);
assert_eq!(
fired[0].0, "Nothing to undo",
"toast message must be 'Nothing to undo'"
);
}
// -----------------------------------------------------------------------
// Foundation-completion flourish — FoundationCompletedEvent firing logic
// -----------------------------------------------------------------------
/// Reading helper: collect every `FoundationCompletedEvent` written
/// during the most recent `update()` so the test body can assert
/// against count, slot, and suit.
fn drain_foundation_events(app: &App) -> Vec<FoundationCompletedEvent> {
let events = app.world().resource::<Messages<FoundationCompletedEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).copied().collect()
}
/// When a King lands on a foundation that already holds Ace through
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
/// the matching slot + suit.
/// Moving a card to a tableau pile must never produce a
/// `FoundationCompletedEvent`, even if the source tableau happened
/// to have been a King.
#[test]
fn foundation_completed_event_does_not_fire_for_non_foundation_moves() {
use solitaire_core::{Card, Deck, Rank, Suit};
let mut app = test_app(1);
// Reset the world: clear stock + waste so a draw isn't possible,
// empty all tableaux + foundations, then place a face-up King of
// Spades on Tableau(0). Tableau(1) is empty, so the King can move
// there legally.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.set_test_stock_cards(Vec::new());
gs.0.set_test_waste_cards(Vec::new());
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
gs.0.set_test_foundation_cards(foundation, Vec::new());
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
gs.0.set_test_tableau_cards(tableau, Vec::new());
}
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![Card::new(Deck::Deck1, Suit::Spades, Rank::King)],
);
}
app.world_mut().write_message(MoveRequestEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.update();
let fired = drain_foundation_events(&app);
assert!(
fired.is_empty(),
"FoundationCompletedEvent must not fire for non-foundation moves; got {fired:?}"
);
}
/// At 12 cards on a foundation (AceJack on the pile, Queen in
/// flight), the event must NOT fire — the flourish is only for the
/// final 13th completion.
/// A successful undo must NOT fire an `InfoToastEvent`.
#[test]
fn undo_after_draw_does_not_fire_info_toast() {
let mut app = test_app(42);
// Make a move so the undo stack is non-empty.
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Clear events from the draw so we start with a clean slate.
app.world_mut()
.resource_mut::<Messages<InfoToastEvent>>()
.clear();
app.world_mut().write_message(UndoRequestEvent);
app.update();
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut reader = events.get_cursor();
let fired: Vec<_> = reader.read(events).collect();
assert!(
fired.is_empty(),
"no InfoToastEvent must fire on a successful undo"
);
}
// -----------------------------------------------------------------------
// Win-game replay recording
//
// The recording resource captures exactly the player-driven actions
// that successfully advanced GameState. On GameWonEvent it freezes
// into a Replay (with seed/mode/time/score metadata) and persists.
// -----------------------------------------------------------------------
/// Drive a fresh game through a draw + a tableau→foundation move,
/// then assert the recording resource captured both, in order, with
/// the correct shape.
/// Invalid moves must not appear in the recording — the recording is
/// "what successfully happened", not "what was requested".
#[test]
fn replay_does_not_record_rejected_moves() {
let mut app = test_app(42);
// Stock → Waste is InvalidDestination; the live engine rejects it.
app.world_mut().write_message(MoveRequestEvent {
from: KlondikePile::Stock,
to: KlondikePile::Stock,
count: 1,
});
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert!(
recording.moves.is_empty(),
"rejected moves must not enter the recording, got {:?}",
recording.moves,
);
}
/// Undo intentionally does NOT enter the recording. The replay
/// represents the canonical path the player took to win, not the
/// missteps that were rolled back.
#[test]
fn replay_recording_skips_undo() {
let mut app = test_app(42);
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().write_message(UndoRequestEvent);
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert_eq!(
recording.moves.len(),
1,
"only the draw is recorded; the undo does not erase it nor add a new entry",
);
assert!(matches!(
recording.moves[0],
KlondikeInstruction::RotateStock
));
}
/// Starting a new game wipes the recording so the next deal begins
/// with a clean buffer.
#[test]
fn replay_recording_clears_on_new_game() {
let mut app = test_app(1);
app.world_mut().write_message(DrawRequestEvent);
app.update();
assert_eq!(
app.world().resource::<RecordingReplay>().moves.len(),
1,
"draw should have been recorded",
);
// Use `confirmed: true` so the request bypasses the
// abandon-current-game modal (which fires when move_count > 0)
// and goes straight to the new-game branch that clears the
// recording. The modal-spawn path is exercised by other tests
// in this module.
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(2),
mode: None,
confirmed: true,
});
app.update();
let recording = app.world().resource::<RecordingReplay>();
assert!(
recording.moves.is_empty(),
"recording must be cleared on new-game start; got {:?}",
recording.moves,
);
}
/// On `GameWonEvent`, the recording is frozen into a `Replay` and
/// appended to the rolling [`solitaire_data::ReplayHistory`]. We
/// point `ReplayPath` at a temp file, fake a win, and load the
/// history back to assert the just-saved entry sits at the front
/// with the metadata + move list intact.
#[test]
fn replay_recording_freezes_into_replay_on_game_won() {
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_freeze.json");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
let mut app = test_app(7654);
app.insert_resource(ReplayPath(Some(path.clone())));
// Drive two real draws so the *session* history (the source the
// freeze now serialises from, via `GameState::recording()`) holds
// two instructions. `RotateStock` is the only instruction the
// engine can drive without the runtime-only `klondike` pile-stack
// types; the round-trip shape is identical for any variant.
app.world_mut().write_message(DrawRequestEvent);
app.update();
app.world_mut().write_message(DrawRequestEvent);
app.update();
// Fire the win event the engine emits when the last foundation
// completes — `record_replay_on_win` listens for it.
app.world_mut().write_message(GameWonEvent {
score: 4321,
time_seconds: 250,
});
app.update();
let history =
load_replay_history_from(&path).expect("a winning replay must be persisted to ReplayPath");
assert_eq!(
history.replays.len(),
1,
"fresh history must contain exactly the just-recorded win",
);
let loaded = &history.replays[0];
assert_eq!(loaded.seed, 7654, "seed must match the live game state");
assert_eq!(
loaded.draw_mode,
DrawStockConfig::DrawOne,
"draw_mode must be captured"
);
assert_eq!(
loaded.final_score, 4321,
"final_score must come from the win event"
);
assert_eq!(
loaded.time_seconds, 250,
"time_seconds must come from the win event"
);
let instructions = loaded.recording.instructions();
assert_eq!(instructions.len(), 2, "every recorded move must round-trip");
assert!(matches!(instructions[0], KlondikeInstruction::RotateStock));
assert!(matches!(instructions[1], KlondikeInstruction::RotateStock));
assert_eq!(
loaded.win_move_index,
Some(1),
"win move index must point at the last recorded instruction",
);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
}
/// Successive `GameWonEvent`s must accumulate in the rolling
/// history rather than overwriting one another. Pre-cap, every win
/// joins the front of `history.replays`.
#[test]
fn replay_recording_appends_to_history_across_wins() {
use solitaire_data::load_replay_history_from;
let path = std::env::temp_dir().join("engine_test_replay_history_append.json");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
let mut app = test_app(11);
app.insert_resource(ReplayPath(Some(path.clone())));
// First win.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(KlondikeInstruction::RotateStock);
}
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 60,
});
app.update();
// Second win — different score so we can distinguish.
{
let mut recording = app.world_mut().resource_mut::<RecordingReplay>();
recording.moves.clear();
recording.moves.push(KlondikeInstruction::RotateStock);
recording.moves.push(KlondikeInstruction::RotateStock);
}
app.world_mut().write_message(GameWonEvent {
score: 200,
time_seconds: 120,
});
app.update();
let history = load_replay_history_from(&path).expect("history must exist");
assert_eq!(history.replays.len(), 2, "both wins must be retained");
// Newest first — second win lands at index 0.
assert_eq!(history.replays[0].final_score, 200);
assert_eq!(history.replays[1].final_score, 100);
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
}
/// `GameWonEvent` with an empty recording must NOT touch disk.
/// Without this guard, parallel-plugin tests that synthesise
/// win events for XP / streak / weekly-goal logic (without
/// driving any actual moves) would clobber the developer's real
/// replay file every time `cargo test` ran.
#[test]
fn replay_with_empty_recording_skips_save() {
let path = std::env::temp_dir().join("engine_test_replay_empty_skip.json");
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(&path);
let mut app = test_app(1);
app.insert_resource(ReplayPath(Some(path.clone())));
// Recording is empty by default — fire a win event anyway.
app.world_mut().write_message(GameWonEvent {
score: 100,
time_seconds: 30,
});
app.update();
assert!(
!path.exists(),
"no replay must be written when recording is empty",
);
}
// -----------------------------------------------------------------------
// Solver-backed "Winnable deals only" toggle
//
// Exercises [`choose_winnable_seed`] and the wiring inside
// `handle_new_game` that consults [`Settings::winnable_deals_only`].
// -----------------------------------------------------------------------
/// Inject a `SettingsResource` with the given `winnable_deals_only`
/// flag. The handle_new_game system already reads this resource via
/// `Option<Res<...>>`, so no `SettingsPlugin` boot is needed.
fn insert_settings(app: &mut App, winnable_deals_only: bool) {
let settings = solitaire_data::Settings {
winnable_deals_only,
..solitaire_data::Settings::default()
};
app.insert_resource(crate::settings_plugin::SettingsResource(settings));
}
#[test]
fn new_game_with_solver_toggle_off_uses_requested_seed() {
// Toggle off — the engine must use the seed it was handed and
// never invoke the solver. Seed 999 is just an arbitrary
// deterministic seed; the test asserts the resulting deal
// matches `GameState::new(999, DrawOne)`.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(999),
mode: None,
confirmed: false,
});
app.update();
let actual_seed = app.world().resource::<GameStateResource>().0.seed;
assert_eq!(
actual_seed, 999,
"with solver toggle off, the requested seed must be honoured exactly"
);
// Cross-check: the dealt tableau must match GameState::new(999) byte-for-byte.
let expected = GameState::new(999, DrawStockConfig::DrawOne);
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
assert_eq!(
app.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(tableau)),
expected.pile(KlondikePile::Tableau(tableau)),
"tableau column {tableau:?} must match the unfiltered seed",
);
}
}
#[test]
fn new_game_with_solver_toggle_off_random_seed_path() {
// When seed is None and toggle is off, the engine uses a
// system-time seed and skips the solver. We can't pin the
// exact seed, but we can assert the seed is *not* the
// sentinel zero (which would only happen if SystemTime is
// before the epoch — practically impossible), AND that no
// resource has been mutated to suggest the solver ran.
// The strongest assertion is "the move runs to completion
// without panicking", which the .update() call covers.
let mut app = test_app(1);
insert_settings(&mut app, false);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// Game state was reseeded — move_count is 0 on the new game.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0
);
}
#[test]
fn new_game_with_solver_toggle_on_skips_solver_for_specific_seed() {
// Even with the toggle on, an *explicit* seed must be honoured:
// daily challenges, replay seeding, and challenge-mode all
// pass `Some(seed)` and must never be retried.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(123),
mode: None,
confirmed: false,
});
app.update();
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
123,
"explicit-seed requests must skip the solver retry loop",
);
}
#[test]
fn choose_winnable_seed_accepts_inconclusive_seed() {
// With the upstream session solver (card_game v0.4.0) no seeds in 0..500
// are proven Unwinnable — they are either Winnable or Inconclusive.
// `choose_winnable_seed` must accept Inconclusive as "probably winnable",
// so calling it with any seed in this range must return quickly (at most
// the retry cap) rather than looping forever.
//
// Seed 394 was previously Unwinnable under the old DFS; now it resolves
// as Inconclusive, so the helper must accept it immediately.
let chosen = choose_winnable_seed(394, DrawStockConfig::DrawOne);
assert_eq!(
chosen, 394,
"seed 394 resolves as Inconclusive; choose_winnable_seed must accept it as-is"
);
}
#[test]
fn new_game_with_solver_toggle_on_retries_until_winnable() {
// End-to-end: with the toggle on, fire a NewGameRequestEvent
// with seed=None and *manually pre-seed* the system-time
// path by clearing the GameStateResource so handle_new_game
// takes the random branch. We can't easily inject the
// system-time seed here, so we exercise the helper via a
// separate call and assert the *resource* receives the
// post-retry seed when the helper would have rejected.
//
// We test the integration by setting up an alternative
// scenario: pass `seed: Some(394)` with toggle on. Our
// implementation already documents that explicit seeds skip
// the retry, so this *won't* trigger retry. The cleaner
// integration is captured in `choose_winnable_seed_skips_*`.
// Here we verify the default-seed path doesn't crash when
// toggle is on — exercising the live solver call inside
// handle_new_game without depending on the solver picking
// a specific seed.
let mut app = test_app(1);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
// The chosen seed is non-deterministic (system time),
// but the new game must have been started cleanly:
// move_count back to 0, undo stack empty.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0
);
assert_eq!(
app.world()
.resource::<GameStateResource>()
.0
.undo_stack_len(),
0
);
}
/// Async-solver flow: a winnable-only request with no explicit
/// seed must populate `PendingNewGameSeed` on the same frame the
/// request fires (no main-thread stall waiting on the solver),
/// and subsequent updates must clear the pending state and
/// produce a new GameState.
///
/// Drives multiple `app.update()` calls because the polling
/// system needs at least one tick after spawn to observe the
/// task as ready and re-emit the synthetic event.
#[test]
fn winnable_seed_search_runs_async_and_completes_eventually() {
let mut app = test_app(394);
insert_settings(&mut app, true);
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
// First update: handle_new_game spawns the solver task and
// returns. The GameStateResource is unchanged on this tick —
// the player's previous game is still on screen, so the UI
// doesn't visually stall.
app.update();
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
"first frame should have an in-flight solver task",
);
// Pump frames until the polling system observes the task as
// ready and re-emits the synthetic event. AsyncComputeTaskPool
// is a shared pool across the whole `cargo test` run — when
// dozens of tests execute in parallel the pool can take a
// while to actually schedule our future. The yield_now() lets
// the pool's worker threads make progress between our polls
// without burning wall-clock time.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
while app.world().resource::<PendingNewGameSeed>().inner.is_some() {
app.update();
std::thread::yield_now();
if std::time::Instant::now() >= deadline {
break;
}
}
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
"solver task should have completed within 15 s wall-clock",
);
// New game completed: a fresh deal carries 0 moves.
assert_eq!(
app.world().resource::<GameStateResource>().0.move_count(),
0,
"completed new game must be in fresh-deal state",
);
}
/// Cancel-on-replace: a winnable-only request that arrives while
/// a previous solver task is in flight must drop the previous
/// task and queue the new one. The most recently-fired request
/// is the one whose seed wins, regardless of which task started
/// first.
#[test]
fn winnable_seed_search_drops_in_flight_task_on_new_request() {
let mut app = test_app(394);
insert_settings(&mut app, true);
// Fire the first request; first update spawns the task.
app.world_mut().write_message(NewGameRequestEvent {
seed: None,
mode: None,
confirmed: false,
});
app.update();
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_some(),
"first request should be in flight",
);
// Fire a SECOND request with an explicit seed before the
// first task can complete. handle_new_game's `pending.inner =
// None` line must drop the in-flight task; the explicit-seed
// branch then bypasses the solver entirely. After this tick
// the GameStateResource carries seed 12345, not whatever the
// solver would have picked for the first request.
app.world_mut().write_message(NewGameRequestEvent {
seed: Some(12345),
mode: None,
confirmed: true,
});
app.update();
// Drive a few more ticks to drain any stragglers.
for _ in 0..5 {
app.update();
}
assert!(
app.world().resource::<PendingNewGameSeed>().inner.is_none(),
"explicit-seed request must have cancelled the in-flight task",
);
assert_eq!(
app.world().resource::<GameStateResource>().0.seed,
12345,
"explicit-seed request takes precedence over the dropped solver task",
);
}