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)) }