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:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -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") ]
|
||||
]
|
||||
Reference in new issue
Block a user