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,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)
|
||||
)
|
||||
Reference in new issue
Block a user