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

+12 -1
View File
@@ -40,9 +40,20 @@ object Fixtures:
seed = v("seed").num.toLong,
score = v("score").num.toInt,
status = status(v("status")),
frame = v("frame").num.toInt
frame = v("frame").num.toInt,
queued = opt(v("queued"))(direction)
)
def session(v: ujson.Value): Session =
Session(gameState(v("game")), v("best").num.toInt, v("improved").bool)
def event(v: ujson.Value): Event = v("tag").str match
case "Time" => Event.Time(v("t").num.toLong)
case "KeyDown" => Event.KeyDown(v("key").str)
case "KeyUp" => Event.KeyUp(v("key").str)
case "Click" => Event.Click(v("x").num.toInt, v("y").num.toInt)
case other => throw new IllegalArgumentException(s"unknown event tag: $other")
def shape(v: ujson.Value): Shape = v("tag").str match
case "Rect" => Shape.Rect(v("x").num.toInt, v("y").num.toInt, v("w").num.toInt, v("h").num.toInt, v("color").str)
case "Text" =>
+1 -1
View File
@@ -29,7 +29,7 @@ class M04SpawnSuite extends munit.FunSuite:
// 3x2 board, one-cell snake, about to eat the apple at (1,0)
private val eating = GameState(3, 2, List(Point(0, 0)), Direction.Right, Direction.Right,
Some(Point(1, 0)), 0L, 0, Status.Playing, 0)
Some(Point(1, 0)), 0L, 0, Status.Playing, 0, None)
test("tick: eating the apple spawns a new one in the same step") {
// snake becomes (1,0),(0,0); free cells are (2,0),(0,1),(1,1),(2,1); seed 0 draws 1144304738 % 4 = 2 -> (1,1)
+77
View File
@@ -0,0 +1,77 @@
package snake
class M04StateSuite extends munit.FunSuite:
// Small initial states for the "behaves like" checks: two States behave alike when they give the same pair from each.
private val starts = List(0, 1, 7, 42)
private def sameOnAll[A](st1: State[Int, A], st2: State[Int, A]): Unit =
starts.foreach(s => assertEquals(st1.run(s), st2.run(s), s"from state $s"))
// A counter step: gives the current number times 10 and adds 1 to the state. A def, so that a stub that throws
// fails each test on its own instead of the whole suite at construction time.
private def step: State[Int, Int] = State.get[Int].flatMap(n => State.put(n + 1).map(_ => n * 10))
private def f(a: Int): State[Int, Int] = State.modify[Int](_ * 2).map(_ => a + 1)
private def g(b: Int): State[Int, (Int, Int)] = State.get[Int].flatMap(s => State.pure((b, s)))
test("State pure gives its value and leaves the state alone") {
assertEquals(State.pure[Int, String]("x").run(99), ("x", 99))
}
test("State map applies f to the value and keeps the state from the underlying step") {
assertEquals(step.map(_ + 1).run(4), (41, 5))
}
test("State flatMap threads the state through both steps") {
assertEquals(step.flatMap(a => step.map(b => (a, b))).run(4), ((40, 50), 6))
}
test("State get gives the state as the value and leaves it alone") {
assertEquals(State.get[String].run("board"), ("board", "board"))
}
test("State put replaces the state and gives unit") {
assertEquals(State.put(5).run(99), ((), 5))
}
test("State modify applies f to the state and gives unit") {
assertEquals(State.modify[Int](_ * 2).run(21), ((), 42))
}
test("State law: left identity, flatMap(pure(a), f) behaves like f(a)") {
sameOnAll(State.pure[Int, Int](3).flatMap(f), f(3))
}
test("State law: right identity, flatMap(m, pure) behaves like m") {
sameOnAll(step.flatMap(State.pure), step)
}
test("State law: associativity, the grouping of flatMap does not matter") {
sameOnAll(step.flatMap(f).flatMap(g), step.flatMap(a => f(a).flatMap(g)))
}
test("State a counter hands out fresh labels in order") {
val fresh = State.get[Int].flatMap(n => State.put(n + 1).map(_ => s"L$n"))
val three = fresh.flatMap(a => fresh.flatMap(b => fresh.map(c => List(a, b, c))))
assertEquals(three.run(0), (List("L0", "L1", "L2"), 3))
assertEquals(three.run(7), (List("L7", "L8", "L9"), 10))
}
test("State Random is State with the seed as S: the dice give the same values and seeds") {
// One draw written only with State's get and put: read the seed, compute, store the next seed.
val draw = State.get[Long].flatMap { seed =>
val (v, next) = Random.nextUint32.run(seed)
State.put(next).map(_ => v)
}
val d6State = draw.map(v => 1 + (v % 6))
val twoDiceState = d6State.flatMap(a => d6State.map(b => a + b))
val d6Random = Random.nextUint32.map(v => 1 + (v % 6))
val twoDiceRandom = d6Random.flatMap(a => d6Random.map(b => a + b))
// A Random step turned into a State by hand: the same function, in the other wrapper.
val drawAsState = State[Long, Long](seed => Random.nextUint32.run(seed))
List(0L, 1L, 42L, 4294967295L).foreach { seed =>
assertEquals(draw.run(seed), Random.nextUint32.run(seed), s"draw from $seed")
assertEquals(draw.run(seed), drawAsState.run(seed), s"draw as a State from $seed")
assertEquals(d6State.run(seed), d6Random.run(seed), s"d6 from $seed")
assertEquals(twoDiceState.run(seed), twoDiceRandom.run(seed), s"twoDice from $seed")
}
}
+262
View File
@@ -0,0 +1,262 @@
package snake
import SafeIO.*
class M07bSafeIOSuite extends munit.FunSuite:
private def rectAt(x: Int): Shape = Shape.Rect(x, 0, 1, 1, "#000000")
private val r1 = rectAt(0)
private val t1 = Shape.Text(1, 2, "hi", "#ffffff", 16, "left")
private def drawOp(shape: Shape): Op = Op.Draw(shape)
private val seedOp: Op = Op.RandomSeed
private def boom[A, B](a: A): B = throw new RuntimeException("boom")
private val N = 100000
// The value of a RandomSeed answer (the tests' interpreters always answer it with a Seed).
private def seedOf(r: OpResult): Long = r match
case OpResult.Seed(value) => value
case _ => -1L // Done, or Module 10's Best: never the answer to a RandomSeed
// Which constructor a program is. Functions cannot be compared, so the tests check a node's shape by its tag and
// then observe the program through `trace`. (A tag, not the program itself: printing a 100 000-deep chain would
// overflow the stack.)
private def tag(p: SafeIO[Any]): String = p match
case Pure(_) => "Pure"
case Effect(_, _) => "Effect"
case FlatMap(_, _) => "FlatMap"
// --- building -------------------------------------------------------------------------------------------------
test("pure builds a Pure") { assertEquals(pure(3), Pure(3)) }
test("effect(op) is an Effect whose continuation is pure") {
effect(seedOp) match
case Effect(op, k) =>
assertEquals(op, seedOp)
assertEquals(k(OpResult.Seed(5)), Pure(OpResult.Seed(5)))
case other => fail(s"expected an Effect, got ${tag(other)}")
}
test("flatMap only builds a FlatMap node") {
val m = pure(1)
val p = m.flatMap(n => pure(n + 1))
p match
case FlatMap(io, _) => assertEquals[SafeIO[Any], SafeIO[Any]](io, m)
case other => fail(s"expected a FlatMap, got ${tag(other)}")
assertEquals(trace(p, 0), Traced(2, Nil))
}
test("building flatMap(pure(1), throwingF) does not throw") {
assertEquals(tag(pure(1).flatMap(boom[Int, SafeIO[Int]])), "FlatMap")
}
test("building map(io, throwingF) does not throw") {
assertEquals(tag(effect(seedOp).map(boom[OpResult, Int])), "FlatMap")
}
test("tracing map(io, throwingF) runs f and throws") {
interceptMessage[RuntimeException]("boom")(trace(effect(seedOp).map(boom[OpResult, Int]), 0))
}
// --- step: the three rewrite rules ----------------------------------------------------------------------------
// An Effect that reads the seed and produces it as a Long.
private val readSeed: SafeIO[Long] = Effect(seedOp, r => Pure(seedOf(r)))
test("step leaves a Pure unchanged") {
val p = pure(1)
assertEquals(step(p), p)
}
test("step leaves an Effect unchanged") {
val p = effect(seedOp)
assertEquals(step(p), p)
}
test("step of FlatMap(Pure(a), f) is f(a)") {
assertEquals(step(pure(3).flatMap(n => pure(n * 2))), Pure(6))
}
test("step of FlatMap(Effect(op, k), f) is an Effect with the same op that continues with FlatMap(k(r), f)") {
step(readSeed.flatMap(n => pure(n * 2))) match
case Effect(op, k) =>
assertEquals(op, seedOp)
val next = k(OpResult.Seed(5))
next match
case FlatMap(io, _) => assertEquals[SafeIO[Any], SafeIO[Any]](io, Pure(5L))
case other => fail(s"expected a FlatMap, got ${tag(other)}")
assertEquals(trace(next, 0), Traced(10L, Nil))
case other => fail(s"expected an Effect, got ${tag(other)}")
}
test("step re-associates FlatMap(FlatMap(m, g), f) into FlatMap(m, x => FlatMap(g(x), f))") {
val m = readSeed
val s = step(m.flatMap(n => pure(n + 1)).flatMap(n => pure(n * 2)))
s match
case FlatMap(io, _) => assertEquals[SafeIO[Any], SafeIO[Any]](io, m)
case other => fail(s"expected a FlatMap, got ${tag(other)}")
assertEquals(trace(s, 4), Traced(10L, List(seedOp)))
}
test("step does not call the functions it re-associates") {
assertEquals(tag(step(effect(seedOp).flatMap(boom[OpResult, SafeIO[Int]]).flatMap(boom[Int, SafeIO[Int]]))), "FlatMap")
}
test("step of a left-nested chain is one rewrite, not a whole walk") {
// After one step, the top is still a FlatMap: step did constant work, the rest is left for later steps.
val chain = (1 to N).foldLeft(pure(0))((acc, _) => acc.flatMap(n => pure(n + 1)))
assertEquals(tag(step(chain)), "FlatMap")
}
// --- the interpreter ------------------------------------------------------------------------------------------
test("trace of pure(3) has result 3 and no ops") {
assertEquals(trace(pure(3), 0), Traced(3, Nil))
}
test("trace answers RandomSeed with the seed and Draw with Done") {
assertEquals(
trace(effect(seedOp).flatMap(s => effect(drawOp(r1)).map(_ => s)), 9),
Traced(OpResult.Seed(9), List(seedOp, drawOp(r1)))
)
assertEquals(trace(effect(drawOp(r1)), 9), Traced(OpResult.Done, List(drawOp(r1))))
}
test("trace refuses operations of later modules") {
intercept[IllegalArgumentException](trace(effect(Op.LoadBest), 0))
}
private def drawing(shape: Shape): SafeIO[Unit] = effect(drawOp(shape)).map(_ => ())
test("sequence of nothing has no ops") {
assertEquals(trace(sequence(Nil), 0), Traced((), Nil))
}
test("sequence runs effects left to right") {
assertEquals(
trace(sequence(List(effect(seedOp).map(_ => ()), drawing(t1), drawing(r1))), 0).ops,
List(seedOp, drawOp(t1), drawOp(r1))
)
}
// --- stack safety ---------------------------------------------------------------------------------------------
private def leftDraws(n: Int): SafeIO[Any] =
(0 until n).foldLeft[SafeIO[Any]](pure(()))((acc, i) => acc.flatMap(_ => effect(drawOp(rectAt(i)))))
private def rightDraws(n: Int): SafeIO[Any] =
(0 until n).foldRight[SafeIO[Any]](pure(()))((i, rest) => effect(drawOp(rectAt(i))).flatMap(_ => rest))
test("100 000 left-nested flatMaps of draws run, in order") {
val Traced(result, ops) = trace(leftDraws(N), 0)
assertEquals(result, OpResult.Done)
assertEquals(ops.length, N)
assertEquals((ops.head, ops.last), (drawOp(rectAt(0)), drawOp(rectAt(N - 1))))
}
test("100 000 right-nested flatMaps of draws run, in order") {
val ops = trace(rightDraws(N), 0).ops
assertEquals(ops.length, N)
assertEquals((ops.head, ops.last), (drawOp(rectAt(0)), drawOp(rectAt(N - 1))))
}
test("100 000 left-nested maps run without any op") {
assertEquals(trace((1 to N).foldLeft(pure(0))((acc, _) => acc.map(_ + 1)), 0), Traced(N, Nil))
}
test("sequence of 100 000 draws runs") {
assertEquals(trace(sequence(List.tabulate(N)(i => drawing(rectAt(i)))), 0).ops.length, N)
}
test("a while loop over step is a complete interpreter for 100 000 left-nested flatMaps") {
// What an impure runtime would do (a loop is allowed there): rewrite until the top is Pure or Effect, perform.
var performed = List.empty[Op]
var current = leftDraws(N)
while tag(current) != "Pure" do
current = step(current)
current match
case Effect(op, k) =>
performed = op :: performed
current = k(OpResult.Done)
case _ => ()
assertEquals(performed.length, N)
}
// --- the monad laws, observed through trace -------------------------------------------------------------------
private val programs: List[(String, () => SafeIO[Long])] = List(
("a pure value", () => pure(4L)),
("one effect", () => readSeed),
("a draw followed by reading the seed", () => effect(drawOp(r1)).flatMap(_ => readSeed))
)
private def f(n: Long): SafeIO[Long] = effect(drawOp(rectAt(n.toInt))).flatMap(_ => pure(n * 2))
private def g(n: Long): SafeIO[Long] = effect(seedOp).map(s => seedOf(s) + n)
programs.foreach { (name, program) =>
test(s"left identity holds for $name") {
val a = trace(program(), 5).result
assertEquals(trace(pure(a).flatMap(f), 5), trace(f(a), 5))
}
test(s"right identity holds for $name") {
assertEquals(trace(program().flatMap(pure), 5), trace(program(), 5))
}
test(s"associativity holds for $name") {
assertEquals(trace(program().flatMap(f).flatMap(g), 5), trace(program().flatMap(x => f(x).flatMap(g)), 5))
}
}
// --- the same programs under Module 7's IO --------------------------------------------------------------------
// Each program is written once against this interface and built with both IOs.
private trait Builder[F[_]]:
def pure[A](a: A): F[A]
def effect(op: Op): F[OpResult]
def map[A, B](io: F[A])(f: A => B): F[B]
def flatMap[A, B](io: F[A])(f: A => F[B]): F[B]
def sequence(ios: List[F[Unit]]): F[Unit]
def trace[A](io: F[A], seed: Long): Traced[A]
def perform(op: Op): F[Unit] = map(effect(op))(_ => ())
private object Safe extends Builder[SafeIO]:
def pure[A](a: A): SafeIO[A] = SafeIO.pure(a)
def effect(op: Op): SafeIO[OpResult] = SafeIO.effect(op)
def map[A, B](io: SafeIO[A])(f: A => B): SafeIO[B] = io.map(f)
def flatMap[A, B](io: SafeIO[A])(f: A => SafeIO[B]): SafeIO[B] = io.flatMap(f)
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = SafeIO.sequence(ios)
def trace[A](io: SafeIO[A], seed: Long): Traced[A] = SafeIO.trace(io, seed)
private object Simple extends Builder[IO]:
def pure[A](a: A): IO[A] = IO.pure(a)
def effect(op: Op): IO[OpResult] = IO.effect(op)
def map[A, B](io: IO[A])(f: A => B): IO[B] = io.map(f)
def flatMap[A, B](io: IO[A])(f: A => IO[B]): IO[B] = io.flatMap(f)
def sequence(ios: List[IO[Unit]]): IO[Unit] = IO.sequence(ios)
def trace[A](io: IO[A], seed: Long): Traced[A] = IO.trace(io, seed)
// A program for any Builder, traced with seed 7.
private trait Shared:
def traced[F[_]](M: Builder[F]): Traced[?]
private val shared: List[(String, Shared)] = List(
("seed, then a draw that depends on it", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
M.trace(M.flatMap(M.map(M.effect(seedOp))(seedOf))(s => M.map(M.effect(drawOp(rectAt((s % 20).toInt))))(_ => s)), 7)
),
("a sequence of three effects", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
M.trace(M.sequence(List(M.perform(drawOp(t1)), M.perform(seedOp), M.perform(drawOp(r1)))), 7)
),
("200 left-nested flatMaps", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
M.trace((0 until 200).foldLeft(M.pure(0))((acc, i) => M.flatMap(acc)(n => M.map(M.effect(drawOp(rectAt(i))))(_ => n + 1))), 7)
),
("nested on both sides", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
val left = M.flatMap(M.map(M.effect(seedOp))(seedOf))(s => M.flatMap(M.effect(drawOp(r1)))(_ => M.pure(s + 1)))
M.trace(M.flatMap(left)(n => M.map(M.sequence(List(M.perform(drawOp(t1)), M.perform(seedOp))))(_ => n * 3)), 7)
)
)
shared.foreach { (name, program) =>
test(s"trace equals Module 7's trace for $name") {
assertEquals(program.traced(Safe), program.traced(Simple))
}
}
+4
View File
@@ -27,6 +27,10 @@ class M08StreamSuite extends munit.FunSuite:
assertEquals(interval(10).run(List(time(35))), List(10L, 20L, 30L))
}
test("interval(1) emits every millisecond crossed") {
assertEquals(interval(1).run(List(time(1), time(3))), List(1L, 2L, 3L))
}
test("interval ignores key events") {
assertEquals(interval(10).run(List(down("a"), up("a"))), Nil)
}
+26 -2
View File
@@ -28,12 +28,18 @@ class M09AppSuite extends munit.FunSuite:
assertEquals(IO.trace(App.render(Spawn.newGame(42)), 0).ops, View.view(Spawn.newGame(42)).map(Op.Draw(_)))
}
// Module 10 (Exercise 10.7) makes `main` also load the stored best and draw one more shape after the game's view,
// so this test accepts both versions; m10 pins the final one exactly.
test("main asks for a random seed, then builds the stream from it") {
val traced = IO.trace(App.main, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
assert(
traced.ops == List(Op.RandomSeed) || traced.ops == List(Op.RandomSeed, Op.LoadBest),
s"main may only ask for the seed (and, from m10 on, the stored best); it asked for ${traced.ops}"
)
val (outs, _) = traced.result.step(time(0))
assertEquals(outs.length, 1)
assertEquals(drawn(outs.head), View.view(Spawn.newGame(42)))
val expected = View.view(Spawn.newGame(42))
assertEquals(drawn(outs.head).take(expected.length), expected)
}
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
@@ -54,3 +60,21 @@ class M09AppSuite extends munit.FunSuite:
assertEquals(first.length, 1)
assert(later.nonEmpty)
}
// ---- play: the whole program in one equality (Exercise 9.5)
private def drawOps(state: GameState): List[Op] = View.view(state).map(Op.Draw(_))
test("play on the classic program equals the scenes of the expected states") {
val events = List(time(150), down("ArrowUp"), down("x"), time(300), time(310))
val expectedStates = Game.history(Spawn.newGame(42), List(Action.Tick, Action.Turn(Direction.Up), Action.Tick))
assertEquals(App.play(classicMain, 42, events), expectedStates.map(drawOps))
}
test("play with no events gives no drawings") {
assertEquals(App.play(classicMain, 42, Nil), Nil)
}
test("play uses the seed for the program's RandomSeed") {
assertEquals(App.play(classicMain, 7, List(time(0))), List(drawOps(Spawn.newGame(7))))
}
+24 -5
View File
@@ -2,12 +2,15 @@ package snake
import scala.collection.mutable.ListBuffer
/** A fake platform: records every call and lets the test fire events by hand. */
class FakePlatform(seed: Long = 42) extends Platform:
/** A fake platform: records every call and lets the test fire events by hand. `best` answers loadBest (m10). */
class FakePlatform(seed: Long = 42, best: Int = 0) extends Platform:
val calls: ListBuffer[List[Any]] = ListBuffer.empty
private var onTime: Long => Unit = _ => ()
private var onDown: String => Unit = _ => ()
private var onUp: String => Unit = _ => ()
private var onClickCb: (Int, Int) => Unit = (_, _) => ()
// Which callbacks the runtime registered, by name: "click", "down", "time", "up" (m13).
private var handlers: Set[String] = Set.empty
def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit = calls += List("fillRect", x, y, w, h, color)
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit =
@@ -15,13 +18,29 @@ class FakePlatform(seed: Long = 42) extends Platform:
def randomSeed(): Long =
calls += List("randomSeed")
seed
def startClock(cb: Long => Unit): Unit = onTime = cb
def onKeyDown(cb: String => Unit): Unit = onDown = cb
def onKeyUp(cb: String => Unit): Unit = onUp = cb
def loadBest(): Int =
calls += List("loadBest")
best
def saveBest(score: Int): Unit = calls += List("saveBest", score)
def startClock(cb: Long => Unit): Unit =
onTime = cb
handlers += "time"
def onKeyDown(cb: String => Unit): Unit =
onDown = cb
handlers += "down"
def onKeyUp(cb: String => Unit): Unit =
onUp = cb
handlers += "up"
// m13: a runtime from Module 9 does not register this yet; nothing in m09-m12 fires a click.
def onClick(cb: (Int, Int) => Unit): Unit =
onClickCb = cb
handlers += "click"
def fireTime(t: Long): Unit = onTime(t)
def fireKey(key: String): Unit = onDown(key)
def fireKeyUp(key: String): Unit = onUp(key)
def fireClick(x: Int, y: Int): Unit = onClickCb(x, y)
def registered: List[String] = handlers.toList.sorted
class M09RuntimeSuite extends munit.FunSuite:
// The m09 program (classicMain) ticks every 150 ms; the shipped `main` uses speedActions after m10.
+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"))
}
+125
View File
@@ -0,0 +1,125 @@
package snake
import Fixtures.*
import Replay.*
import M09Helpers.drawn
class M10ReplaySuite extends munit.FunSuite:
private def viewer(v: ujson.Value): Viewer = Viewer(v("index").num.toInt, v("last").num.toInt, v("playing").bool)
private def viewerAction(v: ujson.Value): ViewerAction = ViewerAction.valueOf(v("tag").str)
cases("m10-viewer-update").foreach { c =>
test(s"viewerUpdate: ${c("name").str}") {
val a = c("args")
assertEquals(viewerUpdate(viewer(a(0)), viewerAction(a(1))), viewer(c("expected")))
}
}
cases("m10-viewer-key").foreach { c =>
test(s"viewerKey: ${c("name").str}") {
assertEquals(viewerKey(c("args")(0).str), opt(c("expected"))(viewerAction))
}
}
cases("m10-viewer-view").foreach { c =>
test(s"viewerView: ${c("name").str}") {
val a = c("args")
assertEquals(viewerView(list(a(0))(gameState), viewer(a(1))), list(c("expected"))(shape))
}
}
// The sample recording, as the page loads it. Not a fixture of cases: a recording `{seed, actions}`.
private lazy val sample: Recording = {
val v = load("replay-sample")
Recording(v("seed").num.toLong, list(v("actions"))(action))
}
private def time(t: Long): Event = Event.Time(t)
private def key(k: String): Event = Event.KeyDown(k)
test("the sample recording is worth watching: Tick and Turn only, under 400 actions, at least five apples, then game over") {
assert(sample.actions.forall {
case Action.Tick | Action.Turn(_) => true
case _ => false
})
assert(sample.actions.length >= 150 && sample.actions.length <= 400, s"${sample.actions.length} actions")
val fs = Game.history(Spawn.newGame(sample.seed), sample.actions)
assert(fs.last.score >= 5, s"score ${fs.last.score}")
assertEquals(fs.last.status, Status.GameOver)
assertEquals(fs.init.last.status, Status.Playing)
}
test("frames replays the recording with history, from the new game of its seed") {
val recording = Recording(42, List(Action.Tick, Action.Turn(Direction.Up), Action.Tick))
assertEquals(frames(recording), Game.history(Spawn.newGame(42), recording.actions))
assertEquals(frames(sample).length, sample.actions.length + 1)
}
test("viewerStart is the first frame, not playing") {
assertEquals(viewerStart(12), Viewer(0, 12, playing = false))
}
test("viewerUpdate keeps the index in [0, last] whatever the actions") {
import ViewerAction.*
val actions = List(Prev, Next, Last, Next, TogglePlay, Beat, Beat, First, Prev, Beat)
val viewers = actions.scanLeft(viewerStart(3))(viewerUpdate)
assert(viewers.forall(v => v.index >= 0 && v.index <= 3 && !(v.playing && v.index == 3)))
}
test("the autoplay clock beats every 100 ms") { assertEquals(REPLAY_MS, 100L) }
test("replayActions turns the clock into Beats and keys into viewer actions") {
assertEquals(
replayActions.run(List(time(100), key("ArrowRight"), key("x"), key(" "), time(300))),
List(ViewerAction.Beat, ViewerAction.Next, ViewerAction.TogglePlay, ViewerAction.Beat, ViewerAction.Beat)
)
}
test("autoplay: Space, then the clock steps one frame per 100 ms and stops at the end") {
val out = viewerStates(3, replayActions).run(List(time(0), key(" "), time(100), time(200), time(300), time(400), time(500)))
assertEquals(out.map(_.index), List(0, 0, 1, 2, 3, 3, 3))
assertEquals(out.map(_.playing), List(false, true, true, true, false, false, false))
}
test("without Space nothing plays: the clock alone never moves the viewer") {
assert(viewerStates(3, replayActions).run(List(time(0), time(1000), time(2000))).forall(_.index == 0))
}
test("replayMain asks for nothing and draws the first frame of the recording first") {
val traced = IO.trace(replayMain(sample), 0)
assertEquals(traced.ops, Nil)
val (drawings, _) = traced.result.step(time(0))
assertEquals(drawings.length, 1)
assertEquals(drawn(drawings.head), viewerView(frames(sample), viewerStart(sample.actions.length)))
val newGameView = View.view(Spawn.newGame(sample.seed))
assertEquals(drawn(drawings.head).take(newGameView.length), newGameView)
}
test("replayMain: End shows the last frame of the game, the game over") {
val out = IO.trace(replayMain(sample), 0).result.run(List(time(0), key("End")))
val fs = frames(sample)
assertEquals(drawn(out.last), viewerView(fs, Viewer(fs.length - 1, fs.length - 1, playing = false)))
assert(drawn(out.last).exists {
case Shape.Text(_, _, "Game Over", _, _, _) => true
case _ => false
})
}
test("stepping back shows exactly the earlier scene") {
val out = IO
.trace(replayMain(sample), 0)
.result
.run(List(time(0), key("ArrowRight"), key("ArrowRight"), key("ArrowRight"), key("ArrowLeft"), key("d"), key("a"), key("a")))
val scenes = out.map(drawn)
assertEquals(scenes(4), scenes(2))
assertEquals(scenes(6), scenes(4))
assertEquals(scenes(7), scenes(1))
assertNotEquals(scenes(3), scenes(2))
}
test("replayMain: after ten seconds in a background tab autoplay moves 2 frames (250 ms), not 100") {
val (_, afterPlay) = IO.trace(replayMain(sample), 0).result.step(key(" "))
val (_, afterFirst) = afterPlay.step(time(0))
assertEquals(afterFirst.step(time(10000))._1.length, 2)
}
+5
View File
@@ -120,6 +120,11 @@ class M11PongSuite extends munit.FunSuite:
assertEquals(out(3).player, 20)
}
test("pongMain: after ten seconds in a background tab the match advances by 250 ms, not by ten seconds") {
val (_, afterFirst) = IO.trace(pongMain, 42).result.step(time(0))
assertEquals(afterFirst.step(time(10000))._1.length, 8)
}
test("pongMain asks for a seed, draws the new match first, and then the updated one") {
val traced = IO.trace(pongMain, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
+392
View File
@@ -0,0 +1,392 @@
package snake
import Fixtures.*
import Check.*
class M12CheckSuite extends munit.FunSuite:
// ---- generators
test("elementOf picks the element at the index drawn by intBelow") {
val xs = List("a", "b", "c", "d", "e")
val (i, seed) = Random.intBelow(5).run(0)
assertEquals(elementOf(xs).run(0), (xs(i), seed))
}
test("elementOf of a one-element array always gives that element, and still uses up a draw") {
assertEquals(elementOf(List("only")).run(0), ("only", 1831565813L))
}
test("listOf(gen, 0) is the empty list and leaves the seed alone") {
assertEquals(listOf(Random.nextUint32, 0).run(99), (List.empty[Long], 99L))
}
test("listOf threads the seed from each draw to the next") {
assertEquals(listOf(Random.nextUint32, 3).run(0), (List(1144304738L, 1416247L, 958946056L), 1199730143L))
}
test("listOf of a constant generator repeats the value and leaves the seed alone") {
assertEquals(listOf(Random.pure("x"), 4).run(7), (List("x", "x", "x", "x"), 7L))
}
test("genActions(n) gives n actions, the same ones for the same seed") {
val (actions, _) = genActions(30).run(5)
assertEquals(actions.length, 30)
assert(actions.forall {
case Action.Tick | Action.Turn(_) => true
case _ => false
})
assertEquals(genActions(30).run(5), genActions(30).run(5))
}
// ---- forAll
test("forAll reports success with the number of runs") {
assertEquals(forAll(Random.intBelow(10), 200, 1)(x => x >= 0 && x < 10), CheckResult.Passed(200))
}
test("forAll with zero runs succeeds without drawing anything") {
assertEquals(forAll(Random.nextUint32, 0, 1)(_ => false), CheckResult.Passed(0))
}
test("forAll reports the first failing draw, its value and the seed it started from") {
val gen = Random.intBelow(10)
val prop = (x: Int) => x != 7
val (draws, _) = listOf(gen, 200).run(3)
val first = draws.indexWhere(x => !prop(x))
assert(first > 0, "the test needs a failure that is not the very first draw")
forAll(gen, 200, 3)(prop) match
case CheckResult.Failed(run, seed, value) =>
assertEquals((run, value), (first, 7))
assertEquals(gen.run(seed)._1, 7)
case other => fail(s"expected a failure, got $other")
}
test("forAll stops at the first failure: the property is not asked about later values") {
val gen = Random.intBelow(10)
val (draws, _) = listOf(gen, 200).run(3)
val first = draws.indexWhere(_ == 7)
var calls = 0 // test code may count; the code under test may not
val result = forAll(gen, 200, 3) { x => calls = calls + 1; x != 7 }
result match
case CheckResult.Failed(run, _, _) => assertEquals(run, first)
case other => fail(s"expected a failure, got $other")
assertEquals(calls, first + 1)
}
test("forAll is reproducible: same generator, property, runs and seed give the same report") {
assertEquals(forAll(Random.intBelow(1000), 500, 11)(_ % 97 != 0), forAll(Random.intBelow(1000), 500, 11)(_ % 97 != 0))
}
// ---- shrinking
test("removals lists every way to drop one element, in order") {
assertEquals(removals(List(1, 2, 3)), List(List(2, 3), List(1, 3), List(1, 2)))
}
test("removals of an empty list is empty, and of a single element is one empty list") {
assertEquals(removals(List.empty[Int]), List.empty[List[Int]])
assertEquals(removals(List("a")), List(List.empty[String]))
}
test("minimize keeps exactly the elements the failure needs") {
assertEquals(minimize(List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))(xs => xs.contains(3) && xs.contains(7)), List(3, 7))
}
test("minimize keeps the order and the duplicates the failure needs") {
assertEquals(
minimize(List("b", "a", "c", "a", "d", "a"))(xs => xs.count(_ == "a") >= 2 && xs.contains("c")),
List("c", "a", "a")
)
}
test("minimize of a list that fails whatever you remove is the empty list") {
assertEquals(minimize(List(1, 2, 3))(_ => true), List.empty[Int])
}
test("minimize returns a list that still fails and has no failing removal (1-minimal)") {
val fails = (xs: List[Int]) => xs.sum >= 10
val small = minimize(List(5, 1, 4, 2, 6, 3))(fails)
assert(fails(small))
assert(removals(small).forall(ys => !fails(ys)))
}
// ---- invariants
cases("m12-violations").foreach { c =>
test(s"violations: ${c("name").str}") {
assertEquals(violations(gameState(c("args")(0))), list(c("expected"))(_.str))
}
}
// ---- moveGaps
// A whole state whose snake has its head at (x, y); moveGaps only looks at the head.
private def at(x: Int, y: Int): GameState =
GameState(20, 20, List(Point(x, y), Point(x - 1, y)), Direction.Right, Direction.Right, None, 0L, 0, Status.Playing,
0, None)
test("moveGaps measures the distance between consecutive moves of the head") {
assertEquals(moveGaps(List(at(1, 0), at(1, 0), at(2, 0), at(2, 0), at(2, 0), at(3, 0), at(3, 1))), List(3, 1))
}
test("moveGaps of fewer than two moves is empty") {
assertEquals(moveGaps(Nil), List.empty[Int])
assertEquals(moveGaps(List(at(1, 0))), List.empty[Int])
assertEquals(moveGaps(List(at(1, 0), at(1, 0), at(2, 0), at(2, 0))), List.empty[Int])
}
test("moveGaps counts a move at index 1") {
assertEquals(moveGaps(List(at(1, 0), at(2, 0), at(2, 0), at(3, 0))), List(2))
}
test("moveGaps treats an empty snake as a head of its own") {
def withSnake(snake: List[Point]): GameState = at(0, 0).copy(snake = snake)
val states = List(Nil, Nil, List(Point(1, 0)), List(Point(1, 0)), List(Point(2, 0))).map(withSnake)
assertEquals(moveGaps(states), List(2))
}
// ---- the payoff: properties of the game, and deliberately broken games ("mutants") that they must catch
private type Update = (GameState, Action) => GameState
// Every state of the game played with `upd` from `start`. (For the real `update` this is `history`.)
private def statesWith(upd: Update, start: GameState, actions: List[Action]): List[GameState] =
actions.scanLeft(start)(upd)
private def sane(upd: Update)(actions: List[Action]): Boolean =
statesWith(upd, Spawn.newGame(1), actions).forall(s => violations(s).isEmpty)
test("statesWith(update, ...) in these tests is the history of Module 5") {
val (actions, _) = genActions(25).run(9)
assertEquals(statesWith(Game.update, Spawn.newGame(1), actions), Game.history(Spawn.newGame(1), actions))
}
test("the real game keeps every invariant on 300 random games of 40 actions") {
assertEquals(forAll(genActions(40), 300, 2024)(sane(Game.update)), CheckResult.Passed(300))
}
// Mutant 1: `turn` checks against nextDir instead of dir (version A of Puzzle 2 in Module 3).
private def turnAgainstNextDir(state: GameState, action: Action): GameState = action match
case Action.Turn(dir) =>
if state.status == Status.Playing && dir != Model.opposite(state.nextDir) then state.copy(nextDir = dir)
else state
case other => Game.update(state, other)
test("the quick double turn bug is found and shrunk to its two key presses") {
val prop = sane(turnAgainstNextDir)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
minimize(value)(actions => !prop(actions)) match
case List(Action.Turn(first), Action.Turn(second)) =>
assert(List(Direction.Up, Direction.Down).contains(first))
assertEquals(second, Direction.Left)
case other => fail(s"expected two turns, got $other")
case other => fail(s"expected a failure, got $other")
}
// Mutant 2: `advance` forgets to check the walls.
private def advanceWithoutWalls(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 body = if eating then state.snake else Util.dropLast(state.snake)
if Util.contains(body, head, _ == _) then state.copy(dir = state.nextDir, status = Status.GameOver)
else
state.copy(
snake = head :: body,
dir = state.nextDir,
apple = if eating then None else state.apple,
score = state.score + (if eating then 1 else 0)
)
// What `update` does after a step since Exercise 10.6: take the queued turn. The mutants below that replace `Tick`
// do the same, so that each one differs from the real game in exactly one thing.
private def takeQueued(s: GameState): GameState = s.queued match
case Some(dir) if s.status == Status.Playing => s.copy(nextDir = dir, queued = None)
case _ => s
private def noWalls(state: GameState, action: Action): GameState = action match
case Action.Tick => takeQueued(Spawn.spawnApple(advanceWithoutWalls(state)))
case other => Game.update(state, other)
test("a game without walls is found and shrunk to the shortest way off the board: ten ticks") {
val prop = sane(noWalls)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
assertEquals(minimize(value)(actions => !prop(actions)), List.fill(10)(Action.Tick))
case other => fail(s"expected a failure, got $other")
}
// Mutant 4: a `Tick` that forgets the turn queue of Exercise 10.6 (the `Frame` step still takes it).
private def tickForgetsQueue(state: GameState, action: Action): GameState = action match
case Action.Tick => Spawn.tick(state)
case other => Game.update(state, other)
test("a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions") {
val prop = sane(tickForgetsQueue)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
assertEquals(
minimize(value)(actions => !prop(actions)),
List(Action.Turn(Direction.Up), Action.Turn(Direction.Right), Action.Tick, Action.Turn(Direction.Left))
)
case other => fail(s"expected a failure, got $other")
}
// Mutant 5: the queue accepts any key that differs from the pending turn, even the one straight back from it.
private def queueAnyKey(state: GameState, action: Action): GameState = action match
case Action.Turn(dir) if state.status == Status.Playing && state.nextDir != state.dir && dir != state.nextDir =>
state.copy(queued = Some(dir))
case other => Game.update(state, other)
test("a queue that accepts a reversal is found and shrunk to its two key presses") {
val prop = sane(queueAnyKey)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
assertEquals(
minimize(value)(actions => !prop(actions)),
List(Action.Turn(Direction.Up), Action.Turn(Direction.Down))
)
case other => fail(s"expected a failure, got $other")
}
// Mutant 3: the frame rule this course used at first: step whenever the *total* frame count is a multiple of movesEvery.
private def totalFrameCount(state: GameState, action: Action): GameState = action match
case Action.Frame =>
if state.status != Status.Playing then state
else
val counted = state.copy(frame = state.frame + 1)
if counted.frame % Game.movesEvery(counted.score) == 0 then Spawn.tick(counted) else counted
case other => Game.update(state, other)
// A long corridor: the snake is one step from its third apple (score 2 -> 3, so the speed changes), `frame` frames into the game.
private def corridor(frame: Int): GameState =
GameState(60, 1, List(Point(1, 0), Point(0, 0)), Direction.Right, Direction.Right, Some(Point(2, 0)), 0L, 2,
Status.Playing, frame, None)
private val frames = List.fill(40)(Action.Frame)
private def steady(upd: Update)(frame: Int): Boolean =
val states = statesWith(upd, corridor(frame), frames)
val scoreAtMove = states.zip(states.drop(1)).collect {
case (before, after) if after.snake.head != before.snake.head => after.score
}
moveGaps(states).zip(scoreAtMove).forall((gap, score) => gap == Game.movesEvery(score))
test("the real game moves exactly every movesEvery(score) frames, also across a speed-up") {
assertEquals(forAll(Random.intBelow(8), 100, 7)(steady(Game.update)), CheckResult.Passed(100))
}
test("counting frames from the start of the game makes some steps too short, and the property finds one") {
forAll(Random.intBelow(56), 100, 7)(steady(totalFrameCount)) match
case CheckResult.Failed(_, _, value) => assertEquals(steady(totalFrameCount)(value), false)
case other => fail(s"expected a failure, got $other")
}
// ---- random states (Exercise 12.6)
private def connected(snake: List[Point]): Boolean =
snake.zip(snake.drop(1)).forall((a, b) => math.abs(b.x - a.x) + math.abs(b.y - a.y) == 1)
private def board(width: Int, height: Int): GameState = Model.initialState(0).copy(width = width, height = height)
test("genSnake draws the head first, then walks from the tail (seed 0, 5x5, at most 6 cells)") {
assertEquals(
genSnake(5, 5, 6).run(0),
(List(Point(3, 2), Point(3, 1), Point(3, 0), Point(2, 0), Point(1, 0), Point(0, 0)), 4231026099L)
)
}
test("genSnake is pinned for a few more seeds") {
assertEquals(
genSnake(5, 5, 6).run(1)._1,
List(Point(2, 3), Point(3, 3), Point(4, 3), Point(4, 4), Point(3, 4), Point(2, 4))
)
assertEquals(
genSnake(5, 5, 6).run(42)._1,
List(Point(1, 0), Point(1, 1), Point(2, 1), Point(2, 0), Point(3, 0), Point(3, 1))
)
}
test("genSnake with maxLen 1 is just the head, drawn with two draws") {
val (x, s1) = Random.intBelow(5).run(0)
val (y, s2) = Random.intBelow(5).run(s1)
assertEquals(genSnake(5, 5, 1).run(0), (List(Point(x, y)), s2))
}
test("genSnake stops early when the walk is stuck: a 1x1 board holds one cell") {
assertEquals(genSnake(1, 1, 5).run(3)._1, List(Point(0, 0)))
}
test("genSnake always gives a snake on the board, connected, without overlap, at most maxLen long") {
assertEquals(
forAll(genSnake(4, 3, 8), 300, 5) { snake =>
snake.length >= 1 && snake.length <= 8 && connected(snake)
&& snake.forall(p => Rules.inBounds(board(4, 3), p))
&& snake.distinct.length == snake.length
},
CheckResult.Passed(300)
)
}
test("every generated state is sane: 500 states of a 5x5 board have no violations") {
assertEquals(forAll(genState(5, 5), 500, 2024)(s => violations(s).isEmpty), CheckResult.Passed(500))
}
test("generated states are Playing, have nothing queued, and vary in length and apple") {
val (states, _) = listOf(genState(5, 5), 500).run(2024)
assert(states.forall(s => s.status == Status.Playing && s.queued.isEmpty && s.width == 5 && s.height == 5))
assert(states.forall(s =>
if s.snake.length == 1 then s.dir == Direction.Right else Rules.move(s.snake(1), s.dir) == s.snake.head
))
assert(states.exists(_.snake.length == 1) && states.exists(_.snake.length >= 10))
assert(states.exists(_.apple.isEmpty) && states.exists(_.apple.isDefined))
assert(states.exists(s => s.nextDir != s.dir))
}
test("generated states start with seed 0, score 0 and frame 0") {
val (states, _) = listOf(genState(5, 5), 50).run(2024)
assert(states.forall(s => s.seed == 0L && s.score == 0 && s.frame == 0))
}
// ---- a second implementation as an oracle (Exercise 12.7)
cases("m03-advance").foreach { c =>
test(s"advanceSpec agrees with Module 3's advance fixture: ${c("name").str}") {
assertEquals(advanceSpec(gameState(c("args")(0))), gameState(c("expected")))
}
}
test("advance and advanceSpec agree on 500 generated states of a 5x5 board") {
assertEquals(forAll(genState(5, 5), 500, 2024)(s => Rules.advance(s) == advanceSpec(s)), CheckResult.Passed(500))
}
// Mutant 6: Puzzle 1 of Module 3, "the new head must not be on the old snake" (it forgets that the tail moves away).
private def advanceOldSnakeCheck(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 body = if eating then state.snake else Util.dropLast(state.snake)
if !Rules.inBounds(state, head) || Util.contains(state.snake, head, _ == _) then
state.copy(dir = state.nextDir, status = Status.GameOver)
else
state.copy(
snake = head :: body,
dir = state.nextDir,
apple = if eating then None else state.apple,
score = state.score + (if eating then 1 else 0)
)
test("no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane") {
assertEquals(
forAll(genState(5, 5), 500, 2024)(s => violations(advanceOldSnakeCheck(s)).isEmpty),
CheckResult.Passed(500)
)
}
test("comparing with advanceSpec catches the wrong collision check: the head was moving onto the tail") {
forAll(genState(5, 5), 500, 2024)(s => advanceOldSnakeCheck(s) == advanceSpec(s)) match
case CheckResult.Failed(run, _, s) =>
assertEquals(run, 21)
assertEquals(Rules.move(s.snake.head, s.nextDir), s.snake.last)
assertEquals(advanceOldSnakeCheck(s).status, Status.GameOver)
assertEquals(advanceSpec(s).status, Status.Playing)
case other => fail(s"expected a failure, got $other")
}
+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))
}