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

+179 -4
View File
@@ -17,12 +17,42 @@ class M10ExtensionsSuite extends munit.FunSuite:
}
}
cases("m10-session-update").foreach { c =>
test(s"sessionUpdate: ${c("name").str}") {
val a = c("args")
assertEquals(App.sessionUpdate(session(a(0)), action(a(1))), session(c("expected")))
}
}
cases("m10-session-view").foreach { c =>
test(s"sessionView: ${c("name").str}") {
assertEquals(App.sessionView(session(c("args")(0))), list(c("expected"))(shape))
}
}
cases("m10-input").foreach { c =>
test(s"keyToAction: ${c("name").str}") {
assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action))
}
}
// ---- the turn queue (Exercise 10.6)
test("steer is what update does with a Turn") {
cases("m10-update").filter(_("args")(1)("tag").str == "Turn").foreach { c =>
val a = c("args")
assertEquals(Game.steer(gameState(a(0)), direction(a(1)("dir"))), gameState(c("expected")), c("name").str)
}
}
test("Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after") {
val start = Spawn.newGame(42) // heading Right from (10, 10); the apple is far away
val states = List(Action.Turn(Direction.Up), Action.Turn(Direction.Left), Action.Tick, Action.Tick)
.scanLeft(start)(Game.update)
assertEquals(states.drop(2).map(_.snake.head), List(Point(10, 10), Point(10, 9), Point(9, 9)))
assertEquals(states.map(_.queued), List(None, None, Some(Direction.Left), None, None))
}
private def time(t: Long): Event = Event.Time(t)
private val frameTimes = List(25L, 50L, 75L, 100L, 125L, 150L, 175L, 200L)
@@ -39,14 +69,159 @@ class M10ExtensionsSuite extends munit.FunSuite:
)
}
// The drawings are compared by their first shapes, the game's view: Exercise 10.7 adds the best score after it.
private def assertStartsWithView(io: IO[Unit], state: GameState): Unit =
val expected = View.view(state)
assertEquals(drawn(io).take(expected.length), expected)
test("main (seed 42) draws a moved snake after eight 25 ms frames") {
val traced = IO.trace(App.main, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
assertEquals(traced.ops.head, Op.RandomSeed)
val (first, afterFirst) = traced.result.step(time(0))
assertEquals(drawn(first.head), View.view(Spawn.newGame(42)))
assertStartsWithView(first.head, Spawn.newGame(42))
val drawings = afterFirst.run(frameTimes.map(time))
assertEquals(drawings.length, 8)
val start = Spawn.newGame(42)
val moved = start.copy(snake = start.snake.map(p => p.copy(x = p.x + 1)), frame = 8)
assertEquals(drawn(drawings(7)), View.view(moved))
val moved = start.copy(snake = start.snake.map(p => p.copy(x = p.x + 1)), frame = 0)
assertStartsWithView(drawings(7), moved)
}
// ---- clampTime: a long silence from the clock (a background tab) must not turn into hundreds of frames
private def down(key: String): Event = Event.KeyDown(key)
test("clampTime passes small steps of time through unchanged") {
assertEquals(Stream.interval(10).clampTime(100).run(List(5L, 10L, 35L).map(time)), List(10L, 20L, 30L))
}
test("clampTime lets a long gap count as maxGap, and carries on from there") {
assertEquals(Stream.interval(10).clampTime(25).run(List(10L, 5000L, 5010L).map(time)), List(10L, 20L, 30L, 40L))
}
test("clampTime clamps the very first step too (the clock starts at 0)") {
assertEquals(Stream.interval(10).clampTime(25).run(List(time(1000))), List(10L, 20L))
}
test("clampTime never moves the clock backwards") {
assertEquals(
Stream.interval(10).clampTime(100).run(List(30L, 10L, 40L).map(time)),
List(10L, 20L, 30L, 40L, 50L, 60L)
)
}
test("clampTime passes key inputs through, and they do not disturb the clock") {
assertEquals(Stream.keyDowns.clampTime(5).run(List(down("a"), time(50), down("b"))), List("a", "b"))
assertEquals(Stream.interval(10).clampTime(25).run(List(time(10), down("a"), time(20))), List(10L, 20L))
}
test("clampTime tells the inner stream exactly the clamped time, and a repeated time adds nothing") {
assertEquals(Stream.interval(1).clampTime(100).run(List(5L, 5L, 6L).map(time)), List(1L, 2L, 3L, 4L, 5L, 6L))
}
test("running the same clamped stream twice gives the same outputs") {
val s = Stream.interval(10).clampTime(25)
val xs = List(10L, 5000L, 5010L).map(time)
assertEquals(s.run(xs), s.run(xs))
}
test("the game accepts at most 250 ms of time at once") { assertEquals(App.MAX_GAP_MS, 250L) }
test("main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds") {
val (_, afterFirst) = IO.trace(App.main, 42).result.step(time(0))
val (drawings, next) = afterFirst.step(time(10000))
assertEquals(drawings.length.toLong, App.MAX_GAP_MS / App.FRAME_MS)
assertEquals(next.step(time(10025))._1.length, 1)
}
// ---- a persisted high score (Exercise 10.7)
private val loadOp: Op = Op.LoadBest
private def drawOps(scene: Scene): List[Op] = scene.map(Op.Draw(_))
test("loadBest records a LoadBest op and is answered with the best given to trace") {
assertEquals(IO.trace(IO.loadBest, 0, 17), Traced(17, List(loadOp)))
}
test("trace answers LoadBest with 0 when no best is given") {
assertEquals(IO.trace(IO.loadBest, 9).result, 0)
}
test("saveBest records a SaveBest op with the score and produces ()") {
assertEquals(IO.trace(IO.saveBest(7), 0), Traced((), List(Op.SaveBest(7))))
}
test("a program can load the best, then save a higher one") {
assertEquals(IO.trace(IO.loadBest.flatMap(b => IO.saveBest(b + 1)), 0, 4).ops, List(loadOp, Op.SaveBest(5)))
}
test("sessions starts with the new game and the stored best, then yields one session per action") {
val out = App.sessions(42, 3, App.speedActions).run(List(time(25), Event.KeyDown(" ")))
val s0 = Session(Spawn.newGame(42), 3, improved = false)
val s1 = App.sessionUpdate(s0, Action.Frame)
assertEquals(out, List(s0, s1, App.sessionUpdate(s1, Action.TogglePause)))
}
test("renderSession draws the session view and saves nothing when the best did not improve") {
val s = Session(Spawn.newGame(42), 5, improved = false)
assertEquals(IO.trace(App.renderSession(s), 0).ops, drawOps(App.sessionView(s)))
}
test("renderSession draws the session view, then saves the best, when the best improved") {
val s = Session(Spawn.newGame(42).copy(score = 6), 6, improved = true)
assertEquals(IO.trace(App.renderSession(s), 0).ops, drawOps(App.sessionView(s)) :+ Op.SaveBest(6))
}
test("main asks for the seed, then the stored best, and draws the first session") {
val traced = IO.trace(App.main, 42, 9)
assertEquals(traced.ops, List(Op.RandomSeed, loadOp))
val (first, _) = traced.result.step(time(0))
assertEquals(drawn(first.head), App.sessionView(Session(Spawn.newGame(42), 9, improved = false)))
}
test("play passes the best to the program: the first scene shows it") {
assertEquals(
App.play(App.main, 42, List(time(0)), 9),
List(drawOps(App.sessionView(Session(Spawn.newGame(42), 9, improved = false))))
)
}
// The golden fixture: a seed, a scripted list of inputs, a stored best, and the scene of the last drawing.
cases("m10-golden").foreach { c =>
val a = c("args")
def played = App.play(App.main, a(0).num.toLong, list(a(1))(event), a(2).num.toInt)
val expected = list(c("expected"))(shape)
test(s"play(main): ${c("name").str}") {
assertEquals(played.last.collect { case Op.Draw(shape) => shape }, expected)
}
test(s"play(main) saves once per improvement: ${c("name").str}") {
val best = a(2).num.toInt
val saves = played.flatten.collect { case Op.SaveBest(score) => score }
val finalBest = expected.collectFirst { case Shape.Text(_, _, t, _, _, _) if t.startsWith("Best: ") => t.drop(6).toInt }.get
assertEquals(saves, ((best + 1) to finalBest).toList)
}
}
// The runtime with a fake platform (M09Runtime.test.scala) that records the two new calls.
test("runIO answers LoadBest with platform.loadBest() and continues with it") {
val p = FakePlatform(42, 11)
assertEquals(Runtime.runIO(IO.loadBest.map(_ + 1), p), 12)
assertEquals(p.calls.toList, List(List("loadBest")))
}
test("runIO maps SaveBest to platform.saveBest(score)") {
val p = FakePlatform()
Runtime.runIO(IO.saveBest(8).flatMap(_ => IO.pure("done")), p)
assertEquals(p.calls.toList, List(List("saveBest", 8)))
}
test("runApp asks for the seed and loads the best once, at start, and draws nothing before the first input") {
val p = FakePlatform(42, 3)
Runtime.runApp(App.main, p)
assertEquals(p.calls.toList, List(List("randomSeed"), List("loadBest")))
p.fireTime(0)
p.fireTime(25)
assertEquals(p.calls.count(_.head == "loadBest"), 1)
assert(p.calls.exists(c => c.head == "fillText" && c(3) == "Best: 3"))
}