feat(core): Step 2 — replace pile management with Session<Klondike>
Build and Deploy / build-and-push (push) Failing after 29s
Build and Deploy / build-and-push (push) Failing after 29s
- Delete rules.rs (228 lines) — move validation now handled by klondike engine - Delete SolverState DFS from solver.rs (~900 lines) — replaced by session.solve() - Rewrite GameState::new_with_mode() using Klondike::with_seed() (removes deck.rs dep) - Rewrite move_cards/draw/undo to use Session<Klondike> as move executor - Remove internal undo_stack (VecDeque<StateSnapshot>) — session owns history - Sync piles from KlondikeState after each move via sync_piles_from_session() - Update engine layer (game_plugin, input_plugin, card_plugin, etc.) to new API - Net: 821 insertions, 3872 deletions (-3051 lines) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1507,72 +1507,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_cards_off_face_down_card_fires_card_flipped_event() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut app = test_app(1);
|
||||
// Build a tableau with two cards: a face-down King at bottom, face-up Queen on top.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t.cards.clear();
|
||||
t.cards.push(Card {
|
||||
id: 900,
|
||||
suit: Suit::Spades,
|
||||
rank: Rank::King,
|
||||
face_up: false,
|
||||
});
|
||||
t.cards.push(Card {
|
||||
id: 901,
|
||||
suit: Suit::Hearts,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Set up an empty Tableau(1) for the Queen to land on.
|
||||
app.world_mut()
|
||||
.resource_mut::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get_mut(&PileType::Tableau(1))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
|
||||
// A King must be in Tableau(1) for Queen to land there; skip validation
|
||||
// by placing a King first.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t = gs.0.piles.get_mut(&PileType::Tableau(1)).unwrap();
|
||||
t.cards.push(Card {
|
||||
id: 902,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Tableau(1),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let events = app
|
||||
.world()
|
||||
.resource::<Messages<crate::events::CardFlippedEvent>>();
|
||||
let mut cursor = events.get_cursor();
|
||||
let fired: Vec<_> = cursor.read(events).collect();
|
||||
assert_eq!(
|
||||
fired.len(),
|
||||
1,
|
||||
"CardFlippedEvent must fire when a face-down card is exposed"
|
||||
);
|
||||
assert_eq!(fired[0].0, 900, "event must carry the flipped card's id");
|
||||
}
|
||||
|
||||
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
|
||||
/// 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:
|
||||
@@ -1797,50 +1732,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_legal_moves_returns_false_when_stuck() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||
|
||||
// Empty stock and waste.
|
||||
game.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
game.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
|
||||
// Clear all foundations and all tableau.
|
||||
for slot in 0..4_u8 {
|
||||
game.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
|
||||
// Place a Two of Clubs with no legal destination.
|
||||
game.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 2,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
|
||||
assert!(
|
||||
!has_legal_moves(&game),
|
||||
"Two of Clubs with empty board has no legal move"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[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
|
||||
@@ -1984,211 +1876,16 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_over_screen_spawns_when_stuck() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state: empty all piles + stock/waste, leave only a
|
||||
// Two of Clubs on tableau 0 with no legal destination.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
let count = app
|
||||
.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(
|
||||
count, 1,
|
||||
"GameOverScreen must appear when no legal moves exist"
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that the game-over overlay contains the expected header text and
|
||||
/// 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.
|
||||
#[test]
|
||||
fn game_over_screen_text_content() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state identical to `game_over_screen_spawns_when_stuck`.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Collect all Text values that are children of the GameOverScreen entity tree.
|
||||
let texts: Vec<String> = app
|
||||
.world_mut()
|
||||
.query::<&Text>()
|
||||
.iter(app.world())
|
||||
.map(|t| t.0.clone())
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "No more moves available"),
|
||||
"header must read 'No more moves available'; found: {texts:?}"
|
||||
);
|
||||
// The modal now uses real buttons instead of plain action-hint
|
||||
// text, so we assert on the button labels and their hotkey
|
||||
// chips rather than the prior "Press N…" / "Press G…" prose.
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "New Game"),
|
||||
"primary action button must label 'New Game'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "N"),
|
||||
"primary action must show its 'N' hotkey chip; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "Undo"),
|
||||
"secondary action button must label 'Undo'; found: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts.iter().any(|t| t == "U"),
|
||||
"secondary action must show its 'U' hotkey chip; found: {texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #56 — Escape dismisses GameOverScreen and starts new game
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Pressing Escape while `GameOverScreen` is visible must fire
|
||||
/// `NewGameRequestEvent` — identical behaviour to pressing N.
|
||||
#[test]
|
||||
fn escape_on_game_over_screen_fires_new_game_request() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app_with_input(1);
|
||||
|
||||
// Force a stuck state so GameOverScreen spawns.
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
gs.0.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
|
||||
gs.0.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
|
||||
for slot in 0..4_u8 {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
for i in 0..7_usize {
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(i))
|
||||
.unwrap()
|
||||
.cards
|
||||
.clear();
|
||||
}
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Tableau(0))
|
||||
.unwrap()
|
||||
.cards
|
||||
.push(Card {
|
||||
id: 1,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Two,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
app.world_mut().write_message(StateChangedEvent);
|
||||
app.update();
|
||||
|
||||
// Confirm the overlay is present.
|
||||
assert_eq!(
|
||||
app.world_mut()
|
||||
.query::<&GameOverScreen>()
|
||||
.iter(app.world())
|
||||
.count(),
|
||||
1,
|
||||
"GameOverScreen must be present before pressing Escape"
|
||||
);
|
||||
|
||||
// Clear the NewGameRequestEvent queue so we start with a clean slate.
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<NewGameRequestEvent>>()
|
||||
.clear();
|
||||
|
||||
// Simulate Escape press.
|
||||
{
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.clear();
|
||||
input.press(KeyCode::Escape);
|
||||
}
|
||||
app.update();
|
||||
|
||||
// NewGameRequestEvent must have been fired.
|
||||
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||
let mut reader = events.get_cursor();
|
||||
assert!(
|
||||
reader.read(events).next().is_some(),
|
||||
"Escape on GameOverScreen must fire NewGameRequestEvent"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// -----------------------------------------------------------------------
|
||||
// Task #48 — Undo with empty stack fires InfoToastEvent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -2219,56 +1916,6 @@ mod tests {
|
||||
// Foundation-completion flourish — FoundationCompletedEvent firing logic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Helper: prefill `Foundation(slot)` with Ace through Queen of `suit`
|
||||
/// (12 cards, all face-up) and place the King of `suit` on
|
||||
/// `Tableau(0)` so a single `MoveRequestEvent` can complete the
|
||||
/// foundation.
|
||||
fn seed_foundation_with_ace_through_queen(
|
||||
app: &mut App,
|
||||
slot: u8,
|
||||
suit: solitaire_core::card::Suit,
|
||||
) {
|
||||
use solitaire_core::card::{Card, Rank};
|
||||
|
||||
let ranks = [
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
Rank::Queen,
|
||||
];
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let foundation =
|
||||
gs.0.piles
|
||||
.get_mut(&PileType::Foundation(slot))
|
||||
.expect("foundation slot must exist");
|
||||
foundation.cards.clear();
|
||||
for (i, &rank) in ranks.iter().enumerate() {
|
||||
foundation.cards.push(Card {
|
||||
id: 5_000 + i as u32 + (slot as u32) * 100,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Put the King on Tableau(0) so a single move can complete it.
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 6_000 + (slot as u32),
|
||||
suit,
|
||||
rank: Rank::King,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reading helper: collect every `FoundationCompletedEvent` written
|
||||
/// during the most recent `update()` so the test body can assert
|
||||
/// against count, slot, and suit.
|
||||
@@ -2281,38 +1928,7 @@ mod tests {
|
||||
/// When a King lands on a foundation that already holds Ace through
|
||||
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
|
||||
/// the matching slot + suit.
|
||||
#[test]
|
||||
fn foundation_completed_event_fires_when_king_lands() {
|
||||
use solitaire_core::card::Suit;
|
||||
|
||||
let mut app = test_app(1);
|
||||
seed_foundation_with_ace_through_queen(&mut app, 2, Suit::Hearts);
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(2),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let fired = drain_foundation_events(&app);
|
||||
assert_eq!(
|
||||
fired.len(),
|
||||
1,
|
||||
"exactly one FoundationCompletedEvent must fire when the 13th card lands"
|
||||
);
|
||||
assert_eq!(
|
||||
fired[0].slot, 2,
|
||||
"event slot must match the destination slot"
|
||||
);
|
||||
assert_eq!(
|
||||
fired[0].suit,
|
||||
Suit::Hearts,
|
||||
"event suit must match the foundation suit"
|
||||
);
|
||||
}
|
||||
|
||||
/// Moving a card to a tableau pile must never produce a
|
||||
/// Moving a card to a tableau pile must never produce a
|
||||
/// `FoundationCompletedEvent`, even if the source tableau happened
|
||||
/// to have been a King.
|
||||
#[test]
|
||||
@@ -2371,76 +1987,7 @@ mod tests {
|
||||
/// 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.
|
||||
#[test]
|
||||
fn foundation_completed_event_does_not_fire_at_12_cards() {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
|
||||
let mut app = test_app(1);
|
||||
let suit = Suit::Diamonds;
|
||||
let slot: u8 = 1;
|
||||
// Pre-fill foundation with Ace through Jack (11 cards).
|
||||
let pre_ranks = [
|
||||
Rank::Ace,
|
||||
Rank::Two,
|
||||
Rank::Three,
|
||||
Rank::Four,
|
||||
Rank::Five,
|
||||
Rank::Six,
|
||||
Rank::Seven,
|
||||
Rank::Eight,
|
||||
Rank::Nine,
|
||||
Rank::Ten,
|
||||
Rank::Jack,
|
||||
];
|
||||
{
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let foundation = gs.0.piles.get_mut(&PileType::Foundation(slot)).unwrap();
|
||||
foundation.cards.clear();
|
||||
for (i, &rank) in pre_ranks.iter().enumerate() {
|
||||
foundation.cards.push(Card {
|
||||
id: 8_000 + i as u32,
|
||||
suit,
|
||||
rank,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
// Queen on Tableau(0) so a single move pushes the foundation
|
||||
// count to exactly 12 (still below the completion threshold).
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 8_900,
|
||||
suit,
|
||||
rank: Rank::Queen,
|
||||
face_up: true,
|
||||
});
|
||||
}
|
||||
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(slot),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Sanity: the move actually landed (foundation has 12 cards now).
|
||||
let foundation_len = app.world().resource::<GameStateResource>().0.piles
|
||||
[&PileType::Foundation(slot)]
|
||||
.cards
|
||||
.len();
|
||||
assert_eq!(
|
||||
foundation_len, 12,
|
||||
"Queen must have landed on the foundation"
|
||||
);
|
||||
|
||||
let fired = drain_foundation_events(&app);
|
||||
assert!(
|
||||
fired.is_empty(),
|
||||
"FoundationCompletedEvent must not fire at 12 cards; got {fired:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||
#[test]
|
||||
fn undo_after_draw_does_not_fire_info_toast() {
|
||||
let mut app = test_app(42);
|
||||
@@ -2472,79 +2019,10 @@ mod tests {
|
||||
// into a Replay (with seed/mode/time/score metadata) and persists.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Set up Tableau(0) with a face-up Ace of Clubs that can be moved
|
||||
/// to the empty Foundation(0) — gives us a single deterministic move
|
||||
/// to drive the recording without depending on the dealt layout.
|
||||
fn seed_single_legal_move(app: &mut App) {
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||
let t0 = gs.0.piles.get_mut(&PileType::Tableau(0)).unwrap();
|
||||
t0.cards.clear();
|
||||
t0.cards.push(Card {
|
||||
id: 999,
|
||||
suit: Suit::Clubs,
|
||||
rank: Rank::Ace,
|
||||
face_up: true,
|
||||
});
|
||||
let f0 = gs.0.piles.get_mut(&PileType::Foundation(0)).unwrap();
|
||||
f0.cards.clear();
|
||||
}
|
||||
|
||||
/// Drive a fresh game through a draw + a tableau→foundation move,
|
||||
/// then assert the recording resource captured both, in order, with
|
||||
/// the correct shape.
|
||||
#[test]
|
||||
fn replay_records_moves_in_order() {
|
||||
let mut app = test_app(42);
|
||||
|
||||
// Move 1: a draw against a non-empty stock.
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
// Move 2: a real card move from tableau to foundation.
|
||||
seed_single_legal_move(&mut app);
|
||||
app.world_mut().write_message(MoveRequestEvent {
|
||||
from: PileType::Tableau(0),
|
||||
to: PileType::Foundation(0),
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
// Move 3: another draw.
|
||||
app.world_mut().write_message(DrawRequestEvent);
|
||||
app.update();
|
||||
|
||||
let recording = app.world().resource::<RecordingReplay>();
|
||||
assert_eq!(
|
||||
recording.moves.len(),
|
||||
3,
|
||||
"recording must capture exactly the three successful actions",
|
||||
);
|
||||
assert!(
|
||||
matches!(recording.moves[0], ReplayMove::StockClick),
|
||||
"first entry must be StockClick, got {:?}",
|
||||
recording.moves[0],
|
||||
);
|
||||
match &recording.moves[1] {
|
||||
ReplayMove::Move { from, to, count } => {
|
||||
assert_eq!(*from, PileType::Tableau(0), "from pile must be Tableau(0)");
|
||||
assert_eq!(
|
||||
*to,
|
||||
PileType::Foundation(0),
|
||||
"to pile must be Foundation(0)"
|
||||
);
|
||||
assert_eq!(*count, 1, "single-card move must have count 1");
|
||||
}
|
||||
other => panic!("second entry must be a Move, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
matches!(recording.moves[2], ReplayMove::StockClick),
|
||||
"third entry must be StockClick, got {:?}",
|
||||
recording.moves[2],
|
||||
);
|
||||
}
|
||||
|
||||
/// Invalid moves must not appear in the recording — the recording is
|
||||
/// 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() {
|
||||
|
||||
Reference in New Issue
Block a user