feat(scala): m09 input/app/runtime and m10 extensions with tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-29 00:13:52 +01:00
co-authored by Claude Opus 5.5
parent bd0c9fcc0c
commit c6ecb7c013
13 changed files with 448 additions and 6 deletions
+56
View File
@@ -0,0 +1,56 @@
package snake
import M09Helpers.{classicMain, drawn}
class M09AppSuite extends munit.FunSuite:
private def time(t: Long): Event = Event.Time(t)
private def down(key: String): Event = Event.KeyDown(key)
test("the game ticks every 150 ms") { assertEquals(App.TICK_MS, 150L) }
test("classicActions turns the clock into Tick actions and keys into Turn actions") {
assertEquals(
App.classicActions.run(List(time(150), down("ArrowUp"), down("x"), time(300))),
List(Action.Tick, Action.Turn(Direction.Up), Action.Tick)
)
}
test("states starts with the new game, then yields one state per action") {
val out = App.states(42, App.classicActions).run(List(time(150), down("ArrowUp"), time(300)))
assertEquals(out.length, 4)
assertEquals(out.head, Spawn.newGame(42))
assertEquals(out.map(_.snake.head), List(Point(10, 10), Point(11, 10), Point(11, 10), Point(11, 9)))
assertEquals(out.map(_.nextDir), List(Direction.Right, Direction.Right, Direction.Up, Direction.Up))
assertEquals(out.map(_.dir), List(Direction.Right, Direction.Right, Direction.Right, Direction.Up))
}
test("render draws the whole view of the state") {
assertEquals(IO.trace(App.render(Spawn.newGame(42)), 0).ops, View.view(Spawn.newGame(42)).map(Op.Draw(_)))
}
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))
val (outs, _) = traced.result.step(time(0))
assertEquals(outs.length, 1)
assertEquals(drawn(outs.head), View.view(Spawn.newGame(42)))
}
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
test("the program built from classicActions draws the new game first, then the ticked game after 150 ms") {
val stream = IO.trace(classicMain, 42).result
val (first, next) = stream.step(time(0))
val (second, _) = next.step(time(150))
assertEquals(drawn(first.head), View.view(Spawn.newGame(42)))
assertEquals(second.length, 1)
assertNotEquals(drawn(second.head), drawn(first.head))
}
// Holds before and after m10: whatever `main` ticks with, the clock must be wired in, so time passing produces more drawings.
test("main draws again once time has passed") {
val stream = IO.trace(App.main, 42).result
val (first, next) = stream.step(time(0))
val (later, _) = next.step(time(150))
assertEquals(first.length, 1)
assert(later.nonEmpty)
}
+11
View File
@@ -0,0 +1,11 @@
package snake
/** Test helpers shared by the m09 suites. */
object M09Helpers:
/** The m09 program built from classicActions (ticks every 150 ms). The shipped `main` switches to speedActions in m10. */
lazy val classicMain: IO[Stream[IO[Unit]]] =
IO.randomSeed.map(seed => App.states(seed, App.classicActions).map(App.render))
/** The shapes an IO draws, in order (interpreted by `IO.trace`). */
def drawn(io: IO[Unit]): List[Shape] = IO.trace(io, 0).ops.collect { case Op.Draw(shape) => shape }
+10
View File
@@ -0,0 +1,10 @@
package snake
import Fixtures.*
class M09InputSuite extends munit.FunSuite:
cases("m09-input").foreach { c =>
test(s"keyToAction: ${c("name").str}") {
assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action))
}
}
+112
View File
@@ -0,0 +1,112 @@
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:
val calls: ListBuffer[List[Any]] = ListBuffer.empty
private var onTime: Long => Unit = _ => ()
private var onDown: String => Unit = _ => ()
private var onUp: String => Unit = _ => ()
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 =
calls += List("fillText", x, y, text, color, size, align)
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 fireTime(t: Long): Unit = onTime(t)
def fireKey(key: String): Unit = onDown(key)
def fireKeyUp(key: String): Unit = onUp(key)
class M09RuntimeSuite extends munit.FunSuite:
// The m09 program (classicMain) ticks every 150 ms; the shipped `main` uses speedActions after m10.
private def main = M09Helpers.classicMain
// What the platform should have been asked to draw for a scene.
private def expectedCalls(scene: Scene): List[List[Any]] = scene.map {
case Shape.Rect(x, y, w, h, color) => List("fillRect", x, y, w, h, color)
case Shape.Text(x, y, text, color, size, align) => List("fillText", x, y, text, color, size, align)
}
private val rect = Shape.Rect(1, 2, 3, 4, "#abcdef")
private val text = Shape.Text(5, 6, "hi", "#fff", 16, "center")
test("runIO of a pure value returns it without touching the platform") {
val p = FakePlatform()
assertEquals(Runtime.runIO(IO.pure(7), p), 7)
assertEquals(p.calls.toList, Nil)
}
test("runIO maps Draw Rect to fillRect(x, y, w, h, color)") {
val p = FakePlatform()
Runtime.runIO(IO.draw(rect), p)
assertEquals(p.calls.toList, List(List("fillRect", 1, 2, 3, 4, "#abcdef")))
}
test("runIO maps Draw Text to fillText(x, y, text, color, size, align)") {
val p = FakePlatform()
Runtime.runIO(IO.draw(text), p)
assertEquals(p.calls.toList, List(List("fillText", 5, 6, "hi", "#fff", 16, "center")))
}
test("runIO answers RandomSeed with platform.randomSeed() and continues with it") {
val p = FakePlatform(99)
assertEquals(Runtime.runIO(IO.randomSeed.map(_ + 1), p), 100L)
assertEquals(p.calls.toList, List(List("randomSeed")))
}
test("runIO performs effects in order") {
val p = FakePlatform()
Runtime.runIO(IO.sequence(List(IO.draw(rect), IO.draw(text), IO.draw(rect))), p)
assertEquals(p.calls.toList.map(_.head), List("fillRect", "fillText", "fillRect"))
}
test("runIO handles 100000 effects without overflowing the stack") {
val p = FakePlatform()
val n = 100000
Runtime.runIO(IO.sequence(List.fill(n)(IO.draw(rect))), p)
assertEquals(p.calls.length, n)
}
test("runApp asks the platform for a seed once, at start, and draws nothing before the first event") {
val p = FakePlatform()
Runtime.runApp(main, p)
assertEquals(p.calls.toList, List(List("randomSeed")))
}
test("runApp draws the initial scene on the first clock tick, then the ticked scene after 150 ms") {
val p = FakePlatform(42)
Runtime.runApp(main, p)
p.calls.clear()
p.fireTime(150)
assertEquals(
p.calls.toList,
expectedCalls(View.view(Spawn.newGame(42))) ++ expectedCalls(View.view(Spawn.tick(Spawn.newGame(42))))
)
}
test("runApp feeds key presses to the game") {
val p = FakePlatform(42)
Runtime.runApp(main, p)
p.fireTime(0)
p.calls.clear()
p.fireKey("ArrowUp")
p.fireTime(150)
val turned = Spawn.newGame(42).copy(nextDir = Direction.Up)
assertEquals(p.calls.toList, expectedCalls(View.view(turned)) ++ expectedCalls(View.view(Spawn.tick(turned))))
}
test("runApp ignores key releases and unmapped keys (no redraw)") {
val p = FakePlatform(42)
Runtime.runApp(main, p)
p.fireTime(0)
p.calls.clear()
p.fireKeyUp("ArrowUp")
p.fireKey("x")
assertEquals(p.calls.toList, Nil)
}
+52
View File
@@ -0,0 +1,52 @@
package snake
import Fixtures.*
import M09Helpers.drawn
class M10ExtensionsSuite extends munit.FunSuite:
cases("m10-update").foreach { c =>
test(s"update: ${c("name").str}") {
val a = c("args")
assertEquals(Game.update(gameState(a(0)), action(a(1))), gameState(c("expected")))
}
}
cases("m10-moves-every").foreach { c =>
test(s"movesEvery: ${c("name").str}") {
assertEquals(Game.movesEvery(c("args")(0).num.toInt), c("expected").num.toInt)
}
}
cases("m10-input").foreach { c =>
test(s"keyToAction: ${c("name").str}") {
assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action))
}
}
private def time(t: Long): Event = Event.Time(t)
private val frameTimes = List(25L, 50L, 75L, 100L, 125L, 150L, 175L, 200L)
test("the animation clock runs every 25 ms") { assertEquals(App.FRAME_MS, 25L) }
test("speedActions turns the clock into Frame actions every 25 ms") {
assertEquals(App.speedActions.run(frameTimes.map(time)), List.fill(8)(Action.Frame))
}
test("speedActions still turns keys into actions, including pause and restart") {
assertEquals(
App.speedActions.run(List(Event.KeyDown(" "), Event.KeyDown("r"), Event.KeyDown("ArrowUp"))),
List(Action.TogglePause, Action.Restart, Action.Turn(Direction.Up))
)
}
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))
val (first, afterFirst) = traced.result.step(time(0))
assertEquals(drawn(first.head), View.view(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))
}