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:
co-authored by
Claude Opus 5.5
parent
bd0c9fcc0c
commit
c6ecb7c013
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user