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