Files
functional-programming-snake/fsharp/tests/M10Replay.fs
T
Lucas WintherandClaude Fable 5.2 edd57f0148 feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:05:29 +01:00

181 lines
8.4 KiB
FSharp

module Snake.Tests.M10Replay
open System.Text.Json
open Expecto
open Snake
open Snake.Model
open Snake.View
open Snake.IO
open Snake.Stream
open Snake.Replay
open Snake.Tests.Fixtures
open Snake.Tests.M09Helpers
// Viewers in the fixtures use the keys index, last and playing.
let private viewer (v: JsonElement) : Viewer =
{ Index = num (prop "index" v); Last = num (prop "last" v); Playing = bool (prop "playing" v) }
let private viewerAction (v: JsonElement) : ViewerAction =
match str (prop "tag" v) with
| "Next" -> ViewerAction.Next
| "Prev" -> ViewerAction.Prev
| "First" -> ViewerAction.First
| "Last" -> ViewerAction.Last
| "TogglePlay" -> ViewerAction.TogglePlay
| "Beat" -> ViewerAction.Beat
| other -> failwith $"unknown viewer action tag: {other}"
// The sample recording, as the page loads it. Not a fixture of cases: a recording {seed, actions}.
let private sample: Recording =
let v = load "replay-sample"
{ Seed = u32 (prop "seed" v); Actions = list action (prop "actions" v) }
[<Tests>]
let tests =
testList "M10 Replay" [
yield! fixtureTests "m10-viewer-update" "viewerUpdate" (fun c ->
Expect.equal (viewerUpdate (viewer (arg 0 c)) (viewerAction (arg 1 c))) (viewer (expected c)) "viewerUpdate")
yield! fixtureTests "m10-viewer-key" "viewerKey" (fun c ->
Expect.equal (viewerKey (str (arg 0 c))) (opt viewerAction (expected c)) "viewerKey")
yield! fixtureTests "m10-viewer-view" "viewerView" (fun c ->
Expect.equal (viewerView (list gameState (arg 0 c)) (viewer (arg 1 c))) (list shape (expected c)) "viewerView")
yield!
[ testCase
"the sample recording is worth watching: Tick and Turn only, under 400 actions, at least five apples, then game over"
(fun () ->
Expect.all
sample.Actions
(fun a ->
match a with
| Tick
| Turn _ -> true
| _ -> false)
"Tick and Turn only"
let n = List.length sample.Actions
Expect.isTrue (n >= 150 && n <= 400) $"{n} actions"
let fs = Game.history (Spawn.newGame sample.Seed) sample.Actions
let last = List.last fs
Expect.isGreaterThanOrEqual last.Score 5 "score"
Expect.equal last.Status GameOver "last frame"
Expect.equal (List.item (List.length fs - 2) fs).Status Playing "frame before the last")
testCase "frames replays the recording with history, from the new game of its seed" (fun () ->
let recording = { Seed = 42u; Actions = [ Tick; Turn Up; Tick ] }
Expect.equal (frames recording) (Game.history (Spawn.newGame 42u) recording.Actions) "frames"
Expect.equal (List.length (frames sample)) (List.length sample.Actions + 1) "length")
testCase "viewerStart is the first frame, not playing" (fun () ->
Expect.equal (viewerStart 12) { Index = 0; Last = 12; Playing = false } "viewerStart")
testCase "viewerUpdate keeps the index in [0, last] whatever the actions" (fun () ->
let actions =
[ ViewerAction.Prev
ViewerAction.Next
ViewerAction.Last
ViewerAction.Next
ViewerAction.TogglePlay
ViewerAction.Beat
ViewerAction.Beat
ViewerAction.First
ViewerAction.Prev
ViewerAction.Beat ]
let viewers = List.scan viewerUpdate (viewerStart 3) actions
Expect.all
viewers
(fun v -> v.Index >= 0 && v.Index <= 3 && not (v.Playing && v.Index = 3))
"in range, never playing at the end")
testCase "the autoplay clock beats every 100 ms" (fun () -> Expect.equal REPLAY_MS 100L "REPLAY_MS")
testCase "replayActions turns the clock into Beats and keys into viewer actions" (fun () ->
Expect.equal
(run [ Time 100L; KeyDown "ArrowRight"; KeyDown "x"; KeyDown " "; Time 300L ] replayActions)
[ ViewerAction.Beat
ViewerAction.Next
ViewerAction.TogglePlay
ViewerAction.Beat
ViewerAction.Beat ]
"actions")
testCase "autoplay: Space, then the clock steps one frame per 100 ms and stops at the end" (fun () ->
let out =
viewerStates 3 replayActions
|> run [ Time 0L; KeyDown " "; Time 100L; Time 200L; Time 300L; Time 400L; Time 500L ]
Expect.equal (out |> List.map (fun v -> v.Index)) [ 0; 0; 1; 2; 3; 3; 3 ] "indices"
Expect.equal
(out |> List.map (fun v -> v.Playing))
[ false; true; true; true; false; false; false ]
"playing")
testCase "without Space nothing plays: the clock alone never moves the viewer" (fun () ->
Expect.all
(viewerStates 3 replayActions |> run [ Time 0L; Time 1000L; Time 2000L ])
(fun v -> v.Index = 0)
"index 0")
testCase "replayMain asks for nothing and draws the first frame of the recording first" (fun () ->
let traced = IO.trace 0u (replayMain sample)
Expect.equal traced.Ops [] "ops"
let drawings, _ = Stream.step (Time 0L) traced.Result
Expect.equal (List.length drawings) 1 "one drawing"
Expect.equal
(drawn (List.head drawings))
(viewerView (frames sample) (viewerStart (List.length sample.Actions)))
"first scene"
let start = View.view (Spawn.newGame sample.Seed)
Expect.equal
(drawn (List.head drawings) |> List.truncate (List.length start))
start
"the game's own view first")
testCase "replayMain: End shows the last frame of the game, the game over" (fun () ->
let out = (IO.trace 0u (replayMain sample)).Result |> run [ Time 0L; KeyDown "End" ]
let fs = frames sample
let last = List.length fs - 1
let scene = drawn (List.last out)
Expect.equal scene (viewerView fs { Index = last; Last = last; Playing = false }) "last scene"
Expect.exists
scene
(fun s ->
match s with
| Text(_, _, "Game Over", _, _, _) -> true
| _ -> false)
"Game Over")
testCase "stepping back shows exactly the earlier scene" (fun () ->
let out =
(IO.trace 0u (replayMain sample)).Result
|> run
[ Time 0L
KeyDown "ArrowRight"
KeyDown "ArrowRight"
KeyDown "ArrowRight"
KeyDown "ArrowLeft"
KeyDown "d"
KeyDown "a"
KeyDown "a" ]
let scenes = List.map drawn out
Expect.equal scenes[4] scenes[2] "back one"
Expect.equal scenes[6] scenes[4] "forward and back"
Expect.equal scenes[7] scenes[1] "back to frame 1"
Expect.notEqual scenes[3] scenes[2] "frames differ")
testCase "replayMain: after ten seconds in a background tab autoplay moves 2 frames (250 ms), not 100" (fun () ->
let _, afterPlay = Stream.step (KeyDown " ") (IO.trace 0u (replayMain sample)).Result
let _, afterFirst = Stream.step (Time 0L) afterPlay
Expect.equal (List.length (fst (Stream.step (Time 10000L) afterFirst))) 2 "two drawings") ]
]