From a2375d2fd90fe0a306e472e74b75717f29b9e305 Mon Sep 17 00:00:00 2001 From: funman300 Date: Mon, 6 Jul 2026 13:19:56 -0700 Subject: [PATCH] refactor(engine): move hud/settings/game/input plugin tests into tests.rs files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continues #118 after card_plugin (PR #124): the four remaining oversized plugin files become module directories with their trailing #[cfg(test)] blocks extracted verbatim into sibling tests.rs files, following the replay_overlay/ pattern. hud_plugin: 3,598 -> 2,725 + 872 (44 tests) settings_plugin: 3,512 -> 2,857 + 654 (25 tests) game_plugin: 2,562 -> 1,310 + 1,251 (39 tests) input_plugin: 2,540 -> 1,832 + 707 (31 tests) Pure mechanical moves via git mv — no runtime code changes; all 139 tests preserved and passing. Refs #118 Co-Authored-By: Claude Fable 5 --- .../{game_plugin.rs => game_plugin/mod.rs} | 1254 +---------------- solitaire_engine/src/game_plugin/tests.rs | 1251 ++++++++++++++++ .../src/{hud_plugin.rs => hud_plugin/mod.rs} | 875 +----------- solitaire_engine/src/hud_plugin/tests.rs | 872 ++++++++++++ .../{input_plugin.rs => input_plugin/mod.rs} | 710 +--------- solitaire_engine/src/input_plugin/tests.rs | 707 ++++++++++ .../mod.rs} | 657 +-------- solitaire_engine/src/settings_plugin/tests.rs | 654 +++++++++ 8 files changed, 3488 insertions(+), 3492 deletions(-) rename solitaire_engine/src/{game_plugin.rs => game_plugin/mod.rs} (53%) create mode 100644 solitaire_engine/src/game_plugin/tests.rs rename solitaire_engine/src/{hud_plugin.rs => hud_plugin/mod.rs} (75%) create mode 100644 solitaire_engine/src/hud_plugin/tests.rs rename solitaire_engine/src/{input_plugin.rs => input_plugin/mod.rs} (69%) create mode 100644 solitaire_engine/src/input_plugin/tests.rs rename solitaire_engine/src/{settings_plugin.rs => settings_plugin/mod.rs} (82%) create mode 100644 solitaire_engine/src/settings_plugin/tests.rs diff --git a/solitaire_engine/src/game_plugin.rs b/solitaire_engine/src/game_plugin/mod.rs similarity index 53% rename from solitaire_engine/src/game_plugin.rs rename to solitaire_engine/src/game_plugin/mod.rs index 19e20ed..6f428c9 100644 --- a/solitaire_engine/src/game_plugin.rs +++ b/solitaire_engine/src/game_plugin/mod.rs @@ -1307,1256 +1307,4 @@ fn save_game_state_on_exit( } #[cfg(test)] -mod tests { - 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()))); - - // Push two recorded instructions manually so we can verify they - // survive the freeze/save round-trip without having to drive a - // real win. Both are `RotateStock` — the only instruction - // constructible without the runtime-only `klondike` pile-stack - // types (which the engine intentionally does not depend on); the - // round-trip shape is identical for any instruction variant. - { - let mut recording = app.world_mut().resource_mut::(); - recording.moves.push(KlondikeInstruction::RotateStock); - recording.moves.push(KlondikeInstruction::RotateStock); - } - - // 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" - ); - assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip"); - assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock)); - assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock)); - - #[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", - ); - } -} +mod tests; diff --git a/solitaire_engine/src/game_plugin/tests.rs b/solitaire_engine/src/game_plugin/tests.rs new file mode 100644 index 0000000..1d9c9c0 --- /dev/null +++ b/solitaire_engine/src/game_plugin/tests.rs @@ -0,0 +1,1251 @@ +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()))); + + // Push two recorded instructions manually so we can verify they + // survive the freeze/save round-trip without having to drive a + // real win. Both are `RotateStock` — the only instruction + // constructible without the runtime-only `klondike` pile-stack + // types (which the engine intentionally does not depend on); the + // round-trip shape is identical for any instruction variant. + { + let mut recording = app.world_mut().resource_mut::(); + recording.moves.push(KlondikeInstruction::RotateStock); + recording.moves.push(KlondikeInstruction::RotateStock); + } + + // 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" + ); + assert_eq!(loaded.moves.len(), 2, "every recorded move must round-trip"); + assert!(matches!(loaded.moves[0], KlondikeInstruction::RotateStock)); + assert!(matches!(loaded.moves[1], KlondikeInstruction::RotateStock)); + + #[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", + ); +} diff --git a/solitaire_engine/src/hud_plugin.rs b/solitaire_engine/src/hud_plugin/mod.rs similarity index 75% rename from solitaire_engine/src/hud_plugin.rs rename to solitaire_engine/src/hud_plugin/mod.rs index b4a8808..2d5d03c 100644 --- a/solitaire_engine/src/hud_plugin.rs +++ b/solitaire_engine/src/hud_plugin/mod.rs @@ -2722,877 +2722,4 @@ fn toggle_hud_on_tap( } #[cfg(test)] -mod tests { - use super::*; - use crate::game_plugin::GamePlugin; - use crate::table_plugin::TablePlugin; - use chrono::Local; - use solitaire_core::{DrawStockConfig, game_state::GameState}; - - fn headless_app() -> App { - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(GamePlugin) - .add_plugins(TablePlugin) - .add_plugins(HudPlugin); - app.update(); - app - } - - #[test] - fn hud_plugin_registers_without_panic() { - let _app = headless_app(); - } - - #[test] - fn update_hud_runs_after_game_mutation_without_panic() { - let mut app = headless_app(); - app.world_mut().resource_mut::().0 = - GameState::new(42, DrawStockConfig::DrawOne); - app.update(); - } - - fn read_hud_text(app: &mut App) -> String { - app.world_mut() - .query_filtered::<&Text, With>() - .iter(app.world()) - .next() - .map(|t| t.0.clone()) - .unwrap_or_default() - } - - #[test] - fn score_reflects_game_state() { - let mut app = headless_app(); - let score = app.world_mut().resource_mut::().0.force_test_score(20); - app.update(); - assert_eq!(read_hud_text::(&mut app), format!("Score: {score}")); - } - - #[test] - fn moves_reflects_game_state() { - let mut app = headless_app(); - app.world_mut() - .resource_mut::() - .0 - .set_test_move_count(42); - app.update(); - assert_eq!(read_hud_text::(&mut app), "Moves: 42"); - } - - #[test] - fn draw_three_mode_shows_draw_3_badge() { - use solitaire_core::game_state::GameMode; - let mut app = headless_app(); - app.world_mut().resource_mut::().0 = - GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic); - app.update(); - assert_eq!(read_hud_text::(&mut app), "Draw 3"); - } - - #[test] - fn zen_mode_hides_score() { - use solitaire_core::game_state::GameMode; - let mut app = headless_app(); - app.world_mut().resource_mut::().0 = - GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen); - app.update(); - // Zen mode spec: "No score display" → text must be empty. - assert_eq!(read_hud_text::(&mut app), ""); - } - - #[test] - fn time_display_uses_mm_ss_format() { - let mut app = headless_app(); - app.world_mut() - .resource_mut::() - .0 - .elapsed_seconds = 125; - app.update(); - // 125 seconds = 2 minutes 5 seconds → "2:05" - assert_eq!(read_hud_text::(&mut app), "2:05"); - } - - // ----------------------------------------------------------------------- - // format_time_limit (pure function) - // ----------------------------------------------------------------------- - - #[test] - fn format_time_limit_300_is_5_00() { - assert_eq!(format_time_limit(300), "5:00"); - } - - #[test] - fn format_time_limit_zero() { - assert_eq!(format_time_limit(0), "0:00"); - } - - #[test] - fn format_time_limit_pads_seconds() { - assert_eq!(format_time_limit(65), "1:05"); - } - - // ----------------------------------------------------------------------- - // challenge_hud_text (pure function) - // ----------------------------------------------------------------------- - - #[test] - fn challenge_hud_text_shows_time_limit() { - let dc = DailyChallengeResource { - date: Local::now().date_naive(), - seed: 1, - goal_description: None, - target_score: None, - max_time_secs: Some(300), - }; - assert_eq!(challenge_hud_text(&dc), "Limit: 5:00"); - } - - #[test] - fn challenge_hud_text_shows_score_goal() { - let dc = DailyChallengeResource { - date: Local::now().date_naive(), - seed: 1, - goal_description: None, - target_score: Some(4000), - max_time_secs: None, - }; - assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts"); - } - - #[test] - fn challenge_hud_text_empty_when_no_constraints() { - let dc = DailyChallengeResource { - date: Local::now().date_naive(), - seed: 1, - goal_description: None, - target_score: None, - max_time_secs: None, - }; - assert_eq!(challenge_hud_text(&dc), ""); - } - - #[test] - fn challenge_time_color_above_60_is_info() { - let c = challenge_time_color(61); - assert_eq!(c, STATE_INFO); - } - - #[test] - fn challenge_time_color_exactly_60_is_info() { - let c = challenge_time_color(60); - assert_eq!(c, STATE_INFO); - } - - #[test] - fn challenge_time_color_59_is_warning() { - let c = challenge_time_color(59); - assert_eq!(c, STATE_WARNING); - } - - #[test] - fn challenge_time_color_30_is_warning() { - let c = challenge_time_color(30); - assert_eq!(c, STATE_WARNING); - } - - #[test] - fn challenge_time_color_29_is_danger() { - let c = challenge_time_color(29); - assert_eq!(c, STATE_DANGER); - } - - #[test] - fn challenge_time_color_zero_is_danger() { - let c = challenge_time_color(0); - assert_eq!(c, STATE_DANGER); - } - - // ----------------------------------------------------------------------- - // HudChallenge in-app tests - // ----------------------------------------------------------------------- - - #[test] - fn challenge_hud_empty_when_no_daily_resource() { - // No DailyChallengeResource inserted → HudChallenge must be empty. - let mut app = headless_app(); - app.world_mut().resource_mut::().set_changed(); - app.update(); - assert_eq!(read_hud_text::(&mut app), ""); - } - - #[test] - fn challenge_hud_shows_time_limit_when_resource_present() { - let mut app = headless_app(); - app.world_mut().insert_resource(DailyChallengeResource { - date: Local::now().date_naive(), - seed: 42, - goal_description: Some("Win fast".to_string()), - target_score: None, - max_time_secs: Some(300), - }); - app.world_mut().resource_mut::().set_changed(); - app.update(); - assert_eq!(read_hud_text::(&mut app), "Limit: 5:00"); - } - - #[test] - fn challenge_hud_shows_score_goal_when_resource_present() { - let mut app = headless_app(); - app.world_mut().insert_resource(DailyChallengeResource { - date: Local::now().date_naive(), - seed: 42, - goal_description: None, - target_score: Some(4000), - max_time_secs: None, - }); - app.world_mut().resource_mut::().set_changed(); - app.update(); - assert_eq!(read_hud_text::(&mut app), "Goal: 4000 pts"); - } - - #[test] - fn challenge_hud_clears_on_win() { - let mut app = headless_app(); - app.world_mut().insert_resource(DailyChallengeResource { - date: Local::now().date_naive(), - seed: 42, - goal_description: None, - target_score: None, - max_time_secs: Some(300), - }); - // Mark the game as won — HudChallenge should be empty. - app.world_mut().resource_mut::().0.set_test_won(true); - app.update(); - assert_eq!(read_hud_text::(&mut app), ""); - } - - // ----------------------------------------------------------------------- - // HudUndos in-app tests - // ----------------------------------------------------------------------- - - #[test] - fn undos_hud_empty_at_game_start() { - let mut app = headless_app(); - app.update(); - assert_eq!(read_hud_text::(&mut app), ""); - } - - #[test] - fn undos_hud_shows_count_after_undo() { - let mut app = headless_app(); - app.world_mut() - .resource_mut::() - .0 - .force_test_undos(3); - app.update(); - assert_eq!(read_hud_text::(&mut app), "Undos: 3"); - } - - // ----------------------------------------------------------------------- - // HudAutoComplete in-app tests (Task #56) - // ----------------------------------------------------------------------- - - fn headless_app_with_auto_complete() -> App { - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(GamePlugin) - .add_plugins(TablePlugin) - .add_plugins(HudPlugin); - app.init_resource::(); - app.update(); - app - } - - #[test] - fn auto_complete_badge_shows_auto_when_active() { - let mut app = headless_app_with_auto_complete(); - app.world_mut().resource_mut::().active = true; - // Also trigger game state change so the update fires. - app.world_mut() - .resource_mut::() - .0 - .set_test_move_count(1); - app.update(); - assert_eq!(read_hud_text::(&mut app), "AUTO"); - } - - #[test] - fn auto_complete_badge_empty_when_inactive() { - let mut app = headless_app_with_auto_complete(); - // active is false by default. - app.world_mut() - .resource_mut::() - .0 - .set_test_move_count(1); - app.update(); - assert_eq!(read_hud_text::(&mut app), ""); - } - - // ----------------------------------------------------------------------- - // HudRecycles in-app tests - // ----------------------------------------------------------------------- - - #[test] - fn recycles_hud_hidden_when_zero_in_draw_one_mode() { - let mut app = headless_app(); - // Draw-One, no recycles yet — text must be empty. - app.world_mut().resource_mut::().0 = - GameState::new(42, DrawStockConfig::DrawOne); - app.update(); - assert_eq!(read_hud_text::(&mut app), ""); - } - - #[test] - fn recycles_hud_hidden_when_zero_in_draw_three_mode() { - let mut app = headless_app(); - // Draw-Three, no recycles yet — text must also be empty. - app.world_mut().resource_mut::().0 = - GameState::new(42, DrawStockConfig::DrawThree); - app.update(); - assert_eq!(read_hud_text::(&mut app), ""); - } - - #[test] - fn recycles_hud_shows_count_draw_three() { - let mut app = headless_app(); - let mut gs = GameState::new(42, DrawStockConfig::DrawThree); - gs.force_test_recycles(3); - app.world_mut().resource_mut::().0 = gs; - app.update(); - assert_eq!(read_hud_text::(&mut app), "Recycles: 3"); - } - - #[test] - fn recycles_hud_shows_count_draw_one() { - let mut app = headless_app(); - // Draw-One with recycle_count > 0 must now show the counter too. - let mut gs = GameState::new(42, DrawStockConfig::DrawOne); - gs.force_test_recycles(2); - app.world_mut().resource_mut::().0 = gs; - app.update(); - assert_eq!(read_hud_text::(&mut app), "Recycles: 2"); - } - - // ----------------------------------------------------------------------- - // Score-change feedback (G2) - // ----------------------------------------------------------------------- - - /// Tells `TimePlugin` to advance by `secs` on every subsequent - /// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept - /// local to avoid coupling the two test modules. - fn set_manual_time_step(app: &mut App, secs: f32) { - use bevy::time::TimeUpdateStrategy; - use std::time::Duration; - app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32( - secs, - ))); - } - - /// Counts entities matching component `M` currently in the world. - fn count_with(app: &mut App) -> usize { - app.world_mut().query::<&M>().iter(app.world()).count() - } - - /// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating - /// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse - /// component is also inserted on the score readout — both signals - /// fire from the same delta detection. - #[test] - fn score_increase_above_threshold_spawns_floater_in_accent_primary() { - let mut app = headless_app(); - // Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha - // can be asserted exactly: with Automatic strategy a few ms - // of wall-clock time leaks in between updates and the alpha - // drifts below 1.0 by `dt / lifetime`. - set_manual_time_step(&mut app, 0.0); - // Initial state has score=0; bumping by 50 (the threshold) - // is the smallest jump that triggers the floater. - app.world_mut().resource_mut::().0.force_test_score(50); - app.update(); - - // One floater should now exist. - let count = count_with::(&mut app); - assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump"); - - // Its TextColor must be ACCENT_PRIMARY at full alpha. The - // detect system spawns the floater coloured ACCENT_PRIMARY - // and at dt=0 the first advance tick leaves alpha = 1.0. - let world = app.world_mut(); - let mut q = world.query::<(&ScoreFloater, &TextColor)>(); - let (_floater, color) = q.iter(world).next().expect("floater missing TextColor"); - assert_eq!(color.0, ACCENT_PRIMARY); - } - - /// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse - /// the floater has reached the end of its lifetime and despawned. - #[test] - fn score_floater_despawns_after_full_lifetime() { - let mut app = headless_app(); - app.world_mut().resource_mut::().0.force_test_score(50); - app.update(); - assert_eq!(count_with::(&mut app), 1); - - // Advance by a delta well past the floater's lifetime — the - // single oversized tick clamps t at 1.0 and the entity is - // despawned in the same `Update`. - set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1); - app.update(); - app.update(); // first update propagates the new strategy; second runs the system with non-zero dt. - - assert_eq!( - count_with::(&mut app), - 0, - "floater should have despawned after its full lifetime" - ); - } - - /// A small score change (below the threshold) inserts a pulse on - /// the readout but never spawns a floater — keeping the floating - /// "+N" reserved for meaningful score jumps. - #[test] - fn score_increase_below_threshold_does_not_spawn_floater() { - let mut app = headless_app(); - // +5 mirrors a single tableau-to-foundation move; well below - // the 50-point threshold so the floater path stays dormant. - app.world_mut().resource_mut::().0.force_test_score(5); - app.update(); - assert_eq!( - count_with::(&mut app), - 0, - "delta of +5 must not spawn a floater" - ); - } - - /// The triangular pulse curve hits its peak (1.1) at t=0.5 and - /// returns to 1.0 at the endpoints. Pure-function check that - /// guards the curve shape against future tweaks. - #[test] - fn score_pulse_scale_is_triangular() { - assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6); - assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6); - assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6); - // Values outside [0,1] are clamped before the curve runs. - assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6); - assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6); - } - - /// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and - /// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale` - /// curve test — the two animations share a triangular shape so a - /// future tweak that desyncs them shows up here. - #[test] - fn streak_flourish_scale_curves_through_one_one_one() { - let dur = MOTION_STREAK_FLOURISH_SECS; - assert!( - (streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5, - "streak flourish scale at t=0 must be 1.0", - ); - assert!( - (streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5, - "streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE", - ); - assert!( - (streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5, - "streak flourish scale at t=duration must return to 1.0", - ); - } - - /// Out-of-range values are clamped, not extrapolated. Matches the - /// foundation flourish's clamp behaviour so the score readout never - /// freezes at a non-1.0 scale on the frame after the flourish ends. - #[test] - fn streak_flourish_scale_clamps_out_of_range() { - let dur = MOTION_STREAK_FLOURISH_SECS; - assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5); - assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5); - } - - /// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never - /// divides by zero. - #[test] - fn streak_flourish_scale_zero_duration_is_one() { - assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5); - assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5); - } - - // ----------------------------------------------------------------------- - // Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish - // ----------------------------------------------------------------------- - - /// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn - /// a `ScorePulse` on the readout or a `ScoreFloater` on the stage. - #[test] - fn score_change_skips_pulse_and_floater_under_reduce_motion() { - use solitaire_data::Settings; - let mut app = headless_app(); - app.insert_resource(SettingsResource(Settings { - reduce_motion_mode: true, - ..Settings::default() - })); - // +100 would normally create both a ScorePulse and a ScoreFloater. - app.world_mut().resource_mut::().0.force_test_score(50); - app.update(); - assert_eq!( - count_with::(&mut app), - 0, - "ScorePulse must not spawn under reduce-motion" - ); - assert_eq!( - count_with::(&mut app), - 0, - "ScoreFloater must not spawn under reduce-motion" - ); - } - - // ----------------------------------------------------------------------- - // Phase 2: keyboard focus ring — HUD action bar - // ----------------------------------------------------------------------- - - /// Returns the `Focusable` carried by the unique entity matching - /// marker `M`. Helper for the HUD focus tests. - fn focusable_for(app: &mut App) -> Focusable { - app.world_mut() - .query_filtered::<&Focusable, With>() - .iter(app.world()) - .next() - .copied() - .unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::())) - } - - #[test] - fn hud_buttons_get_focusable_marker() { - let mut app = headless_app(); - // Every action-bar button is in `FocusGroup::Hud`. - for f in [ - focusable_for::(&mut app), - focusable_for::(&mut app), - focusable_for::(&mut app), - focusable_for::(&mut app), - focusable_for::(&mut app), - focusable_for::(&mut app), - focusable_for::(&mut app), - ] { - assert_eq!( - f.group, - FocusGroup::Hud, - "every HUD action button must be in FocusGroup::Hud" - ); - } - } - - /// Returns the tooltip string carried by the unique entity matching - /// marker `M`. Panics if zero or more than one such entity exists, - /// which is the invariant we want to enforce for HUD readouts and - /// action buttons (each marker is spawned exactly once). - fn tooltip_for(app: &mut App) -> String { - let mut q = app.world_mut().query_filtered::<&Tooltip, With>(); - let world = app.world(); - let mut iter = q.iter(world); - let first = iter - .next() - .unwrap_or_else(|| { - panic!( - "expected a Tooltip on the {} entity", - std::any::type_name::() - ) - }) - .0 - .clone() - .into_owned(); - assert!( - iter.next().is_none(), - "expected exactly one Tooltip-bearing entity for {}", - std::any::type_name::() - ); - first - } - - /// Every HUD readout and action button must spawn with a `Tooltip` - /// carrying the approved canonical microcopy. Mirrors the structure - /// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the - /// invariant — one marker entity, one tooltip, exact text — is - /// asserted consistently across every element. - #[test] - fn hud_elements_carry_expected_tooltip_strings() { - let mut app = headless_app(); - - // HUD readouts (left column, top to bottom). - assert_eq!( - tooltip_for::(&mut app), - "Points earned this game. Hidden in Zen mode." - ); - assert_eq!( - tooltip_for::(&mut app), - "Moves you've made this game. Counts placements and stock draws." - ); - assert_eq!( - tooltip_for::(&mut app), - "Time on this game. Counts down in Time Attack." - ); - assert_eq!( - tooltip_for::(&mut app), - "Active game mode. Click Modes to switch." - ); - assert_eq!( - tooltip_for::(&mut app), - "Today's daily challenge target. Beat it for bonus XP." - ); - assert_eq!( - tooltip_for::(&mut app), - "Cards drawn on the next stock click in Draw-Three." - ); - assert_eq!( - tooltip_for::(&mut app), - "Undos used this game. Any undo blocks the No Undo achievement." - ); - assert_eq!( - tooltip_for::(&mut app), - "Times you've recycled the stock. Three or more unlocks Comeback." - ); - assert_eq!( - tooltip_for::(&mut app), - "Board is solvable from here. Press Enter to auto-finish." - ); - assert_eq!( - tooltip_for::(&mut app), - "Pile selected with Tab. Use arrows or Enter to act." - ); - - // Action bar (left to right). - assert_eq!( - tooltip_for::(&mut app), - "Open Stats, Achievements, Profile, Settings, or Leaderboard." - ); - assert_eq!( - tooltip_for::(&mut app), - "Take back your last move. Costs points and blocks No Undo." - ); - assert_eq!( - tooltip_for::(&mut app), - "Pause the game and freeze the timer." - ); - assert_eq!( - tooltip_for::(&mut app), - "Show controls, rules, and keyboard shortcuts." - ); - assert_eq!( - tooltip_for::(&mut app), - "Highlight a suggested move. Cycles through alternatives on repeat taps." - ); - assert_eq!( - tooltip_for::(&mut app), - "Switch modes: Classic, Daily, Zen, Challenge, Time Attack." - ); - assert_eq!( - tooltip_for::(&mut app), - "Start a fresh deal. Confirms first if a game is in progress." - ); - } - - /// Every interior row of the Modes and Menu popovers must carry a - /// `Tooltip`. The popovers open from action-bar buttons whose own - /// tooltips are already covered above; this test extends the - /// invariant inward so hover discoverability is uniform across the - /// HUD's nested controls. - /// - /// We invoke the popover spawn helpers directly with a maxed-out - /// `ProgressResource` and a `DailyChallengeResource` so every row - /// branch fires (Classic, Daily, Zen, Challenge, Time Attack). - /// Headless click simulation isn't needed — the contract under - /// test is "every popover row spawns with a tooltip", which is a - /// property of the spawn helpers themselves. - #[test] - fn popover_rows_carry_tooltip_strings() { - use crate::progress_plugin::ProgressResource; - use solitaire_sync::progress::PlayerProgress; - - let mut app = headless_app(); - - // Force every mode row to render: level past the challenge - // unlock threshold, plus a daily challenge resource so the - // Daily row appears. - let progress = ProgressResource(PlayerProgress { - level: CHALLENGE_UNLOCK_LEVEL, - ..Default::default() - }); - let daily = DailyChallengeResource { - date: Local::now().date_naive(), - seed: 1, - goal_description: None, - target_score: None, - max_time_secs: None, - }; - - // Spawn both popovers via their helpers. Mirrors how the click - // handlers invoke them in production — we just skip the click. - { - let world = app.world_mut(); - let mut commands = world.commands(); - spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None); - spawn_menu_popover(&mut commands, None); - world.flush(); - } - app.update(); - - // Every ModeOption-tagged entity must also carry a Tooltip, - // and the count must match the five canonical modes. - let mut mode_q = app - .world_mut() - .query_filtered::<&Tooltip, With>(); - let mode_tooltips: Vec = mode_q - .iter(app.world()) - .map(|t| t.0.clone().into_owned()) - .collect(); - assert_eq!( - mode_tooltips.len(), - 5, - "expected a tooltip on each of the 5 mode rows, got {}", - mode_tooltips.len() - ); - // Every approved mode tooltip string must be present somewhere - // among the ModeOption rows. Order isn't asserted — the spawn - // order test elsewhere already covers that. - for expected in [ - "Standard Klondike. Score, timer, and full progression.", - "Today's seeded deal. Same for every player worldwide.", - "No timer, no score, no penalties. Just play.", - "Hand-picked hard seeds. No undo allowed.", - "Win as many games as you can in ten minutes.", - ] { - assert!( - mode_tooltips.iter().any(|s| s == expected), - "missing mode tooltip: {expected:?}" - ); - } - - // Same contract for MenuOption rows: seven entries, each with a - // tooltip, exact strings matching the approved microcopy. - let mut menu_q = app - .world_mut() - .query_filtered::<&Tooltip, With>(); - let menu_tooltips: Vec = menu_q - .iter(app.world()) - .map(|t| t.0.clone().into_owned()) - .collect(); - assert_eq!( - menu_tooltips.len(), - 7, - "expected a tooltip on each of the 7 menu rows, got {}", - menu_tooltips.len() - ); - for expected in [ - "Show controls, rules, and keyboard shortcuts.", - "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", - "Lifetime totals: wins, streaks, fastest time, best score.", - "Browse unlocked achievements and the rewards still ahead.", - "Your level, XP progress, and sync status.", - "Audio, animations, theme, draw mode, and sync.", - "Top players from your sync server. Opt in from Profile.", - ] { - assert!( - menu_tooltips.iter().any(|s| s == expected), - "missing menu tooltip: {expected:?}" - ); - } - } - - #[test] - fn hud_button_order_matches_spawn_order() { - let mut app = headless_app(); - // Visual reading order (left → right): Menu, Undo, Pause, Help, - // Hint, Modes, New Game. Their `order` fields must be 0..=6 in - // that order so Tab cycles them as the player reads them. - assert_eq!(focusable_for::(&mut app).order, 0); - assert_eq!(focusable_for::(&mut app).order, 1); - assert_eq!(focusable_for::(&mut app).order, 2); - assert_eq!(focusable_for::(&mut app).order, 3); - assert_eq!(focusable_for::(&mut app).order, 4); - assert_eq!(focusable_for::(&mut app).order, 5); - assert_eq!(focusable_for::(&mut app).order, 6); - } - - #[test] - fn hud_focus_only_engages_when_button_hovered() { - // Phase 2 declares membership in `FocusGroup::Hud`; the - // engagement rule lives in `handle_focus_keys`. Two halves to - // this test: - // (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract - // still holds when nothing is hovered). - // (b) no modal + a HUD button hovered ⇒ Tab advances - // `FocusedButton` to a Hud-grouped entity. - use crate::ui_focus::{FocusedButton, UiFocusPlugin}; - use crate::ui_modal::UiModalPlugin; - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(UiModalPlugin) - .add_plugins(UiFocusPlugin) - .add_plugins(GamePlugin) - .add_plugins(TablePlugin) - .add_plugins(HudPlugin); - app.init_resource::>(); - app.update(); - - // (a) Sanity: HUD buttons exist and are focusable, but no - // modal open and no hover ⇒ FocusedButton stays None. - assert!( - app.world().resource::().0.is_none(), - "no modal open, no auto-focus" - ); - - // Press Tab. With no modal and no hover, `handle_focus_keys` - // resolves no active group and returns early — Tab must not - // advance the HUD focus ring on its own. - { - let mut input = app.world_mut().resource_mut::>(); - input.release_all(); - input.clear(); - input.press(KeyCode::Tab); - } - app.update(); - - assert!( - app.world().resource::().0.is_none(), - "Tab with no modal and no Hud hover must not engage the HUD focus ring" - ); - - // (b) Hover the Menu button — the leftmost HUD action — and - // Tab. The Hud-group cycle should pick a Hud-tagged entity. - let menu_entity = app - .world_mut() - .query_filtered::>() - .iter(app.world()) - .next() - .expect("MenuButton entity should exist"); - app.world_mut() - .entity_mut(menu_entity) - .insert(Interaction::Hovered); - - { - let mut input = app.world_mut().resource_mut::>(); - input.release_all(); - input.clear(); - input.press(KeyCode::Tab); - } - app.update(); - - let focused = app - .world() - .resource::() - .0 - .expect("Tab with a HUD button hovered must engage the HUD focus ring"); - // The focused entity must itself be Hud-grouped (i.e. one of - // the action-bar buttons), not anything else in the world. - let focusable = app - .world() - .entity(focused) - .get::() - .expect("focused entity must carry Focusable"); - assert_eq!( - focusable.group, - FocusGroup::Hud, - "Hud-engaged Tab must focus a Hud-grouped entity" - ); - } -} +mod tests; diff --git a/solitaire_engine/src/hud_plugin/tests.rs b/solitaire_engine/src/hud_plugin/tests.rs new file mode 100644 index 0000000..c3aaff4 --- /dev/null +++ b/solitaire_engine/src/hud_plugin/tests.rs @@ -0,0 +1,872 @@ +use super::*; +use crate::game_plugin::GamePlugin; +use crate::table_plugin::TablePlugin; +use chrono::Local; +use solitaire_core::{DrawStockConfig, game_state::GameState}; + +fn headless_app() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(GamePlugin) + .add_plugins(TablePlugin) + .add_plugins(HudPlugin); + app.update(); + app +} + +#[test] +fn hud_plugin_registers_without_panic() { + let _app = headless_app(); +} + +#[test] +fn update_hud_runs_after_game_mutation_without_panic() { + let mut app = headless_app(); + app.world_mut().resource_mut::().0 = + GameState::new(42, DrawStockConfig::DrawOne); + app.update(); +} + +fn read_hud_text(app: &mut App) -> String { + app.world_mut() + .query_filtered::<&Text, With>() + .iter(app.world()) + .next() + .map(|t| t.0.clone()) + .unwrap_or_default() +} + +#[test] +fn score_reflects_game_state() { + let mut app = headless_app(); + let score = app.world_mut().resource_mut::().0.force_test_score(20); + app.update(); + assert_eq!(read_hud_text::(&mut app), format!("Score: {score}")); +} + +#[test] +fn moves_reflects_game_state() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::() + .0 + .set_test_move_count(42); + app.update(); + assert_eq!(read_hud_text::(&mut app), "Moves: 42"); +} + +#[test] +fn draw_three_mode_shows_draw_3_badge() { + use solitaire_core::game_state::GameMode; + let mut app = headless_app(); + app.world_mut().resource_mut::().0 = + GameState::new_with_mode(42, DrawStockConfig::DrawThree, GameMode::Classic); + app.update(); + assert_eq!(read_hud_text::(&mut app), "Draw 3"); +} + +#[test] +fn zen_mode_hides_score() { + use solitaire_core::game_state::GameMode; + let mut app = headless_app(); + app.world_mut().resource_mut::().0 = + GameState::new_with_mode(42, DrawStockConfig::DrawOne, GameMode::Zen); + app.update(); + // Zen mode spec: "No score display" → text must be empty. + assert_eq!(read_hud_text::(&mut app), ""); +} + +#[test] +fn time_display_uses_mm_ss_format() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::() + .0 + .elapsed_seconds = 125; + app.update(); + // 125 seconds = 2 minutes 5 seconds → "2:05" + assert_eq!(read_hud_text::(&mut app), "2:05"); +} + +// ----------------------------------------------------------------------- +// format_time_limit (pure function) +// ----------------------------------------------------------------------- + +#[test] +fn format_time_limit_300_is_5_00() { + assert_eq!(format_time_limit(300), "5:00"); +} + +#[test] +fn format_time_limit_zero() { + assert_eq!(format_time_limit(0), "0:00"); +} + +#[test] +fn format_time_limit_pads_seconds() { + assert_eq!(format_time_limit(65), "1:05"); +} + +// ----------------------------------------------------------------------- +// challenge_hud_text (pure function) +// ----------------------------------------------------------------------- + +#[test] +fn challenge_hud_text_shows_time_limit() { + let dc = DailyChallengeResource { + date: Local::now().date_naive(), + seed: 1, + goal_description: None, + target_score: None, + max_time_secs: Some(300), + }; + assert_eq!(challenge_hud_text(&dc), "Limit: 5:00"); +} + +#[test] +fn challenge_hud_text_shows_score_goal() { + let dc = DailyChallengeResource { + date: Local::now().date_naive(), + seed: 1, + goal_description: None, + target_score: Some(4000), + max_time_secs: None, + }; + assert_eq!(challenge_hud_text(&dc), "Goal: 4000 pts"); +} + +#[test] +fn challenge_hud_text_empty_when_no_constraints() { + let dc = DailyChallengeResource { + date: Local::now().date_naive(), + seed: 1, + goal_description: None, + target_score: None, + max_time_secs: None, + }; + assert_eq!(challenge_hud_text(&dc), ""); +} + +#[test] +fn challenge_time_color_above_60_is_info() { + let c = challenge_time_color(61); + assert_eq!(c, STATE_INFO); +} + +#[test] +fn challenge_time_color_exactly_60_is_info() { + let c = challenge_time_color(60); + assert_eq!(c, STATE_INFO); +} + +#[test] +fn challenge_time_color_59_is_warning() { + let c = challenge_time_color(59); + assert_eq!(c, STATE_WARNING); +} + +#[test] +fn challenge_time_color_30_is_warning() { + let c = challenge_time_color(30); + assert_eq!(c, STATE_WARNING); +} + +#[test] +fn challenge_time_color_29_is_danger() { + let c = challenge_time_color(29); + assert_eq!(c, STATE_DANGER); +} + +#[test] +fn challenge_time_color_zero_is_danger() { + let c = challenge_time_color(0); + assert_eq!(c, STATE_DANGER); +} + +// ----------------------------------------------------------------------- +// HudChallenge in-app tests +// ----------------------------------------------------------------------- + +#[test] +fn challenge_hud_empty_when_no_daily_resource() { + // No DailyChallengeResource inserted → HudChallenge must be empty. + let mut app = headless_app(); + app.world_mut().resource_mut::().set_changed(); + app.update(); + assert_eq!(read_hud_text::(&mut app), ""); +} + +#[test] +fn challenge_hud_shows_time_limit_when_resource_present() { + let mut app = headless_app(); + app.world_mut().insert_resource(DailyChallengeResource { + date: Local::now().date_naive(), + seed: 42, + goal_description: Some("Win fast".to_string()), + target_score: None, + max_time_secs: Some(300), + }); + app.world_mut().resource_mut::().set_changed(); + app.update(); + assert_eq!(read_hud_text::(&mut app), "Limit: 5:00"); +} + +#[test] +fn challenge_hud_shows_score_goal_when_resource_present() { + let mut app = headless_app(); + app.world_mut().insert_resource(DailyChallengeResource { + date: Local::now().date_naive(), + seed: 42, + goal_description: None, + target_score: Some(4000), + max_time_secs: None, + }); + app.world_mut().resource_mut::().set_changed(); + app.update(); + assert_eq!(read_hud_text::(&mut app), "Goal: 4000 pts"); +} + +#[test] +fn challenge_hud_clears_on_win() { + let mut app = headless_app(); + app.world_mut().insert_resource(DailyChallengeResource { + date: Local::now().date_naive(), + seed: 42, + goal_description: None, + target_score: None, + max_time_secs: Some(300), + }); + // Mark the game as won — HudChallenge should be empty. + app.world_mut().resource_mut::().0.set_test_won(true); + app.update(); + assert_eq!(read_hud_text::(&mut app), ""); +} + +// ----------------------------------------------------------------------- +// HudUndos in-app tests +// ----------------------------------------------------------------------- + +#[test] +fn undos_hud_empty_at_game_start() { + let mut app = headless_app(); + app.update(); + assert_eq!(read_hud_text::(&mut app), ""); +} + +#[test] +fn undos_hud_shows_count_after_undo() { + let mut app = headless_app(); + app.world_mut() + .resource_mut::() + .0 + .force_test_undos(3); + app.update(); + assert_eq!(read_hud_text::(&mut app), "Undos: 3"); +} + +// ----------------------------------------------------------------------- +// HudAutoComplete in-app tests (Task #56) +// ----------------------------------------------------------------------- + +fn headless_app_with_auto_complete() -> App { + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(GamePlugin) + .add_plugins(TablePlugin) + .add_plugins(HudPlugin); + app.init_resource::(); + app.update(); + app +} + +#[test] +fn auto_complete_badge_shows_auto_when_active() { + let mut app = headless_app_with_auto_complete(); + app.world_mut().resource_mut::().active = true; + // Also trigger game state change so the update fires. + app.world_mut() + .resource_mut::() + .0 + .set_test_move_count(1); + app.update(); + assert_eq!(read_hud_text::(&mut app), "AUTO"); +} + +#[test] +fn auto_complete_badge_empty_when_inactive() { + let mut app = headless_app_with_auto_complete(); + // active is false by default. + app.world_mut() + .resource_mut::() + .0 + .set_test_move_count(1); + app.update(); + assert_eq!(read_hud_text::(&mut app), ""); +} + +// ----------------------------------------------------------------------- +// HudRecycles in-app tests +// ----------------------------------------------------------------------- + +#[test] +fn recycles_hud_hidden_when_zero_in_draw_one_mode() { + let mut app = headless_app(); + // Draw-One, no recycles yet — text must be empty. + app.world_mut().resource_mut::().0 = + GameState::new(42, DrawStockConfig::DrawOne); + app.update(); + assert_eq!(read_hud_text::(&mut app), ""); +} + +#[test] +fn recycles_hud_hidden_when_zero_in_draw_three_mode() { + let mut app = headless_app(); + // Draw-Three, no recycles yet — text must also be empty. + app.world_mut().resource_mut::().0 = + GameState::new(42, DrawStockConfig::DrawThree); + app.update(); + assert_eq!(read_hud_text::(&mut app), ""); +} + +#[test] +fn recycles_hud_shows_count_draw_three() { + let mut app = headless_app(); + let mut gs = GameState::new(42, DrawStockConfig::DrawThree); + gs.force_test_recycles(3); + app.world_mut().resource_mut::().0 = gs; + app.update(); + assert_eq!(read_hud_text::(&mut app), "Recycles: 3"); +} + +#[test] +fn recycles_hud_shows_count_draw_one() { + let mut app = headless_app(); + // Draw-One with recycle_count > 0 must now show the counter too. + let mut gs = GameState::new(42, DrawStockConfig::DrawOne); + gs.force_test_recycles(2); + app.world_mut().resource_mut::().0 = gs; + app.update(); + assert_eq!(read_hud_text::(&mut app), "Recycles: 2"); +} + +// ----------------------------------------------------------------------- +// Score-change feedback (G2) +// ----------------------------------------------------------------------- + +/// Tells `TimePlugin` to advance by `secs` on every subsequent +/// `app.update()`. Mirrors the helper in `ui_modal::tests`; kept +/// local to avoid coupling the two test modules. +fn set_manual_time_step(app: &mut App, secs: f32) { + use bevy::time::TimeUpdateStrategy; + use std::time::Duration; + app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32( + secs, + ))); +} + +/// Counts entities matching component `M` currently in the world. +fn count_with(app: &mut App) -> usize { + app.world_mut().query::<&M>().iter(app.world()).count() +} + +/// A score jump ≥ `SCORE_FLOATER_THRESHOLD` spawns a floating +/// `ScoreFloater` entity coloured `ACCENT_PRIMARY`. The pulse +/// component is also inserted on the score readout — both signals +/// fire from the same delta detection. +#[test] +fn score_increase_above_threshold_spawns_floater_in_accent_primary() { + let mut app = headless_app(); + // Pin `Time::delta_secs()` to 0 so the floater's RGB and alpha + // can be asserted exactly: with Automatic strategy a few ms + // of wall-clock time leaks in between updates and the alpha + // drifts below 1.0 by `dt / lifetime`. + set_manual_time_step(&mut app, 0.0); + // Initial state has score=0; bumping by 50 (the threshold) + // is the smallest jump that triggers the floater. + app.world_mut().resource_mut::().0.force_test_score(50); + app.update(); + + // One floater should now exist. + let count = count_with::(&mut app); + assert_eq!(count, 1, "expected a single ScoreFloater for a +50 jump"); + + // Its TextColor must be ACCENT_PRIMARY at full alpha. The + // detect system spawns the floater coloured ACCENT_PRIMARY + // and at dt=0 the first advance tick leaves alpha = 1.0. + let world = app.world_mut(); + let mut q = world.query::<(&ScoreFloater, &TextColor)>(); + let (_floater, color) = q.iter(world).next().expect("floater missing TextColor"); + assert_eq!(color.0, ACCENT_PRIMARY); +} + +/// After enough time for `MOTION_SCORE_PULSE_SECS * 2` to elapse +/// the floater has reached the end of its lifetime and despawned. +#[test] +fn score_floater_despawns_after_full_lifetime() { + let mut app = headless_app(); + app.world_mut().resource_mut::().0.force_test_score(50); + app.update(); + assert_eq!(count_with::(&mut app), 1); + + // Advance by a delta well past the floater's lifetime — the + // single oversized tick clamps t at 1.0 and the entity is + // despawned in the same `Update`. + set_manual_time_step(&mut app, MOTION_SCORE_PULSE_SECS * 2.0 * 2.0 + 0.1); + app.update(); + app.update(); // first update propagates the new strategy; second runs the system with non-zero dt. + + assert_eq!( + count_with::(&mut app), + 0, + "floater should have despawned after its full lifetime" + ); +} + +/// A small score change (below the threshold) inserts a pulse on +/// the readout but never spawns a floater — keeping the floating +/// "+N" reserved for meaningful score jumps. +#[test] +fn score_increase_below_threshold_does_not_spawn_floater() { + let mut app = headless_app(); + // +5 mirrors a single tableau-to-foundation move; well below + // the 50-point threshold so the floater path stays dormant. + app.world_mut().resource_mut::().0.force_test_score(5); + app.update(); + assert_eq!( + count_with::(&mut app), + 0, + "delta of +5 must not spawn a floater" + ); +} + +/// The triangular pulse curve hits its peak (1.1) at t=0.5 and +/// returns to 1.0 at the endpoints. Pure-function check that +/// guards the curve shape against future tweaks. +#[test] +fn score_pulse_scale_is_triangular() { + assert!((score_pulse_scale(0.0) - 1.0).abs() < 1e-6); + assert!((score_pulse_scale(0.5) - 1.1).abs() < 1e-6); + assert!((score_pulse_scale(1.0) - 1.0).abs() < 1e-6); + // Values outside [0,1] are clamped before the curve runs. + assert!((score_pulse_scale(-0.2) - 1.0).abs() < 1e-6); + assert!((score_pulse_scale(2.0) - 1.0).abs() < 1e-6); +} + +/// Streak flourish curve must be 1.0 at t=0, peak at t=0.5, and +/// return to 1.0 at t=duration. Mirrors the `foundation_flourish_scale` +/// curve test — the two animations share a triangular shape so a +/// future tweak that desyncs them shows up here. +#[test] +fn streak_flourish_scale_curves_through_one_one_one() { + let dur = MOTION_STREAK_FLOURISH_SECS; + assert!( + (streak_flourish_scale(0.0, dur) - 1.0).abs() < 1e-5, + "streak flourish scale at t=0 must be 1.0", + ); + assert!( + (streak_flourish_scale(dur / 2.0, dur) - STREAK_FLOURISH_PEAK_SCALE).abs() < 1e-5, + "streak flourish scale at midpoint must be STREAK_FLOURISH_PEAK_SCALE", + ); + assert!( + (streak_flourish_scale(dur, dur) - 1.0).abs() < 1e-5, + "streak flourish scale at t=duration must return to 1.0", + ); +} + +/// Out-of-range values are clamped, not extrapolated. Matches the +/// foundation flourish's clamp behaviour so the score readout never +/// freezes at a non-1.0 scale on the frame after the flourish ends. +#[test] +fn streak_flourish_scale_clamps_out_of_range() { + let dur = MOTION_STREAK_FLOURISH_SECS; + assert!((streak_flourish_scale(-1.0, dur) - 1.0).abs() < 1e-5); + assert!((streak_flourish_scale(dur * 5.0, dur) - 1.0).abs() < 1e-5); +} + +/// Zero duration (e.g. `AnimSpeed::Instant`) returns identity, never +/// divides by zero. +#[test] +fn streak_flourish_scale_zero_duration_is_one() { + assert!((streak_flourish_scale(0.0, 0.0) - 1.0).abs() < 1e-5); + assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5); +} + +// ----------------------------------------------------------------------- +// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish +// ----------------------------------------------------------------------- + +/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn +/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage. +#[test] +fn score_change_skips_pulse_and_floater_under_reduce_motion() { + use solitaire_data::Settings; + let mut app = headless_app(); + app.insert_resource(SettingsResource(Settings { + reduce_motion_mode: true, + ..Settings::default() + })); + // +100 would normally create both a ScorePulse and a ScoreFloater. + app.world_mut().resource_mut::().0.force_test_score(50); + app.update(); + assert_eq!( + count_with::(&mut app), + 0, + "ScorePulse must not spawn under reduce-motion" + ); + assert_eq!( + count_with::(&mut app), + 0, + "ScoreFloater must not spawn under reduce-motion" + ); +} + +// ----------------------------------------------------------------------- +// Phase 2: keyboard focus ring — HUD action bar +// ----------------------------------------------------------------------- + +/// Returns the `Focusable` carried by the unique entity matching +/// marker `M`. Helper for the HUD focus tests. +fn focusable_for(app: &mut App) -> Focusable { + app.world_mut() + .query_filtered::<&Focusable, With>() + .iter(app.world()) + .next() + .copied() + .unwrap_or_else(|| panic!("no Focusable on the {} button", std::any::type_name::())) +} + +#[test] +fn hud_buttons_get_focusable_marker() { + let mut app = headless_app(); + // Every action-bar button is in `FocusGroup::Hud`. + for f in [ + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + focusable_for::(&mut app), + ] { + assert_eq!( + f.group, + FocusGroup::Hud, + "every HUD action button must be in FocusGroup::Hud" + ); + } +} + +/// Returns the tooltip string carried by the unique entity matching +/// marker `M`. Panics if zero or more than one such entity exists, +/// which is the invariant we want to enforce for HUD readouts and +/// action buttons (each marker is spawned exactly once). +fn tooltip_for(app: &mut App) -> String { + let mut q = app.world_mut().query_filtered::<&Tooltip, With>(); + let world = app.world(); + let mut iter = q.iter(world); + let first = iter + .next() + .unwrap_or_else(|| { + panic!( + "expected a Tooltip on the {} entity", + std::any::type_name::() + ) + }) + .0 + .clone() + .into_owned(); + assert!( + iter.next().is_none(), + "expected exactly one Tooltip-bearing entity for {}", + std::any::type_name::() + ); + first +} + +/// Every HUD readout and action button must spawn with a `Tooltip` +/// carrying the approved canonical microcopy. Mirrors the structure +/// of `hud_buttons_get_focusable_marker` (Phase 2 focus test) so the +/// invariant — one marker entity, one tooltip, exact text — is +/// asserted consistently across every element. +#[test] +fn hud_elements_carry_expected_tooltip_strings() { + let mut app = headless_app(); + + // HUD readouts (left column, top to bottom). + assert_eq!( + tooltip_for::(&mut app), + "Points earned this game. Hidden in Zen mode." + ); + assert_eq!( + tooltip_for::(&mut app), + "Moves you've made this game. Counts placements and stock draws." + ); + assert_eq!( + tooltip_for::(&mut app), + "Time on this game. Counts down in Time Attack." + ); + assert_eq!( + tooltip_for::(&mut app), + "Active game mode. Click Modes to switch." + ); + assert_eq!( + tooltip_for::(&mut app), + "Today's daily challenge target. Beat it for bonus XP." + ); + assert_eq!( + tooltip_for::(&mut app), + "Cards drawn on the next stock click in Draw-Three." + ); + assert_eq!( + tooltip_for::(&mut app), + "Undos used this game. Any undo blocks the No Undo achievement." + ); + assert_eq!( + tooltip_for::(&mut app), + "Times you've recycled the stock. Three or more unlocks Comeback." + ); + assert_eq!( + tooltip_for::(&mut app), + "Board is solvable from here. Press Enter to auto-finish." + ); + assert_eq!( + tooltip_for::(&mut app), + "Pile selected with Tab. Use arrows or Enter to act." + ); + + // Action bar (left to right). + assert_eq!( + tooltip_for::(&mut app), + "Open Stats, Achievements, Profile, Settings, or Leaderboard." + ); + assert_eq!( + tooltip_for::(&mut app), + "Take back your last move. Costs points and blocks No Undo." + ); + assert_eq!( + tooltip_for::(&mut app), + "Pause the game and freeze the timer." + ); + assert_eq!( + tooltip_for::(&mut app), + "Show controls, rules, and keyboard shortcuts." + ); + assert_eq!( + tooltip_for::(&mut app), + "Highlight a suggested move. Cycles through alternatives on repeat taps." + ); + assert_eq!( + tooltip_for::(&mut app), + "Switch modes: Classic, Daily, Zen, Challenge, Time Attack." + ); + assert_eq!( + tooltip_for::(&mut app), + "Start a fresh deal. Confirms first if a game is in progress." + ); +} + +/// Every interior row of the Modes and Menu popovers must carry a +/// `Tooltip`. The popovers open from action-bar buttons whose own +/// tooltips are already covered above; this test extends the +/// invariant inward so hover discoverability is uniform across the +/// HUD's nested controls. +/// +/// We invoke the popover spawn helpers directly with a maxed-out +/// `ProgressResource` and a `DailyChallengeResource` so every row +/// branch fires (Classic, Daily, Zen, Challenge, Time Attack). +/// Headless click simulation isn't needed — the contract under +/// test is "every popover row spawns with a tooltip", which is a +/// property of the spawn helpers themselves. +#[test] +fn popover_rows_carry_tooltip_strings() { + use crate::progress_plugin::ProgressResource; + use solitaire_sync::progress::PlayerProgress; + + let mut app = headless_app(); + + // Force every mode row to render: level past the challenge + // unlock threshold, plus a daily challenge resource so the + // Daily row appears. + let progress = ProgressResource(PlayerProgress { + level: CHALLENGE_UNLOCK_LEVEL, + ..Default::default() + }); + let daily = DailyChallengeResource { + date: Local::now().date_naive(), + seed: 1, + goal_description: None, + target_score: None, + max_time_secs: None, + }; + + // Spawn both popovers via their helpers. Mirrors how the click + // handlers invoke them in production — we just skip the click. + { + let world = app.world_mut(); + let mut commands = world.commands(); + spawn_modes_popover(&mut commands, Some(&progress), Some(&daily), None); + spawn_menu_popover(&mut commands, None); + world.flush(); + } + app.update(); + + // Every ModeOption-tagged entity must also carry a Tooltip, + // and the count must match the five canonical modes. + let mut mode_q = app + .world_mut() + .query_filtered::<&Tooltip, With>(); + let mode_tooltips: Vec = mode_q + .iter(app.world()) + .map(|t| t.0.clone().into_owned()) + .collect(); + assert_eq!( + mode_tooltips.len(), + 5, + "expected a tooltip on each of the 5 mode rows, got {}", + mode_tooltips.len() + ); + // Every approved mode tooltip string must be present somewhere + // among the ModeOption rows. Order isn't asserted — the spawn + // order test elsewhere already covers that. + for expected in [ + "Standard Klondike. Score, timer, and full progression.", + "Today's seeded deal. Same for every player worldwide.", + "No timer, no score, no penalties. Just play.", + "Hand-picked hard seeds. No undo allowed.", + "Win as many games as you can in ten minutes.", + ] { + assert!( + mode_tooltips.iter().any(|s| s == expected), + "missing mode tooltip: {expected:?}" + ); + } + + // Same contract for MenuOption rows: seven entries, each with a + // tooltip, exact strings matching the approved microcopy. + let mut menu_q = app + .world_mut() + .query_filtered::<&Tooltip, With>(); + let menu_tooltips: Vec = menu_q + .iter(app.world()) + .map(|t| t.0.clone().into_owned()) + .collect(); + assert_eq!( + menu_tooltips.len(), + 7, + "expected a tooltip on each of the 7 menu rows, got {}", + menu_tooltips.len() + ); + for expected in [ + "Show controls, rules, and keyboard shortcuts.", + "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", + "Lifetime totals: wins, streaks, fastest time, best score.", + "Browse unlocked achievements and the rewards still ahead.", + "Your level, XP progress, and sync status.", + "Audio, animations, theme, draw mode, and sync.", + "Top players from your sync server. Opt in from Profile.", + ] { + assert!( + menu_tooltips.iter().any(|s| s == expected), + "missing menu tooltip: {expected:?}" + ); + } +} + +#[test] +fn hud_button_order_matches_spawn_order() { + let mut app = headless_app(); + // Visual reading order (left → right): Menu, Undo, Pause, Help, + // Hint, Modes, New Game. Their `order` fields must be 0..=6 in + // that order so Tab cycles them as the player reads them. + assert_eq!(focusable_for::(&mut app).order, 0); + assert_eq!(focusable_for::(&mut app).order, 1); + assert_eq!(focusable_for::(&mut app).order, 2); + assert_eq!(focusable_for::(&mut app).order, 3); + assert_eq!(focusable_for::(&mut app).order, 4); + assert_eq!(focusable_for::(&mut app).order, 5); + assert_eq!(focusable_for::(&mut app).order, 6); +} + +#[test] +fn hud_focus_only_engages_when_button_hovered() { + // Phase 2 declares membership in `FocusGroup::Hud`; the + // engagement rule lives in `handle_focus_keys`. Two halves to + // this test: + // (a) no modal + no hover ⇒ Tab is a no-op (Phase 1 contract + // still holds when nothing is hovered). + // (b) no modal + a HUD button hovered ⇒ Tab advances + // `FocusedButton` to a Hud-grouped entity. + use crate::ui_focus::{FocusedButton, UiFocusPlugin}; + use crate::ui_modal::UiModalPlugin; + + let mut app = App::new(); + app.add_plugins(MinimalPlugins) + .add_plugins(UiModalPlugin) + .add_plugins(UiFocusPlugin) + .add_plugins(GamePlugin) + .add_plugins(TablePlugin) + .add_plugins(HudPlugin); + app.init_resource::>(); + app.update(); + + // (a) Sanity: HUD buttons exist and are focusable, but no + // modal open and no hover ⇒ FocusedButton stays None. + assert!( + app.world().resource::().0.is_none(), + "no modal open, no auto-focus" + ); + + // Press Tab. With no modal and no hover, `handle_focus_keys` + // resolves no active group and returns early — Tab must not + // advance the HUD focus ring on its own. + { + let mut input = app.world_mut().resource_mut::>(); + input.release_all(); + input.clear(); + input.press(KeyCode::Tab); + } + app.update(); + + assert!( + app.world().resource::().0.is_none(), + "Tab with no modal and no Hud hover must not engage the HUD focus ring" + ); + + // (b) Hover the Menu button — the leftmost HUD action — and + // Tab. The Hud-group cycle should pick a Hud-tagged entity. + let menu_entity = app + .world_mut() + .query_filtered::>() + .iter(app.world()) + .next() + .expect("MenuButton entity should exist"); + app.world_mut() + .entity_mut(menu_entity) + .insert(Interaction::Hovered); + + { + let mut input = app.world_mut().resource_mut::>(); + input.release_all(); + input.clear(); + input.press(KeyCode::Tab); + } + app.update(); + + let focused = app + .world() + .resource::() + .0 + .expect("Tab with a HUD button hovered must engage the HUD focus ring"); + // The focused entity must itself be Hud-grouped (i.e. one of + // the action-bar buttons), not anything else in the world. + let focusable = app + .world() + .entity(focused) + .get::() + .expect("focused entity must carry Focusable"); + assert_eq!( + focusable.group, + FocusGroup::Hud, + "Hud-engaged Tab must focus a Hud-grouped entity" + ); +} diff --git a/solitaire_engine/src/input_plugin.rs b/solitaire_engine/src/input_plugin/mod.rs similarity index 69% rename from solitaire_engine/src/input_plugin.rs rename to solitaire_engine/src/input_plugin/mod.rs index 0c9b10d..0297e1a 100644 --- a/solitaire_engine/src/input_plugin.rs +++ b/solitaire_engine/src/input_plugin/mod.rs @@ -1829,712 +1829,4 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> { const _VEC3_REFERENCED: Option = None; #[cfg(test)] -mod tests { - use super::*; - use crate::layout::compute_layout; - use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau}; - use solitaire_core::{DrawStockConfig, game_state::GameState}; - - fn clear_test_piles(game: &mut GameState) { - 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()); - } - } - - #[test] - fn dragged_card_z_matches_resting_stack_step() { - assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6); - let step = dragged_card_z(1) - dragged_card_z(0); - assert!( - step > 0.02, - "drag step must exceed Android overlay local_z, got {step}" - ); - assert!( - step + 1e-4 >= STACK_FAN_FRAC, - "drag step must stay aligned with resting stack spacing, got {step}" - ); - } - - #[test] - fn point_in_rect_inside_returns_true() { - let center = Vec2::new(10.0, 20.0); - let size = Vec2::new(40.0, 60.0); - assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size)); - assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size)); - assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size)); - } - - #[test] - fn point_in_rect_on_edge_returns_true() { - let center = Vec2::ZERO; - let size = Vec2::new(10.0, 10.0); - assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size)); - assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size)); - } - - #[test] - fn point_in_rect_outside_returns_false() { - let center = Vec2::ZERO; - let size = Vec2::new(10.0, 10.0); - assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size)); - assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size)); - assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size)); - } - - #[test] - fn find_draggable_picks_top_of_tableau() { - let game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - - // In tableau 6, the visually topmost card is the last (face-up) one. - // Its position: base.y + fan * 6. - let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); - let result = find_draggable_at(top_pos, &game, &layout).expect("hit"); - assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); - assert_eq!(result.1, 6); - assert_eq!(result.2.len(), 1); - } - - #[test] - fn find_draggable_picks_waste_top_with_multiple_cards() { - // Reproduces the reported "drags the wrong waste card" bug: with several - // cards in the waste, clicking the visible top must pick the actual top - // (last index), not the buffer card underneath it. - let mut game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - clear_test_piles(&mut game); - let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), - Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), - Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)]; - game.set_test_waste_cards(waste.clone()); - - let top_index = waste.len() - 1; // 2 = the visible top - let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index); - let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable"); - assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile"); - assert_eq!(result.1, top_index, "picks the top index, not the buffer"); - assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only"); - } - - #[test] - fn find_draggable_picks_lone_waste_card() { - // "can't play the first card in the stock" — a waste of one card must - // still be draggable. - let mut game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - clear_test_piles(&mut game); - let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace); - game.set_test_waste_cards(vec![card.clone()]); - - let pos = card_position(&game, &layout, &KlondikePile::Stock, 0); - let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable"); - assert_eq!(result.0, KlondikePile::Stock); - assert_eq!(result.1, 0); - assert_eq!(result.2, vec![card]); - } - - #[test] - fn draw_three_waste_hit_test_matches_render_fan_step() { - // Regression: the Draw-Three waste hit-test must use the same fan step as - // the renderer (`card_plugin::waste_fan_step`). The previous hard-coded - // `card_size.x * 0.28` matched the renderer only on desktop (column step = - // 1.25*cw); under tighter Android-style spacing the two drift and the top - // fanned card's click target lands on the card beneath it — so dragging - // the visible top card plays the wrong one. - let mut game = GameState::new(7, DrawStockConfig::DrawThree); - let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - - // Force tight (Android-like) column spacing: ~1.03 * card_width. - let cw = layout.card_size.x; - let base = layout.pile_positions[&KlondikePile::Stock]; - let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; - layout.pile_positions.insert( - KlondikePile::Tableau(Tableau::Tableau2), - Vec2::new(t1.x + cw * 1.03, t1.y), - ); - - clear_test_piles(&mut game); - let waste = vec![ - Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), - Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), - Card::new(Deck::Deck1, Suit::Spades, Rank::Nine), - Card::new(Deck::Deck1, Suit::Diamonds, Rank::King), - ]; - game.set_test_waste_cards(waste.clone()); - - // visible_start = len-3 = 1, so the top card sits at fan slot 2. - let top_index = waste.len() - 1; - let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index); - - let expected = base.x + 2.0 * waste_fan_step(&layout); - assert!( - (pos.x - expected).abs() < 1e-3, - "hit-test must use the shared waste fan step" - ); - // The old fixed constant would have drifted from the renderer here. - let old = base.x + 2.0 * cw * 0.28; - assert!( - (pos.x - old).abs() > 1.0, - "shared step must differ from the old fixed step under tight spacing" - ); - } - - #[test] - fn find_draggable_skips_face_down_cards() { - let game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - - // Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at - // the bottom (index 6). Click at the topmost face-down card's - // position — its full body is partly visible above the fanned - // face-up card, but the iterator should skip face-down cards and - // the cursor sits above the face-up card's AABB, so the result - // is None. - let face_down_pos = - card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0); - let result = find_draggable_at(face_down_pos, &game, &layout); - assert!(result.is_none(), "face-down cards should not be draggable"); - } - - #[test] - fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() { - // Regression test for the bug where input_plugin's hit-testing used - // a uniform 0.25 fan step but card_plugin renders face-down cards - // at 0.12 — so for any column with face-down cards above the - // face-up bottom card, clicking the visible card face missed the - // hit-test box and only the bottom strip of the card responded. - let game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - - // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card - // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at - // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. - let face_up_pos = - card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); - let result = find_draggable_at(face_up_pos, &game, &layout) - .expect("clicking the face-up card's visible centre must initiate a drag"); - assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); - assert_eq!(result.1, 6); - assert_eq!(result.2.len(), 1); - } - - #[test] - fn find_draggable_returns_run_when_picking_mid_stack() { - // Manually construct a tableau with three face-up cards all stacked. - let mut game = GameState::new(1, DrawStockConfig::DrawOne); - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - let king = Card::new(D::Deck1, Suit::Spades, Rank::King); - let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); - let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); - game.set_test_tableau_cards( - Tableau::Tableau1, - vec![king, queen.clone(), jack.clone()], - ); - - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - // The Queen's geometric center (index 1) is inside the Jack's bounding box - // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the - // Queen we click in her visible strip: the 0.25h band above the Jack's top - // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h. - let queen_center = - card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1); - let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); - let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); - assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); - assert_eq!(start, 1); - assert_eq!(ids, vec![queen, jack]); - } - - #[test] - fn find_draggable_skips_non_top_waste_card() { - let mut game = GameState::new(1, DrawStockConfig::DrawOne); - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); - let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); - game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]); - - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - // Both cards in waste sit at the same (x, y). Clicking should pick - // the visually top card (three_hearts), with count = 1. - let pos = card_position(&game, &layout, &KlondikePile::Stock, 0); - let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); - assert_eq!(pile, KlondikePile::Stock); - assert_eq!(start, 1); - assert_eq!(ids, vec![three_hearts]); - } - - #[test] - fn find_drop_target_hits_empty_tableau_pile_marker() { - let game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - // Move all cards out of tableau 0 so its marker is the only drop area. - let mut game = game; - game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); - let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; - let target = find_drop_target( - pos, - &game, - &layout, - &KlondikePile::Tableau(Tableau::Tableau7), - ); - assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1))); - } - - #[test] - fn find_drop_target_returns_none_for_origin() { - let game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)]; - let target = find_drop_target( - pos, - &game, - &layout, - &KlondikePile::Tableau(Tableau::Tableau4), - ); - assert_eq!(target, None); - } - - #[test] - fn pile_drop_rect_extends_for_tableau_with_cards() { - let game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - // Tableau 6 has 7 cards. - let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game); - // Expected: card_height + 6 fan steps. - let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac); - assert!( - (size.y - expected).abs() < 1e-3, - "expected {expected}, got {}", - size.y - ); - } - - #[test] - fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() { - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - use solitaire_core::{DrawStockConfig, game_state::GameMode}; - let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic); - // Three waste cards; top (four_clubs) is rightmost in the fan. - let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); - let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); - let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four); - game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]); - - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - let waste_base = layout.pile_positions[&KlondikePile::Stock]; - // Top card (slot=2) is at base.x + 2 * 0.28 * card_width. - let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x; - let cursor = Vec2::new(top_card_x, waste_base.y); - - let result = find_draggable_at(cursor, &game, &layout); - assert!( - result.is_some(), - "top fanned waste card must be hittable at its visual X position" - ); - let (pile, _start, ids) = result.unwrap(); - assert_eq!(pile, KlondikePile::Stock); - assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste"); - } - - #[test] - fn find_draggable_returns_none_for_click_on_empty_pile() { - let mut game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - // Clear tableau 0 so it's an empty slot. - game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); - let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; - let result = find_draggable_at(pos, &game, &layout); - assert!( - result.is_none(), - "clicking an empty pile must not produce a draggable" - ); - } - - #[test] - fn pile_drop_rect_is_card_sized_for_non_tableau() { - let game = GameState::new(42, DrawStockConfig::DrawOne); - let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); - for pile in [ - KlondikePile::Stock, - KlondikePile::Foundation(Foundation::Foundation3), - ] { - let (_, size) = pile_drop_rect(&pile, &layout, &game); - assert_eq!(size, layout.card_size); - } - } - - // ----------------------------------------------------------------------- - // Task #27 — best_destination pure-function tests - // ----------------------------------------------------------------------- - - #[test] - fn best_destination_returns_none_when_no_legal_move() { - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawStockConfig::DrawOne); - - // Clear everything except one card that has nowhere to go. - clear_test_piles(&mut game); - - // A Two of Clubs with empty foundations and empty tableau has no destination. - let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two); - assert!(best_destination(&card, &game).is_none()); - } - - // ----------------------------------------------------------------------- - // best_tableau_destination_for_stack pure-function tests - // ----------------------------------------------------------------------- - - #[test] - fn best_tableau_destination_for_stack_skips_source_pile() { - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawStockConfig::DrawOne); - - clear_test_piles(&mut game); - - // Only tableau 0 has anything; every other column is empty. - // A King is the only card that can go on an empty tableau column. - // Source is Tableau(0), so the result must NOT be Tableau(0). - let king = Card::new(D::Deck1, Suit::Hearts, Rank::King); - game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]); - - let result = best_tableau_destination_for_stack( - &king, - &KlondikePile::Tableau(Tableau::Tableau1), - &game, - 1, - ); - // Result must be some other empty tableau column, never the source. - if let Some((dest, _)) = result { - assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1)); - } - } - - #[test] - fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() { - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawStockConfig::DrawOne); - - clear_test_piles(&mut game); - - // Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King). - // All other piles are empty — no legal tableau target. - let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two); - game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]); - - let result = best_tableau_destination_for_stack( - &two_clubs, - &KlondikePile::Tableau(Tableau::Tableau1), - &game, - 1, - ); - assert!( - result.is_none(), - "Two of Clubs has no legal tableau destination on empty piles" - ); - } - - // ----------------------------------------------------------------------- - // Task #28 — find_hint pure-function tests - // ----------------------------------------------------------------------- - - #[test] - fn find_hint_finds_ace_to_foundation() { - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawStockConfig::DrawOne); - - // Place Ace of Clubs on top of tableau 0. - clear_test_piles(&mut game); - let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace); - game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]); - - let hint = find_hint(&game); - assert!(hint.is_some(), "should find a hint"); - let (from, to) = hint.unwrap(); - assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1)); - assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1)); - } - - // ----------------------------------------------------------------------- - // G key fires ForfeitRequestEvent (modal-based forfeit flow) - // ----------------------------------------------------------------------- - - /// `handle_keyboard_forfeit` only checks `paused` and the G keypress; - /// the "is there actually a game?" gating lives in - /// `pause_plugin::handle_forfeit_request` so it can surface a - /// "No game to forfeit" toast instead of failing silently. - #[test] - fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() { - // Build the system param state by hand so we don't rely on a - // full Bevy app: the assertion is that the function returns - // early on the paused branch without calling write_message. - // This is verified by the plain `if paused { return; }` shape; - // the body is small enough to inspect by reading. - // (Higher-level integration coverage lives in the pause-plugin - // tests where `forfeit_app` simulates the full flow.) - let _ = handle_keyboard_forfeit; // proves the symbol still compiles - } - - // ----------------------------------------------------------------------- - // all_hints / new-game window — pure-function tests added during refactor - // ----------------------------------------------------------------------- - - /// Pass 3 of `all_hints` should suggest drawing from the stock when there - /// are no other moves and the stock is non-empty. - #[test] - fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() { - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - let mut game = GameState::new(1, DrawStockConfig::DrawOne); - - // Remove all foundation, tableau, and waste cards so no pile-to-pile - // move exists. Leave one card in the stock. - clear_test_piles(&mut game); - // Put one card back into the stock so "draw" is a valid suggestion. - game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]); - - let hints = all_hints(&game); - assert_eq!(hints.len(), 1, "exactly one hint: draw from stock"); - let (from, to) = &hints[0]; - assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock"); - assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste"); - } - - // `all_hints` must be empty when both stock and waste are empty and no - // pile-to-pile move exists — the game is truly stuck. - // ----------------------------------------------------------------------- - // Drag-rejection return tween — `CardAnimation` replaces the legacy - // `ShakeAnim` on the dragged cards. The audio cue - // (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the - // visual response on the dragged cards swapped from a horizontal wiggle - // to a smooth ease-out glide back to the origin pile. - // - // These tests build the component values exactly as `end_drag` and - // `touch_end_drag` would, then assert the resulting `CardAnimation` is - // shaped correctly. Driving `end_drag` end-to-end requires a real window - // and mouse-button input, so we exercise the data path the same way the - // legacy `ShakeAnim` tests did. - // ----------------------------------------------------------------------- - - /// Helper: build the `CardAnimation` the rejection paths construct for - /// one dragged card. Mirrors the inline logic in `end_drag` and - /// `touch_end_drag` so the tests stay in sync with the production code. - fn build_drag_reject_animation( - drag_pos: Vec2, - drag_z: f32, - target_pos: Vec2, - stack_index: usize, - ) -> CardAnimation { - let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC; - CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive) - .with_duration(MOTION_DRAG_REJECT_SECS) - } - - /// Every card in `drag.cards` should receive its own `CardAnimation` on - /// rejection. With the shake → tween migration, the assertion changes - /// from "every dragged card gets a ShakeAnim" to "every dragged card - /// gets a CardAnimation" — same coverage, new component. - #[test] - fn rejected_drag_inserts_card_animation_on_each_dragged_card() { - use solitaire_core::Deck as D; - use solitaire_core::{Card, Rank, Suit}; - // Simulate a stack drag of two cards. - let dragged_cards: Vec = vec![ - Card::new(D::Deck1, Suit::Hearts, Rank::King), - Card::new(D::Deck1, Suit::Spades, Rank::Queen), - ]; - - let mut animated: Vec = Vec::new(); - for card in &dragged_cards { - // In `end_drag` we iterate `drag.cards` and look up each card in - // `card_entities`. The cards we would insert a `CardAnimation` on - // must exactly match the dragged set. - animated.push(card.clone()); - } - - assert_eq!( - animated, dragged_cards, - "every card in drag.cards must receive a CardAnimation on rejection" - ); - } - - /// The `end` field of the inserted tween must equal the card's resting - /// slot in its origin pile — the position the card belongs at after a - /// rejected drop. Without this, the tween would glide to the wrong spot - /// and `sync_cards` would have to fight it back. - #[test] - fn rejected_drag_animation_targets_origin_resting_position() { - let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen - let target_pos = Vec2::new(123.5, -50.0); // origin pile slot - let anim = - build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3); - - assert!( - (anim.end - target_pos).length() < 1e-6, - "CardAnimation.end must match the origin slot's resting position. \ - Expected {target_pos:?}, got {:?}", - anim.end - ); - } - - /// The `start` field of the inserted tween must equal the card's - /// drop-time transform position — i.e. wherever the cursor or finger - /// released the card. This is what makes the glide feel like a - /// continuous return rather than a teleport-then-shake. - #[test] - fn rejected_drag_animation_starts_from_drag_position() { - let drag_pos = Vec2::new(640.0, 200.0); - let target_pos = Vec2::new(80.0, -120.0); - let anim = - build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0); - - assert!( - (anim.start - drag_pos).length() < 1e-6, - "CardAnimation.start must match the drop-time transform position \ - (where the cursor released). Expected {drag_pos:?}, got {:?}", - anim.start - ); - // And the start must be visibly distinct from the origin slot — the - // whole point of the tween is that it visibly travels. - assert!( - (anim.start - anim.end).length() > 1.0, - "rejected drag should travel a visible distance, got start={:?} end={:?}", - anim.start, - anim.end - ); - } - - /// The tween duration is taken from the project-wide motion token so - /// designers can retune the feel from one place. Keeps the constant and - /// the call site honest. - #[test] - fn rejected_drag_animation_uses_correct_duration() { - let anim = build_drag_reject_animation( - Vec2::new(640.0, 200.0), - DRAG_Z, - Vec2::new(80.0, -120.0), - 0, - ); - assert!( - (anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6, - "drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \ - ({MOTION_DRAG_REJECT_SECS}), got {}", - anim.duration - ); - } - - /// The curve must be a no-overshoot ease-out so the card decelerates - /// cleanly into its rest position — overshoot on a rejection feels - /// jittery rather than forgiving. - #[test] - fn rejected_drag_animation_uses_responsive_curve() { - let anim = build_drag_reject_animation( - Vec2::new(640.0, 200.0), - DRAG_Z, - Vec2::new(80.0, -120.0), - 0, - ); - assert_eq!( - anim.curve, - MotionCurve::Responsive, - "drag-rejection tween must use Responsive (quintic ease-out) \ - so the card snaps back without bouncing past the slot" - ); - } - - /// The `start_z` of the tween must equal the card's drop-time z - /// (`DRAG_Z`) so the card stays above the rest of the table while it - /// travels home, then settles at the correct resting z. - #[test] - fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() { - let stack_index = 2_usize; - let anim = build_drag_reject_animation( - Vec2::new(640.0, 200.0), - DRAG_Z, - Vec2::new(80.0, -120.0), - stack_index, - ); - assert!( - (anim.start_z - DRAG_Z).abs() < 1e-6, - "tween must start at DRAG_Z so the card stays on top during the glide" - ); - let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC; - assert!( - (anim.end_z - expected_end_z).abs() < 1e-6, - "tween must end at the slot's resting z, got {} expected {expected_end_z}", - anim.end_z - ); - } - - // ----------------------------------------------------------------------- - // Hint system — async port (v0.18.0+) - // - // `handle_keyboard_hint` no longer runs the solver inline; it - // spawns an `AsyncComputeTaskPool` task whose result the polling - // system in `pending_hint` turns into hint visuals one frame - // later. The behaviour contract this section pins is "pressing H - // populates `PendingHintTask`" — the spawn-to-emit pipeline is - // covered end-to-end in `pending_hint::tests`. - // ----------------------------------------------------------------------- - - /// Pressing H on a non-paused, non-won game with a live - /// `GameStateResource` + `LayoutResource` must populate - /// `PendingHintTask`. The polling system, exercised in - /// `pending_hint::tests`, drives the result to a visual event. - #[test] - fn pressing_h_spawns_pending_hint_task() { - let mut app = App::new(); - app.add_plugins(MinimalPlugins); - app.add_message::(); - app.add_message::(); - app.init_resource::(); - app.init_resource::(); - app.init_resource::(); - app.init_resource::>(); - app.insert_resource(LayoutResource( - compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), - )); - app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne))); - app.add_systems(Update, handle_keyboard_hint); - - // Simulate the H key being pressed this frame. - { - let mut input = app.world_mut().resource_mut::>(); - input.release(KeyCode::KeyH); - input.clear(); - input.press(KeyCode::KeyH); - } - app.update(); - - assert!( - app.world() - .resource::() - .is_pending(), - "pressing H must spawn an async hint task", - ); - } -} +mod tests; diff --git a/solitaire_engine/src/input_plugin/tests.rs b/solitaire_engine/src/input_plugin/tests.rs new file mode 100644 index 0000000..0750600 --- /dev/null +++ b/solitaire_engine/src/input_plugin/tests.rs @@ -0,0 +1,707 @@ +use super::*; +use crate::layout::compute_layout; +use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau}; +use solitaire_core::{DrawStockConfig, game_state::GameState}; + +fn clear_test_piles(game: &mut GameState) { + 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()); + } +} + +#[test] +fn dragged_card_z_matches_resting_stack_step() { + assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6); + let step = dragged_card_z(1) - dragged_card_z(0); + assert!( + step > 0.02, + "drag step must exceed Android overlay local_z, got {step}" + ); + assert!( + step + 1e-4 >= STACK_FAN_FRAC, + "drag step must stay aligned with resting stack spacing, got {step}" + ); +} + +#[test] +fn point_in_rect_inside_returns_true() { + let center = Vec2::new(10.0, 20.0); + let size = Vec2::new(40.0, 60.0); + assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size)); + assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size)); + assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size)); +} + +#[test] +fn point_in_rect_on_edge_returns_true() { + let center = Vec2::ZERO; + let size = Vec2::new(10.0, 10.0); + assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size)); + assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size)); +} + +#[test] +fn point_in_rect_outside_returns_false() { + let center = Vec2::ZERO; + let size = Vec2::new(10.0, 10.0); + assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size)); + assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size)); + assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size)); +} + +#[test] +fn find_draggable_picks_top_of_tableau() { + let game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + + // In tableau 6, the visually topmost card is the last (face-up) one. + // Its position: base.y + fan * 6. + let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); + let result = find_draggable_at(top_pos, &game, &layout).expect("hit"); + assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); + assert_eq!(result.1, 6); + assert_eq!(result.2.len(), 1); +} + +#[test] +fn find_draggable_picks_waste_top_with_multiple_cards() { + // Reproduces the reported "drags the wrong waste card" bug: with several + // cards in the waste, clicking the visible top must pick the actual top + // (last index), not the buffer card underneath it. + let mut game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + clear_test_piles(&mut game); + let waste = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), + Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), + Card::new(Deck::Deck1, Suit::Spades, Rank::Nine)]; + game.set_test_waste_cards(waste.clone()); + + let top_index = waste.len() - 1; // 2 = the visible top + let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index); + let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable"); + assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile"); + assert_eq!(result.1, top_index, "picks the top index, not the buffer"); + assert_eq!(result.2, vec![waste[top_index].clone()], "drags the top card only"); +} + +#[test] +fn find_draggable_picks_lone_waste_card() { + // "can't play the first card in the stock" — a waste of one card must + // still be draggable. + let mut game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + clear_test_piles(&mut game); + let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace); + game.set_test_waste_cards(vec![card.clone()]); + + let pos = card_position(&game, &layout, &KlondikePile::Stock, 0); + let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable"); + assert_eq!(result.0, KlondikePile::Stock); + assert_eq!(result.1, 0); + assert_eq!(result.2, vec![card]); +} + +#[test] +fn draw_three_waste_hit_test_matches_render_fan_step() { + // Regression: the Draw-Three waste hit-test must use the same fan step as + // the renderer (`card_plugin::waste_fan_step`). The previous hard-coded + // `card_size.x * 0.28` matched the renderer only on desktop (column step = + // 1.25*cw); under tighter Android-style spacing the two drift and the top + // fanned card's click target lands on the card beneath it — so dragging + // the visible top card plays the wrong one. + let mut game = GameState::new(7, DrawStockConfig::DrawThree); + let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + + // Force tight (Android-like) column spacing: ~1.03 * card_width. + let cw = layout.card_size.x; + let base = layout.pile_positions[&KlondikePile::Stock]; + let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; + layout.pile_positions.insert( + KlondikePile::Tableau(Tableau::Tableau2), + Vec2::new(t1.x + cw * 1.03, t1.y), + ); + + clear_test_piles(&mut game); + let waste = vec![ + Card::new(Deck::Deck1, Suit::Clubs, Rank::Two), + Card::new(Deck::Deck1, Suit::Hearts, Rank::Five), + Card::new(Deck::Deck1, Suit::Spades, Rank::Nine), + Card::new(Deck::Deck1, Suit::Diamonds, Rank::King), + ]; + game.set_test_waste_cards(waste.clone()); + + // visible_start = len-3 = 1, so the top card sits at fan slot 2. + let top_index = waste.len() - 1; + let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index); + + let expected = base.x + 2.0 * waste_fan_step(&layout); + assert!( + (pos.x - expected).abs() < 1e-3, + "hit-test must use the shared waste fan step" + ); + // The old fixed constant would have drifted from the renderer here. + let old = base.x + 2.0 * cw * 0.28; + assert!( + (pos.x - old).abs() > 1.0, + "shared step must differ from the old fixed step under tight spacing" + ); +} + +#[test] +fn find_draggable_skips_face_down_cards() { + let game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + + // Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at + // the bottom (index 6). Click at the topmost face-down card's + // position — its full body is partly visible above the fanned + // face-up card, but the iterator should skip face-down cards and + // the cursor sits above the face-up card's AABB, so the result + // is None. + let face_down_pos = + card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0); + let result = find_draggable_at(face_down_pos, &game, &layout); + assert!(result.is_none(), "face-down cards should not be draggable"); +} + +#[test] +fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() { + // Regression test for the bug where input_plugin's hit-testing used + // a uniform 0.25 fan step but card_plugin renders face-down cards + // at 0.12 — so for any column with face-down cards above the + // face-up bottom card, clicking the visible card face missed the + // hit-test box and only the bottom strip of the card responded. + let game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + + // Tableau 6 starts with 6 face-down + 1 face-up. The face-up card + // sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at + // base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre. + let face_up_pos = + card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6); + let result = find_draggable_at(face_up_pos, &game, &layout) + .expect("clicking the face-up card's visible centre must initiate a drag"); + assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7)); + assert_eq!(result.1, 6); + assert_eq!(result.2.len(), 1); +} + +#[test] +fn find_draggable_returns_run_when_picking_mid_stack() { + // Manually construct a tableau with three face-up cards all stacked. + let mut game = GameState::new(1, DrawStockConfig::DrawOne); + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + let king = Card::new(D::Deck1, Suit::Spades, Rank::King); + let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen); + let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack); + game.set_test_tableau_cards( + Tableau::Tableau1, + vec![king, queen.clone(), jack.clone()], + ); + + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + // The Queen's geometric center (index 1) is inside the Jack's bounding box + // (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the + // Queen we click in her visible strip: the 0.25h band above the Jack's top + // edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h. + let queen_center = + card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1); + let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375); + let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); + assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1)); + assert_eq!(start, 1); + assert_eq!(ids, vec![queen, jack]); +} + +#[test] +fn find_draggable_skips_non_top_waste_card() { + let mut game = GameState::new(1, DrawStockConfig::DrawOne); + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); + let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); + game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]); + + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + // Both cards in waste sit at the same (x, y). Clicking should pick + // the visually top card (three_hearts), with count = 1. + let pos = card_position(&game, &layout, &KlondikePile::Stock, 0); + let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit"); + assert_eq!(pile, KlondikePile::Stock); + assert_eq!(start, 1); + assert_eq!(ids, vec![three_hearts]); +} + +#[test] +fn find_drop_target_hits_empty_tableau_pile_marker() { + let game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + // Move all cards out of tableau 0 so its marker is the only drop area. + let mut game = game; + game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); + let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; + let target = find_drop_target( + pos, + &game, + &layout, + &KlondikePile::Tableau(Tableau::Tableau7), + ); + assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1))); +} + +#[test] +fn find_drop_target_returns_none_for_origin() { + let game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)]; + let target = find_drop_target( + pos, + &game, + &layout, + &KlondikePile::Tableau(Tableau::Tableau4), + ); + assert_eq!(target, None); +} + +#[test] +fn pile_drop_rect_extends_for_tableau_with_cards() { + let game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + // Tableau 6 has 7 cards. + let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game); + // Expected: card_height + 6 fan steps. + let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac); + assert!( + (size.y - expected).abs() < 1e-3, + "expected {expected}, got {}", + size.y + ); +} + +#[test] +fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() { + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + use solitaire_core::{DrawStockConfig, game_state::GameMode}; + let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic); + // Three waste cards; top (four_clubs) is rightmost in the fan. + let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two); + let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three); + let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four); + game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]); + + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + let waste_base = layout.pile_positions[&KlondikePile::Stock]; + // Top card (slot=2) is at base.x + 2 * 0.28 * card_width. + let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x; + let cursor = Vec2::new(top_card_x, waste_base.y); + + let result = find_draggable_at(cursor, &game, &layout); + assert!( + result.is_some(), + "top fanned waste card must be hittable at its visual X position" + ); + let (pile, _start, ids) = result.unwrap(); + assert_eq!(pile, KlondikePile::Stock); + assert_eq!(ids, vec![four_clubs], "only the top card is draggable from waste"); +} + +#[test] +fn find_draggable_returns_none_for_click_on_empty_pile() { + let mut game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + // Clear tableau 0 so it's an empty slot. + game.set_test_tableau_cards(Tableau::Tableau1, Vec::new()); + let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)]; + let result = find_draggable_at(pos, &game, &layout); + assert!( + result.is_none(), + "clicking an empty pile must not produce a draggable" + ); +} + +#[test] +fn pile_drop_rect_is_card_sized_for_non_tableau() { + let game = GameState::new(42, DrawStockConfig::DrawOne); + let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); + for pile in [ + KlondikePile::Stock, + KlondikePile::Foundation(Foundation::Foundation3), + ] { + let (_, size) = pile_drop_rect(&pile, &layout, &game); + assert_eq!(size, layout.card_size); + } +} + +// ----------------------------------------------------------------------- +// Task #27 — best_destination pure-function tests +// ----------------------------------------------------------------------- + +#[test] +fn best_destination_returns_none_when_no_legal_move() { + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + let mut game = GameState::new(1, DrawStockConfig::DrawOne); + + // Clear everything except one card that has nowhere to go. + clear_test_piles(&mut game); + + // A Two of Clubs with empty foundations and empty tableau has no destination. + let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two); + assert!(best_destination(&card, &game).is_none()); +} + +// ----------------------------------------------------------------------- +// best_tableau_destination_for_stack pure-function tests +// ----------------------------------------------------------------------- + +#[test] +fn best_tableau_destination_for_stack_skips_source_pile() { + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + let mut game = GameState::new(1, DrawStockConfig::DrawOne); + + clear_test_piles(&mut game); + + // Only tableau 0 has anything; every other column is empty. + // A King is the only card that can go on an empty tableau column. + // Source is Tableau(0), so the result must NOT be Tableau(0). + let king = Card::new(D::Deck1, Suit::Hearts, Rank::King); + game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]); + + let result = best_tableau_destination_for_stack( + &king, + &KlondikePile::Tableau(Tableau::Tableau1), + &game, + 1, + ); + // Result must be some other empty tableau column, never the source. + if let Some((dest, _)) = result { + assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1)); + } +} + +#[test] +fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() { + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + let mut game = GameState::new(1, DrawStockConfig::DrawOne); + + clear_test_piles(&mut game); + + // Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King). + // All other piles are empty — no legal tableau target. + let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two); + game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]); + + let result = best_tableau_destination_for_stack( + &two_clubs, + &KlondikePile::Tableau(Tableau::Tableau1), + &game, + 1, + ); + assert!( + result.is_none(), + "Two of Clubs has no legal tableau destination on empty piles" + ); +} + +// ----------------------------------------------------------------------- +// Task #28 — find_hint pure-function tests +// ----------------------------------------------------------------------- + +#[test] +fn find_hint_finds_ace_to_foundation() { + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + let mut game = GameState::new(1, DrawStockConfig::DrawOne); + + // Place Ace of Clubs on top of tableau 0. + clear_test_piles(&mut game); + let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace); + game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]); + + let hint = find_hint(&game); + assert!(hint.is_some(), "should find a hint"); + let (from, to) = hint.unwrap(); + assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1)); + assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1)); +} + +// ----------------------------------------------------------------------- +// G key fires ForfeitRequestEvent (modal-based forfeit flow) +// ----------------------------------------------------------------------- + +/// `handle_keyboard_forfeit` only checks `paused` and the G keypress; +/// the "is there actually a game?" gating lives in +/// `pause_plugin::handle_forfeit_request` so it can surface a +/// "No game to forfeit" toast instead of failing silently. +#[test] +fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() { + // Build the system param state by hand so we don't rely on a + // full Bevy app: the assertion is that the function returns + // early on the paused branch without calling write_message. + // This is verified by the plain `if paused { return; }` shape; + // the body is small enough to inspect by reading. + // (Higher-level integration coverage lives in the pause-plugin + // tests where `forfeit_app` simulates the full flow.) + let _ = handle_keyboard_forfeit; // proves the symbol still compiles +} + +// ----------------------------------------------------------------------- +// all_hints / new-game window — pure-function tests added during refactor +// ----------------------------------------------------------------------- + +/// Pass 3 of `all_hints` should suggest drawing from the stock when there +/// are no other moves and the stock is non-empty. +#[test] +fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() { + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + let mut game = GameState::new(1, DrawStockConfig::DrawOne); + + // Remove all foundation, tableau, and waste cards so no pile-to-pile + // move exists. Leave one card in the stock. + clear_test_piles(&mut game); + // Put one card back into the stock so "draw" is a valid suggestion. + game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]); + + let hints = all_hints(&game); + assert_eq!(hints.len(), 1, "exactly one hint: draw from stock"); + let (from, to) = &hints[0]; + assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock"); + assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste"); +} + +// `all_hints` must be empty when both stock and waste are empty and no +// pile-to-pile move exists — the game is truly stuck. +// ----------------------------------------------------------------------- +// Drag-rejection return tween — `CardAnimation` replaces the legacy +// `ShakeAnim` on the dragged cards. The audio cue +// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the +// visual response on the dragged cards swapped from a horizontal wiggle +// to a smooth ease-out glide back to the origin pile. +// +// These tests build the component values exactly as `end_drag` and +// `touch_end_drag` would, then assert the resulting `CardAnimation` is +// shaped correctly. Driving `end_drag` end-to-end requires a real window +// and mouse-button input, so we exercise the data path the same way the +// legacy `ShakeAnim` tests did. +// ----------------------------------------------------------------------- + +/// Helper: build the `CardAnimation` the rejection paths construct for +/// one dragged card. Mirrors the inline logic in `end_drag` and +/// `touch_end_drag` so the tests stay in sync with the production code. +fn build_drag_reject_animation( + drag_pos: Vec2, + drag_z: f32, + target_pos: Vec2, + stack_index: usize, +) -> CardAnimation { + let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC; + CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive) + .with_duration(MOTION_DRAG_REJECT_SECS) +} + +/// Every card in `drag.cards` should receive its own `CardAnimation` on +/// rejection. With the shake → tween migration, the assertion changes +/// from "every dragged card gets a ShakeAnim" to "every dragged card +/// gets a CardAnimation" — same coverage, new component. +#[test] +fn rejected_drag_inserts_card_animation_on_each_dragged_card() { + use solitaire_core::Deck as D; + use solitaire_core::{Card, Rank, Suit}; + // Simulate a stack drag of two cards. + let dragged_cards: Vec = vec![ + Card::new(D::Deck1, Suit::Hearts, Rank::King), + Card::new(D::Deck1, Suit::Spades, Rank::Queen), + ]; + + let mut animated: Vec = Vec::new(); + for card in &dragged_cards { + // In `end_drag` we iterate `drag.cards` and look up each card in + // `card_entities`. The cards we would insert a `CardAnimation` on + // must exactly match the dragged set. + animated.push(card.clone()); + } + + assert_eq!( + animated, dragged_cards, + "every card in drag.cards must receive a CardAnimation on rejection" + ); +} + +/// The `end` field of the inserted tween must equal the card's resting +/// slot in its origin pile — the position the card belongs at after a +/// rejected drop. Without this, the tween would glide to the wrong spot +/// and `sync_cards` would have to fight it back. +#[test] +fn rejected_drag_animation_targets_origin_resting_position() { + let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen + let target_pos = Vec2::new(123.5, -50.0); // origin pile slot + let anim = + build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3); + + assert!( + (anim.end - target_pos).length() < 1e-6, + "CardAnimation.end must match the origin slot's resting position. \ + Expected {target_pos:?}, got {:?}", + anim.end + ); +} + +/// The `start` field of the inserted tween must equal the card's +/// drop-time transform position — i.e. wherever the cursor or finger +/// released the card. This is what makes the glide feel like a +/// continuous return rather than a teleport-then-shake. +#[test] +fn rejected_drag_animation_starts_from_drag_position() { + let drag_pos = Vec2::new(640.0, 200.0); + let target_pos = Vec2::new(80.0, -120.0); + let anim = + build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0); + + assert!( + (anim.start - drag_pos).length() < 1e-6, + "CardAnimation.start must match the drop-time transform position \ + (where the cursor released). Expected {drag_pos:?}, got {:?}", + anim.start + ); + // And the start must be visibly distinct from the origin slot — the + // whole point of the tween is that it visibly travels. + assert!( + (anim.start - anim.end).length() > 1.0, + "rejected drag should travel a visible distance, got start={:?} end={:?}", + anim.start, + anim.end + ); +} + +/// The tween duration is taken from the project-wide motion token so +/// designers can retune the feel from one place. Keeps the constant and +/// the call site honest. +#[test] +fn rejected_drag_animation_uses_correct_duration() { + let anim = build_drag_reject_animation( + Vec2::new(640.0, 200.0), + DRAG_Z, + Vec2::new(80.0, -120.0), + 0, + ); + assert!( + (anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6, + "drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \ + ({MOTION_DRAG_REJECT_SECS}), got {}", + anim.duration + ); +} + +/// The curve must be a no-overshoot ease-out so the card decelerates +/// cleanly into its rest position — overshoot on a rejection feels +/// jittery rather than forgiving. +#[test] +fn rejected_drag_animation_uses_responsive_curve() { + let anim = build_drag_reject_animation( + Vec2::new(640.0, 200.0), + DRAG_Z, + Vec2::new(80.0, -120.0), + 0, + ); + assert_eq!( + anim.curve, + MotionCurve::Responsive, + "drag-rejection tween must use Responsive (quintic ease-out) \ + so the card snaps back without bouncing past the slot" + ); +} + +/// The `start_z` of the tween must equal the card's drop-time z +/// (`DRAG_Z`) so the card stays above the rest of the table while it +/// travels home, then settles at the correct resting z. +#[test] +fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() { + let stack_index = 2_usize; + let anim = build_drag_reject_animation( + Vec2::new(640.0, 200.0), + DRAG_Z, + Vec2::new(80.0, -120.0), + stack_index, + ); + assert!( + (anim.start_z - DRAG_Z).abs() < 1e-6, + "tween must start at DRAG_Z so the card stays on top during the glide" + ); + let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC; + assert!( + (anim.end_z - expected_end_z).abs() < 1e-6, + "tween must end at the slot's resting z, got {} expected {expected_end_z}", + anim.end_z + ); +} + +// ----------------------------------------------------------------------- +// Hint system — async port (v0.18.0+) +// +// `handle_keyboard_hint` no longer runs the solver inline; it +// spawns an `AsyncComputeTaskPool` task whose result the polling +// system in `pending_hint` turns into hint visuals one frame +// later. The behaviour contract this section pins is "pressing H +// populates `PendingHintTask`" — the spawn-to-emit pipeline is +// covered end-to-end in `pending_hint::tests`. +// ----------------------------------------------------------------------- + +/// Pressing H on a non-paused, non-won game with a live +/// `GameStateResource` + `LayoutResource` must populate +/// `PendingHintTask`. The polling system, exercised in +/// `pending_hint::tests`, drives the result to a visual event. +#[test] +fn pressing_h_spawns_pending_hint_task() { + let mut app = App::new(); + app.add_plugins(MinimalPlugins); + app.add_message::(); + app.add_message::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::(); + app.init_resource::>(); + app.insert_resource(LayoutResource( + compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true), + )); + app.insert_resource(GameStateResource(GameState::new(42, DrawStockConfig::DrawOne))); + app.add_systems(Update, handle_keyboard_hint); + + // Simulate the H key being pressed this frame. + { + let mut input = app.world_mut().resource_mut::>(); + input.release(KeyCode::KeyH); + input.clear(); + input.press(KeyCode::KeyH); + } + app.update(); + + assert!( + app.world() + .resource::() + .is_pending(), + "pressing H must spawn an async hint task", + ); +} diff --git a/solitaire_engine/src/settings_plugin.rs b/solitaire_engine/src/settings_plugin/mod.rs similarity index 82% rename from solitaire_engine/src/settings_plugin.rs rename to solitaire_engine/src/settings_plugin/mod.rs index 1a87e5d..40c3bf3 100644 --- a/solitaire_engine/src/settings_plugin.rs +++ b/solitaire_engine/src/settings_plugin/mod.rs @@ -2854,659 +2854,4 @@ fn icon_button( // --------------------------------------------------------------------------- #[cfg(test)] -mod tests { - use super::*; - - fn headless_app() -> App { - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(SettingsPlugin::headless()); - app.init_resource::>(); - app.update(); - app - } - - fn press(app: &mut App, key: KeyCode) { - let mut input = app.world_mut().resource_mut::>(); - input.release(key); - input.clear(); - input.press(key); - } - - #[test] - fn defaults_are_loaded() { - let app = headless_app(); - assert_eq!( - app.world().resource::().0, - Settings::default() - ); - } - - #[test] - fn pressing_left_bracket_decreases_volume_and_emits_event() { - let mut app = headless_app(); - let before = app.world().resource::().0.sfx_volume; - - press(&mut app, KeyCode::BracketLeft); - app.update(); - - let after = app.world().resource::().0.sfx_volume; - assert!(after < before); - - let events = app.world().resource::>(); - let mut cursor = events.get_cursor(); - assert_eq!(cursor.read(events).count(), 1); - } - - #[test] - fn pressing_right_bracket_increases_volume() { - let mut app = headless_app(); - app.world_mut() - .resource_mut::() - .0 - .sfx_volume = 0.5; - - press(&mut app, KeyCode::BracketRight); - app.update(); - - let after = app.world().resource::().0.sfx_volume; - assert!((after - 0.6).abs() < 1e-3); - } - - #[test] - fn clamped_change_does_not_emit_event() { - let mut app = headless_app(); - app.world_mut() - .resource_mut::() - .0 - .sfx_volume = 1.0; - - press(&mut app, KeyCode::BracketRight); - app.update(); - - let events = app.world().resource::>(); - let mut cursor = events.get_cursor(); - assert_eq!(cursor.read(events).count(), 0); - } - - #[test] - fn volume_clamped_at_zero_does_not_emit_event() { - let mut app = headless_app(); - app.world_mut() - .resource_mut::() - .0 - .sfx_volume = 0.0; - - press(&mut app, KeyCode::BracketLeft); - app.update(); - - let after = app.world().resource::().0.sfx_volume; - assert!(after >= 0.0, "volume must not go below zero"); - - let events = app.world().resource::>(); - let mut cursor = events.get_cursor(); - assert_eq!( - cursor.read(events).count(), - 0, - "no event when clamped at floor" - ); - } - - #[test] - fn pressing_o_toggles_settings_screen_flag() { - let mut app = headless_app(); - assert!( - !app.world().resource::().0, - "screen is closed initially" - ); - - press(&mut app, KeyCode::KeyO); - app.update(); - assert!( - app.world().resource::().0, - "O opens settings" - ); - - press(&mut app, KeyCode::KeyO); - app.update(); - assert!( - !app.world().resource::().0, - "second O closes settings" - ); - } - - // cycle_unlocked pure-function tests - #[test] - fn cycle_unlocked_wraps_at_end() { - // [0, 1, 2] → cycling from 2 wraps to 0 - assert_eq!(cycle_unlocked(&[0, 1, 2], 2), 0); - } - - #[test] - fn cycle_unlocked_advances_normally() { - assert_eq!(cycle_unlocked(&[0, 1, 2], 0), 1); - assert_eq!(cycle_unlocked(&[0, 1, 2], 1), 2); - } - - #[test] - fn cycle_unlocked_single_element_stays() { - // Only one unlockable — cycling always returns it. - assert_eq!(cycle_unlocked(&[0], 0), 0); - } - - #[test] - fn cycle_unlocked_current_not_in_list_falls_back_to_second() { - // current=5 is not in [0,1,2]; falls back to pos=0, so next = unlocked[1] = 1 - assert_eq!(cycle_unlocked(&[0, 1, 2], 5), 1); - } - - #[test] - fn cycle_unlocked_empty_returns_zero() { - assert_eq!(cycle_unlocked(&[], 0), 0); - } - - #[test] - fn scroll_is_noop_when_settings_panel_closed() { - use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; - let mut app = headless_app(); - // Panel starts closed (SettingsScreen(false)); spawn a scrollable entity. - let entity = app - .world_mut() - .spawn((SettingsPanelScrollable, ScrollPosition::default())) - .id(); - // Send a downward scroll event while the panel is closed. - app.world_mut().write_message(MouseWheel { - unit: MouseScrollUnit::Line, - x: 0.0, - y: -3.0, - window: Entity::PLACEHOLDER, - }); - app.update(); - // ScrollPosition must remain at 0.0 — panel was closed. - let offset = app - .world() - .entity(entity) - .get::() - .unwrap() - .0 - .y; - assert_eq!(offset, 0.0, "scroll must not move when panel is closed"); - } - - #[test] - fn scroll_moves_offset_when_panel_open() { - use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; - let mut app = headless_app(); - // Open the panel. - app.world_mut().resource_mut::().0 = true; - // Spawn a scrollable entity with an existing offset so we can distinguish clamping. - let entity = app - .world_mut() - .spawn(( - SettingsPanelScrollable, - ScrollPosition(Vec2::new(0.0, 100.0)), - )) - .id(); - // Scroll down by 2 lines (50 px/line → +100 px added to offset_y). - app.world_mut().write_message(MouseWheel { - unit: MouseScrollUnit::Line, - x: 0.0, - y: -2.0, - window: Entity::PLACEHOLDER, - }); - app.update(); - let offset = app - .world() - .entity(entity) - .get::() - .unwrap() - .0 - .y; - assert!( - (offset - 200.0).abs() < 1e-3, - "scrolling down should increase offset_y; got {offset}" - ); - } - - // ----------------------------------------------------------------------- - // Phase 3 — keyboard focus ring, Settings buttons + FocusRow - // ----------------------------------------------------------------------- - - /// Headless app that runs the *real* (UI-enabled) `SettingsPlugin` - /// alongside `UiModalPlugin` and `UiFocusPlugin`, so the spawn / - /// auto-tag systems fire end-to-end without writing to disk. - fn headless_app_with_focus() -> App { - use crate::ui_focus::UiFocusPlugin; - use crate::ui_modal::UiModalPlugin; - - let mut app = App::new(); - app.add_plugins(MinimalPlugins) - .add_plugins(UiModalPlugin) - .add_plugins(UiFocusPlugin) - .add_plugins(SettingsPlugin { - // No persistence — keep the test isolated. - storage_path: None, - ui_enabled: true, - }); - app.init_resource::>(); - app.update(); - app - } - - #[test] - fn settings_buttons_get_focusable_marker() { - let mut app = headless_app_with_focus(); - - // Open the panel. - app.world_mut().resource_mut::().0 = true; - app.update(); - // Two more ticks: the first runs `sync_settings_panel_visibility` - // and queues the spawn commands; the second flushes them and - // runs `attach_focusable_to_settings_buttons`. - app.update(); - app.update(); - - // Every bespoke `SettingsButton` (not `Done`, which is also a - // `ModalButton`) must carry a `Focusable`. - let untagged: Vec<&SettingsButton> = app - .world_mut() - .query_filtered::<&SettingsButton, (With