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

+141
View File
@@ -0,0 +1,141 @@
package snake
/** What `Check.forAll` reports: every value passed, or the first one that did not (with the seed that draws it again). */
enum CheckResult[+A]:
case Passed(runs: Int)
case Failed(run: Int, seed: Long, value: A)
/**
* m12 check: property-based testing built from the course's own parts.
* A generator of test data is nothing new: it is a Random[A] from Random.scala (seed => (A, nextSeed)).
*/
object Check:
/** One of the elements of the non-empty list `xs`: the one at index `intBelow(xs.length)`. */
def elementOf[A](xs: List[A]): Random[A] = Random.intBelow(xs.length).map(xs(_))
/** `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next. */
def listOf[A](gen: Random[A], n: Int): Random[List[A]] =
if n <= 0 then Random.pure(Nil)
else gen.flatMap(a => listOf(gen, n - 1).map(rest => a :: rest))
// Ticks appear three times so that the snake moves more often than it turns.
private val actionPool: List[Action] = List(
Action.Tick, Action.Tick, Action.Tick,
Action.Turn(Direction.Up), Action.Turn(Direction.Down), Action.Turn(Direction.Left), Action.Turn(Direction.Right)
)
/** A generator of `n` random actions (given). */
def genActions(n: Int): Random[List[Action]] = listOf(elementOf(actionPool), n)
/**
* Check `prop` on `runs` values drawn from `gen`, starting from `seed`; stop at the first value that fails.
* Recursion carries the seed: no counter, no loop. `tailrec` makes the compiler check that it cannot overflow the stack.
*/
def forAll[A](gen: Random[A], runs: Int, seed: Long)(prop: A => Boolean): CheckResult[A] =
@annotation.tailrec
def go(i: Int, s: Long): CheckResult[A] =
if i >= runs then CheckResult.Passed(runs)
else
val (value, next) = gen.run(s)
if prop(value) then go(i + 1, next) else CheckResult.Failed(i, s, value)
go(0, seed)
/** Every list that is `xs` with exactly one element removed, in the order of the removed index. */
def removals[A](xs: List[A]): List[List[A]] = xs.indices.toList.map(i => xs.take(i) ++ xs.drop(i + 1))
/** Keep removing one element for as long as the list still fails; the result is 1-minimal. */
def minimize[A](xs: List[A])(fails: List[A] => Boolean): List[A] =
removals(xs).find(fails) match
case Some(smaller) => minimize(smaller)(fails)
case None => xs
private def neighbours(a: Point, b: Point): Boolean = math.abs(a.x - b.x) + math.abs(a.y - b.y) == 1
// Each invariant: its name, and a predicate that is true when the state is fine.
private val invariants: List[(String, GameState => Boolean)] = List(
("snake-not-empty", s => s.snake.nonEmpty),
("snake-in-bounds", s => s.snake.forall(p => Rules.inBounds(s, p))),
("snake-no-overlap", s => s.snake.distinct.length == s.snake.length),
("snake-connected", s => s.snake.zip(s.snake.drop(1)).forall((a, b) => neighbours(a, b))),
("apple-in-bounds", s => s.apple.forall(a => Rules.inBounds(s, a))),
("apple-not-on-snake", s => s.apple.forall(a => !Util.contains(s.snake, a, _ == _))),
("turn-not-reversed", s => s.nextDir != Model.opposite(s.dir)),
("queued-not-reversed", s => !s.queued.contains(Model.opposite(s.nextDir)))
)
/** The names of the invariants that `state` breaks, in the order of the table above. */
def violations(state: GameState): List[String] =
invariants.collect { case (name, holds) if !holds(state) => name }
/** The numbers of states between consecutive moves of the snake's head. */
def moveGaps(states: List[GameState]): List[Int] =
val moved = states.zip(states.drop(1)).zipWithIndex.collect {
case ((before, after), i) if after.snake.headOption != before.snake.headOption => i + 1
}
moved.zip(moved.drop(1)).map((a, b) => b - a)
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
private val directions = List(Direction.Up, Direction.Down, Direction.Left, Direction.Right)
// `Rules.inBounds` wants a whole state; here there is only a board size.
private def onBoard(width: Int, height: Int, p: Point): Boolean = p.x >= 0 && p.x < width && p.y >= 0 && p.y < height
// Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
private def grow(width: Int, height: Int, snake: List[Point], left: Int): Random[List[Point]] =
if left <= 0 then Random.pure(snake)
else
val options = directions
.map(d => Rules.move(snake.last, d))
.filter(p => onBoard(width, height, p) && !Util.contains(snake, p, _ == _))
if options.isEmpty then Random.pure(snake)
else elementOf(options).flatMap(p => grow(width, height, snake :+ p, left - 1))
/** A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself. */
def genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]] =
Random.intBelow(width).flatMap(x =>
Random.intBelow(height).flatMap(y => grow(width, height, List(Point(x, y)), maxLen - 1))
)
// The direction the head moved in to get from the second cell to the first (Right for a one-cell snake).
private def headingOf(snake: List[Point]): Direction = snake match
case head :: second :: _ => directions.find(d => Rules.move(second, d) == head).getOrElse(Direction.Right)
case _ => Direction.Right
/** A random sane Playing state on a `width` x `height` board (given). */
def genState(width: Int, height: Int): Random[GameState] =
Random.intBelow(width * height).flatMap { n =>
genSnake(width, height, n + 1).flatMap { snake =>
val dir = headingOf(snake)
val state = GameState(width, height, snake, dir, dir, None, 0L, 0, Status.Playing, 0, None)
val free = Spawn.freeCells(state)
elementOf(directions.filter(_ != Model.opposite(dir))).flatMap { nextDir =>
Random.intBelow(2).flatMap { coin =>
val apple: Random[Option[Point]] =
if coin == 0 || free.isEmpty then Random.pure(None) else elementOf(free).map(Some(_))
apple.map(a => state.copy(nextDir = nextDir, apple = a))
}
}
}
}
/**
* The step of Module 3, written from the rules instead of from `advance`: build the snake the step would give,
* then ask `violations` whether that snake is on the board and does not overlap itself.
*/
def advanceSpec(state: GameState): GameState =
if state.status != Status.Playing then state
else
val head = Rules.move(state.snake.head, state.nextDir)
val eating = state.apple.contains(head)
val snake = head :: (if eating then state.snake else Util.dropLast(state.snake))
val broken = violations(state.copy(snake = snake)).exists(v => v == "snake-in-bounds" || v == "snake-no-overlap")
if broken then state.copy(dir = state.nextDir, status = Status.GameOver)
else
state.copy(
snake = snake,
dir = state.nextDir,
apple = if eating then None else state.apple,
score = state.score + (if eating then 1 else 0)
)