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::().0 = GameState::new(seed, DrawStockConfig::DrawOne); app } #[test] fn plugin_inserts_game_state_resource() { let app = test_app(1); assert!(app.world().get_resource::().is_some()); assert!(app.world().get_resource::().is_some()); assert!(app.world().get_resource::().is_some()); assert!(app.world().get_resource::().is_some()); } #[test] fn draw_request_advances_game_state() { let mut app = test_app(42); let stock_before = app .world() .resource::() .0 .stock_cards() .len(); app.world_mut().write_message(DrawRequestEvent); app.update(); let stock_after = app .world() .resource::() .0 .stock_cards() .len(); let waste_after = app .world() .resource::() .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::>(); 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::().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 = app .world() .resource::() .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 = app .world() .resource::() .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::() .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::() .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::() .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::>(); 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::().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::() .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::(); 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::>(); 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` 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::>(); 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::() .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::().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::>(); 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 { let events = app.world().resource::>(); 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::(); 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 (Ace–Jack 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::>() .clear(); app.world_mut().write_message(UndoRequestEvent); app.update(); let events = app.world().resource::>(); 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::(); 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::(); 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::().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::(); 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::(); 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::(); 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>`, 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::().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::() .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::().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::().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::().0.move_count(), 0 ); assert_eq!( app.world() .resource::() .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::().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::().inner.is_some() { app.update(); std::thread::yield_now(); if std::time::Instant::now() >= deadline { break; } } assert!( app.world().resource::().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::().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::().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::().inner.is_none(), "explicit-seed request must have cancelled the in-flight task", ); assert_eq!( app.world().resource::().0.seed, 12345, "explicit-seed request takes precedence over the dropped solver task", ); }