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>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+268
View File
@@ -0,0 +1,268 @@
module Snake.Tests.M13Life
open System.Diagnostics
open System.Text.Json
open Expecto
open Snake
open Snake.Model
open Snake.Stream
open Snake.Life.Model
open Snake.Life.Rules
open Snake.Life.View
open Snake.Life.App
open Snake.Tests.Fixtures
open Snake.Tests.M09Helpers
open Snake.Tests.M09Runtime
let private lifeState (v: JsonElement) : LifeState =
{ Cells = list point (prop "cells" v)
Running = bool (prop "running" v)
Seed = u32 (prop "seed" v)
Generation = num (prop "generation" v) }
let private lifeAction (v: JsonElement) : LifeAction =
match str (prop "tag" v) with
| "Frame" -> LifeAction.Frame
| "Toggle" -> LifeAction.Toggle(point (prop "cell" v))
| "TogglePause" -> LifeAction.TogglePause
| "Step" -> LifeAction.Step
| "Clear" -> LifeAction.Clear
| "Randomize" -> LifeAction.Randomize
| other -> failwith $"unknown life action tag: {other}"
let private cell (x: int) (y: int) : Point = { X = x; Y = y }
let private rowMajor (cells: Point list) : bool =
cells |> List.pairwise |> List.forall (fun (a, b) -> a.Y < b.Y || (a.Y = b.Y && a.X < b.X))
// n generations after `cells`, the start included: n + 1 boards.
let private generations (n: int) (cells: Point list) : Point list list =
List.scan (fun cs _ -> lifeStep cs) cells [ 1..n ]
[<Tests>]
let tests =
testList "M13 Life" [
yield! fixtureTests "m13-life-step" "lifeStep" (fun c ->
Expect.equal (lifeStep (list point (arg 0 c))) (list point (expected c)) "lifeStep")
yield! fixtureTests "m13-life-update" "lifeUpdate" (fun c ->
Expect.equal (lifeUpdate (lifeState (arg 0 c)) (lifeAction (arg 1 c))) (lifeState (expected c)) "lifeUpdate")
yield! fixtureTests "m13-life-view" "lifeView" (fun c ->
Expect.equal (lifeView (lifeState (arg 0 c))) (list shape (expected c)) "lifeView")
yield! fixtureTests "m13-life-key" "lifeKey" (fun c ->
Expect.equal (lifeKey (str (arg 0 c))) (opt lifeAction (expected c)) "lifeKey")
yield! fixtureTests "m13-life-click" "lifeClick" (fun c ->
Expect.equal (lifeClick (num (arg 0 c)) (num (arg 1 c))) (opt lifeAction (expected c)) "lifeClick")
yield!
[ // ---- the model and the rules
testCase "the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below" (fun () ->
Expect.equal [ LW; LH; LCELL; STRIP ] [ 40; 30; 16; 24 ] "constants")
testCase "initialLife is an empty, paused world at generation 0 with the given seed" (fun () ->
Expect.equal
(initialLife 9u)
{ Cells = []
Running = false
Seed = 9u
Generation = 0 }
"initialLife")
testCase "lifeUpdate does not modify the state it is given" (fun () ->
let make () =
{ Cells = [ cell 4 5; cell 5 5; cell 6 5 ]
Running = true
Seed = 3u
Generation = 0 }
let s = make ()
[ LifeAction.Frame
LifeAction.Toggle(cell 0 0)
LifeAction.Toggle(cell 5 5)
LifeAction.Randomize
LifeAction.Clear ]
|> List.iter (fun a -> lifeUpdate s a |> ignore)
Expect.equal s (make ()) "unchanged")
testCase "a glider moves one cell diagonally every four generations" (fun () ->
let glider = [ cell 1 0; cell 2 1; cell 0 2; cell 1 2; cell 2 2 ]
Expect.equal
(List.last (generations 4 glider))
[ cell 2 1; cell 3 2; cell 1 3; cell 2 3; cell 3 3 ]
"after four generations")
testCase "the next generation is computed from the whole old board: a blinker blinks forever" (fun () ->
let horizontal = [ cell 9 10; cell 10 10; cell 11 10 ]
let gens = generations 6 horizontal
Expect.equal gens[2] horizontal "two generations"
Expect.equal gens[6] horizontal "six generations"
Expect.equal gens[1] [ cell 10 9; cell 10 10; cell 10 11 ] "vertical in between")
testCase "Randomize draws one intBelow 4 per cell in row-major order, alive when the draw is 0" (fun () ->
let seed = 2024u
// The same draws, written the long way round with the Module 4 generator.
let draws, next =
List.fold
(fun (ds, s) _ ->
let d, s2 = Random.run s (Random.intBelow 4)
ds @ [ d ], s2)
([], seed)
[ 1 .. LW * LH ]
let expectedCells =
draws
|> List.indexed
|> List.choose (fun (i, d) -> if d = 0 then Some(cell (i % LW) (i / LW)) else None)
let result = lifeUpdate (initialLife seed) LifeAction.Randomize
Expect.equal result.Cells expectedCells "cells"
Expect.equal result.Seed next "seed"
let n = List.length result.Cells
Expect.isTrue (n > 200 && n < 400) $"{n} live cells")
testCase "every generation of a random board stays sorted row-major, on the board and without duplicates" (fun () ->
let start = (lifeUpdate (initialLife 5u) LifeAction.Randomize).Cells
generations 20 start
|> List.iter (fun cells ->
Expect.isTrue (rowMajor cells) "sorted row-major, no duplicates"
Expect.all cells (fun c -> c.X >= 0 && c.X < LW && c.Y >= 0 && c.Y < LH) "on the board"))
// Generous on purpose (and more so than in JS, for an unoptimised Debug build): a straightforward
// lifeStep takes a few milliseconds per generation. This only catches a generation that is far slower
// than the 150 ms between two of them.
testCase "lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms" (fun () ->
let start = (lifeUpdate (initialLife 11u) LifeAction.Randomize).Cells
let watch = Stopwatch.StartNew()
generations 50 start |> ignore
let ms = watch.ElapsedMilliseconds
Expect.isLessThan ms 5000L $"{ms} ms")
// ---- the fourth kind of event
testCase "clicks emits the position of every Click event and ignores everything else" (fun () ->
Expect.equal
(Stream.clicks |> Stream.run [ Time 10L; Click(3, 4); KeyDown "a"; Click(0, 479); KeyUp "a" ])
[ (3, 4); (0, 479) ]
"positions")
testCase "the other sources ignore clicks" (fun () ->
let events = [ Click(5, 5); Time 100L; Click(6, 6) ]
Expect.equal (Stream.run events Stream.keyDowns) [] "keyDowns"
Expect.equal (Stream.run events Stream.keyUps) [] "keyUps"
Expect.equal (Stream.run events (Stream.interval 50L)) [ 50L; 100L ] "interval"
Expect.equal
(Stream.run events (Stream.clampTime 250L Stream.clicks))
[ (5, 5); (6, 6) ]
"clampTime passes clicks through")
testCase "a generation every 150 ms" (fun () -> Expect.equal GEN_MS 150L "GEN_MS")
testCase "lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles" (fun () ->
Expect.equal
(lifeActions
|> Stream.run
[ Time 150L; KeyDown " "; Click(20, 40); KeyDown "x"; Click(700, 10); KeyDown "n"; Time 450L ])
[ LifeAction.Frame
LifeAction.TogglePause
LifeAction.Toggle(cell 1 2)
LifeAction.Step
LifeAction.Frame
LifeAction.Frame ]
"actions")
testCase "lifeStates starts with the new world, then yields one state per action" (fun () ->
let out =
lifeStates 7u lifeActions
|> Stream.run [ Time 0L; Click(16, 16); Click(32, 16); Click(48, 16); KeyDown " "; Time 150L ]
Expect.equal (List.head out) (initialLife 7u) "first state"
Expect.equal (out |> List.map (fun s -> List.length s.Cells)) [ 0; 1; 2; 3; 3; 3 ] "live cells"
Expect.equal
(List.last out)
{ Cells = [ cell 2 0; cell 2 1; cell 2 2 ]
Running = true
Seed = 7u
Generation = 1 }
"last state")
testCase "lifeMain asks for a seed, draws the empty world first, and then a click as a live cell" (fun () ->
let traced = IO.trace 42u (lifeMain ())
Expect.equal traced.Ops [ IO.RandomSeed ] "ops"
let first, next = Stream.step (Time 0L) traced.Result
let second, _ = Stream.step (Click(17, 33)) next
Expect.equal (drawn (List.head first)) (lifeView (initialLife 42u)) "first drawing"
Expect.equal
(drawn (List.head second))
(lifeView { initialLife 42u with Cells = [ cell 1 2 ] })
"second drawing")
testCase "lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds" (fun () ->
let _, afterFirst = Stream.step (Time 0L) (IO.trace 42u (lifeMain ())).Result
Expect.equal (List.length (fst (Stream.step (Time 10000L) afterFirst))) 1 "one drawing")
// ---- the runtime (step 7 of the module); the browser glue in web/ is given
testCase "runApp registers a click handler with the platform" (fun () ->
let p = FakePlatform 42u
Runtime.runApp p.Platform (IO.pure (Stream.clicks |> Stream.map (fun _ -> IO.pure ())))
Expect.equal p.Registered [ "click"; "down"; "time"; "up" ] "registered")
testCase "runApp feeds clicks to the stream" (fun () ->
let p = FakePlatform 42u
let program =
IO.pure (Stream.clicks |> Stream.map (fun (x, y) -> IO.draw (View.Rect(x, y, 1, 1, "#ffffff"))))
Runtime.runApp p.Platform program
p.FireClick(33, 47)
p.FireClick(0, 2)
Expect.equal
p.Calls
[ [ box "fillRect"; box 33; box 47; box 1; box 1; box "#ffffff" ]
[ box "fillRect"; box 0; box 2; box 1; box 1; box "#ffffff" ] ]
"calls")
testCase "runApp runs Life: a click on the canvas draws the world with that cell alive" (fun () ->
let p = FakePlatform 42u
Runtime.runApp p.Platform (lifeMain ())
p.FireTime 0L
p.Clear()
p.FireClick(100, 250)
Expect.contains
p.Calls
[ box "fillRect"; box (6 * 16 + 1); box (15 * 16 + 1); box 14; box 14; box "#7cb342" ]
"the live cell")
testCase "Snake and Pong ignore clicks: their actions and drawings are the same with and without them" (fun () ->
let withClicks = [ Time 0L; Click(5, 5); Time 25L; KeyDown "ArrowUp"; Click(300, 300); Time 200L ]
let without =
withClicks
|> List.filter (fun e ->
match e with
| Click _ -> false
| _ -> true)
Expect.equal (Stream.run withClicks App.speedActions) (Stream.run without App.speedActions) "speedActions"
Expect.equal
(Stream.run withClicks Pong.App.pongActions)
(Stream.run without Pong.App.pongActions)
"pongActions"
Expect.equal (App.play 42u withClicks (App.main ())) (App.play 42u without (App.main ())) "play main") ]
]