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