test: expand WASM unit tests and add web behavior e2e specs

solitaire_wasm/src/lib.rs — 5 new unit tests (9 total, was 4):
- serialize_from_saved_round_trip: board key matches after JSON round-trip
- undo_reverts_to_prior_state: state + history length restored after undo
- draw_one_advances_waste_by_one: DrawOne takes exactly 1 card from stock
- draw_three_advances_waste_by_three: DrawThree takes up to 3 cards
- debug_apply_move_json_stock_click: JSON DebugMove path via native method

solitaire_server/e2e/tests/game_behaviors.spec.js — 5 new Playwright tests:
- resume overlay shows when localStorage save exists; seed() returns null
  until user interacts (before bootstrap completes a game)
- clicking New Game on overlay clears history and starts fresh (0 moves)
- clicking Resume restores saved move history length exactly
- HUD new-game button resets history to 0 and score to 0
- tab-visibility timer: timer freezes during hidden, resumes when visible
  (tests the visibilitychange fix from the 500-game UX audit); uses
  page.clock.install() to control setInterval without real-time delay

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-02 14:12:42 -07:00
parent 2b1ad2161a
commit 8bd2fb89eb
2 changed files with 350 additions and 0 deletions
+156
View File
@@ -1114,4 +1114,160 @@ mod tests {
assert_invariants(&snapshot, seed);
}
}
#[test]
fn serialize_from_saved_round_trip() {
let seed = 55_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
};
// Advance a few moves so there is non-trivial state to round-trip.
for _ in 0..20 {
let moves = game.legal_moves_native();
if moves.is_empty() {
break;
}
let idx = pick_move_index(&moves).unwrap_or_default();
let _ = game.apply_legal_move_native(idx);
}
let json = game
.serialize()
.expect("serialize must succeed for a valid game");
assert!(!json.is_empty(), "serialized JSON must be non-empty");
let restored =
SolitaireGame::from_saved(&json).expect("from_saved must accept its own output");
assert_eq!(
board_key(&game.debug_snapshot_native().state),
board_key(&restored.debug_snapshot_native().state),
"restored game board must match original after round-trip"
);
assert_eq!(
game.game.seed, restored.game.seed,
"seed must survive serialize/from_saved"
);
}
#[test]
fn undo_reverts_to_prior_state() {
let seed = 99_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
};
let before_key = board_key(&game.debug_snapshot_native().state);
let before_history_len = game.game.instruction_history().len();
let moves = game.legal_moves_native();
assert!(!moves.is_empty(), "seed {seed}: no legal moves at start");
let idx = pick_move_index(&moves).unwrap_or_default();
game.apply_legal_move_native(idx)
.unwrap_or_else(|e| panic!("apply_legal_move failed: {e}"));
// State should have changed.
assert_ne!(
board_key(&game.debug_snapshot_native().state),
before_key,
"board state must change after applying a legal move"
);
// Undo must restore the prior state.
game.game.undo().expect("undo must succeed after one move");
assert_eq!(
board_key(&game.debug_snapshot_native().state),
before_key,
"board state must match pre-move state after undo"
);
assert_eq!(
game.game.instruction_history().len(),
before_history_len,
"history length must return to pre-move value after undo"
);
}
#[test]
fn draw_one_advances_waste_by_one() {
let seed = 1_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
};
let stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_cards().len();
assert!(stock_before > 0, "seed {seed}: stock must be non-empty at start");
game.game.draw().expect("draw must succeed when stock is non-empty");
assert_eq!(
game.game.stock_cards().len(),
stock_before - 1,
"DrawOne: stock must decrease by 1"
);
assert_eq!(
game.game.waste_cards().len(),
waste_before + 1,
"DrawOne: waste must increase by 1"
);
}
#[test]
fn draw_three_advances_waste_by_three() {
let seed = 1_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawThree, GameMode::Classic),
};
let stock_before = game.game.stock_cards().len();
let waste_before = game.game.waste_cards().len();
assert!(
stock_before >= 3,
"seed {seed}: stock must have at least 3 cards for this test"
);
game.game.draw().expect("draw must succeed when stock has cards");
let expected_drawn = stock_before.min(3);
assert_eq!(
game.game.stock_cards().len(),
stock_before - expected_drawn,
"DrawThree: stock must decrease by {expected_drawn}"
);
assert_eq!(
game.game.waste_cards().len(),
waste_before + expected_drawn,
"DrawThree: waste must increase by {expected_drawn}"
);
}
#[test]
fn debug_apply_move_json_stock_click_advances_waste() {
let seed = 3_u64;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, DrawMode::DrawOne, GameMode::Classic),
};
let waste_before = game.game.waste_cards().len();
assert!(
!game.game.stock_cards().is_empty(),
"seed {seed}: stock must be non-empty at start"
);
// Use the native path: parse the JSON ourselves and apply via the
// native method (debug_apply_move_json wraps this but touches js-sys
// on non-wasm targets).
let mv: DebugMove = serde_json::from_str(r#"{"kind":"stock_click"}"#)
.expect("stock_click JSON must parse to DebugMove");
game.apply_debug_move_native(&mv)
.unwrap_or_else(|e| panic!("apply_debug_move_native failed: {e}"));
assert!(
game.game.waste_cards().len() > waste_before,
"after stock_click move waste must have grown"
);
}
}