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

+222
View File
@@ -0,0 +1,222 @@
package snake
package life
import Fixtures.*
import LifeModel.*
import LifeRules.{lifeStep, lifeUpdate}
import LifeView.lifeView
import LifeApp.*
import M09Helpers.drawn
class M13LifeSuite extends munit.FunSuite:
private def lifeState(v: ujson.Value): LifeState = LifeState(
cells = list(v("cells"))(point),
running = v("running").bool,
seed = v("seed").num.toLong,
generation = v("generation").num.toInt
)
private def lifeAction(v: ujson.Value): LifeAction = v("tag").str match
case "Frame" => LifeAction.Frame
case "Toggle" => LifeAction.Toggle(point(v("cell")))
case "TogglePause" => LifeAction.TogglePause
case "Step" => LifeAction.Step
case "Clear" => LifeAction.Clear
case "Randomize" => LifeAction.Randomize
case other => throw new IllegalArgumentException(s"unknown life action tag: $other")
cases("m13-life-step").foreach { c =>
test(s"lifeStep: ${c("name").str}") {
assertEquals(lifeStep(list(c("args")(0))(point)), list(c("expected"))(point))
}
}
cases("m13-life-update").foreach { c =>
test(s"lifeUpdate: ${c("name").str}") {
val a = c("args")
assertEquals(lifeUpdate(lifeState(a(0)), lifeAction(a(1))), lifeState(c("expected")))
}
}
cases("m13-life-view").foreach { c =>
test(s"lifeView: ${c("name").str}") {
assertEquals(lifeView(lifeState(c("args")(0))), list(c("expected"))(shape))
}
}
cases("m13-life-key").foreach { c =>
test(s"lifeKey: ${c("name").str}") {
assertEquals(lifeKey(c("args")(0).str), opt(c("expected"))(lifeAction))
}
}
cases("m13-life-click").foreach { c =>
test(s"lifeClick: ${c("name").str}") {
val a = c("args")
assertEquals(lifeClick(a(0).num.toInt, a(1).num.toInt), opt(c("expected"))(lifeAction))
}
}
private def time(t: Long): Event = Event.Time(t)
private def down(key: String): Event = Event.KeyDown(key)
private def click(x: Int, y: Int): Event = Event.Click(x, y)
private def cell(x: Int, y: Int): Point = Point(x, y)
private def rowMajor(cells: List[Point]): Boolean =
cells.zip(cells.drop(1)).forall((a, b) => a.y < b.y || (a.y == b.y && a.x < b.x))
// ---- the model and the rules
test("the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below") {
assertEquals(List(LW, LH, LCELL, STRIP), List(40, 30, 16, 24))
}
test("initialLife is an empty, paused world at generation 0 with the given seed") {
assertEquals(initialLife(9), LifeState(Nil, running = false, seed = 9, generation = 0))
}
test("lifeUpdate does not modify the state it is given") {
val s = LifeState(List(cell(4, 5), cell(5, 5), cell(6, 5)), running = true, seed = 3, generation = 0)
val before = s.copy()
List(LifeAction.Frame, LifeAction.Toggle(cell(0, 0)), LifeAction.Toggle(cell(5, 5)), LifeAction.Randomize, LifeAction.Clear)
.foreach(lifeUpdate(s, _))
assertEquals(s, before)
}
test("a glider moves one cell diagonally every four generations") {
val glider = List(cell(1, 0), cell(2, 1), cell(0, 2), cell(1, 2), cell(2, 2))
val after4 = (1 to 4).foldLeft(glider)((cells, _) => lifeStep(cells))
assertEquals(after4, List(cell(2, 1), cell(3, 2), cell(1, 3), cell(2, 3), cell(3, 3)))
}
test("the next generation is computed from the whole old board: a blinker blinks forever") {
val horizontal = List(cell(9, 10), cell(10, 10), cell(11, 10))
val gens = (1 to 6).scanLeft(horizontal)((cells, _) => lifeStep(cells))
assertEquals(gens(2), horizontal)
assertEquals(gens(6), horizontal)
assertEquals(gens(1), List(cell(10, 9), cell(10, 10), cell(10, 11)))
}
test("Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0") {
val seed = 2024L
// The same draws, written the long way round with the Module 4 generator.
val (draws, next) = (0 until LW * LH).foldLeft((Vector.empty[Int], seed)) { case ((ds, s), _) =>
val (d, s2) = Random.intBelow(4).run(s)
(ds :+ d, s2)
}
val expected = draws.zipWithIndex.collect { case (0, i) => cell(i % LW, i / LW) }.toList
val result = lifeUpdate(initialLife(seed), LifeAction.Randomize)
assertEquals(result.cells, expected)
assertEquals(result.seed, next)
assert(result.cells.length > 200 && result.cells.length < 400, s"${result.cells.length} live cells")
}
test("every generation of a random board stays sorted row-major, on the board and without duplicates") {
val start = lifeUpdate(initialLife(5), LifeAction.Randomize).cells
val gens = (1 to 20).scanLeft(start)((cells, _) => lifeStep(cells))
gens.foreach { cells =>
assert(rowMajor(cells))
assert(cells.forall(c => c.x >= 0 && c.x < LW && c.y >= 0 && c.y < LH))
}
}
// Generous on purpose, and more so than in JS because the JVM may still be cold here: 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.
test("lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms") {
val start = lifeUpdate(initialLife(11), LifeAction.Randomize).cells
val t0 = java.lang.System.nanoTime()
(1 to 50).foldLeft(start)((cells, _) => lifeStep(cells))
val ms = (java.lang.System.nanoTime() - t0) / 1000000
assert(ms < 5000, s"$ms ms")
}
// ---- the fourth kind of event
test("clicks emits the position of every Click event and ignores everything else") {
assertEquals(
Stream.clicks.run(List(time(10), click(3, 4), down("a"), click(0, 479), Event.KeyUp("a"))),
List((3, 4), (0, 479))
)
}
test("the other sources ignore clicks") {
val events = List(click(5, 5), time(100), click(6, 6))
assertEquals(Stream.keyDowns.run(events), Nil)
assertEquals(Stream.keyUps.run(events), Nil)
assertEquals(Stream.interval(50).run(events), List(50L, 100L))
assertEquals(Stream.clicks.clampTime(250).run(events), List((5, 5), (6, 6)))
}
test("a generation every 150 ms") { assertEquals(GEN_MS, 150L) }
test("lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles") {
assertEquals(
lifeActions.run(List(time(150), down(" "), click(20, 40), down("x"), click(700, 10), down("n"), time(450))),
List(
LifeAction.Frame,
LifeAction.TogglePause,
LifeAction.Toggle(cell(1, 2)),
LifeAction.Step,
LifeAction.Frame,
LifeAction.Frame
)
)
}
test("lifeStates starts with the new world, then yields one state per action") {
val out = lifeStates(7, lifeActions).run(List(time(0), click(16, 16), click(32, 16), click(48, 16), down(" "), time(150)))
assertEquals(out.head, initialLife(7))
assertEquals(out.map(_.cells.length), List(0, 1, 2, 3, 3, 3))
assertEquals(out.last, LifeState(List(cell(2, 0), cell(2, 1), cell(2, 2)), running = true, seed = 7, generation = 1))
}
test("lifeMain asks for a seed, draws the empty world first, and then a click as a live cell") {
val traced = IO.trace(lifeMain, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
val (first, next) = traced.result.step(time(0))
val (second, _) = next.step(click(17, 33))
assertEquals(drawn(first.head), lifeView(initialLife(42)))
assertEquals(drawn(second.head), lifeView(initialLife(42).copy(cells = List(cell(1, 2)))))
}
test("lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds") {
val (_, afterFirst) = IO.trace(lifeMain, 42).result.step(time(0))
assertEquals(afterFirst.step(time(10000))._1.length, 1)
}
// ---- the runtime (step 7 of the module), with the fake platform of M09Runtime.test.scala
test("runApp registers a click handler with the platform") {
val p = FakePlatform()
Runtime.runApp(IO.pure(Stream.clicks.map(_ => IO.pure(()))), p)
assertEquals(p.registered, List("click", "down", "time", "up"))
}
test("runApp feeds clicks to the stream") {
val p = FakePlatform()
Runtime.runApp(IO.pure(Stream.clicks.map((x, y) => IO.draw(Shape.Rect(x, y, 1, 1, "#ffffff")))), p)
p.fireClick(33, 47)
p.fireClick(0, 2)
assertEquals(p.calls.toList, List(List("fillRect", 33, 47, 1, 1, "#ffffff"), List("fillRect", 0, 2, 1, 1, "#ffffff")))
}
test("runApp runs Life: a click on the canvas draws the world with that cell alive") {
val p = FakePlatform()
Runtime.runApp(lifeMain, p)
p.fireTime(0)
p.calls.clear()
p.fireClick(100, 250)
assert(p.calls.contains(List("fillRect", 6 * 16 + 1, 15 * 16 + 1, 14, 14, "#7cb342")))
}
test("Snake and Pong ignore clicks: their actions and drawings are the same with and without them") {
val withClicks = List(time(0), click(5, 5), time(25), down("ArrowUp"), click(300, 300), time(200))
val without = withClicks.filter {
case Event.Click(_, _) => false
case _ => true
}
assertEquals(App.speedActions.run(withClicks), App.speedActions.run(without))
assertEquals(pong.PongApp.pongActions.run(withClicks), pong.PongApp.pongActions.run(without))
assertEquals(App.play(App.main, 42, withClicks), App.play(App.main, 42, without))
}