Files

131 lines
5.2 KiB
Scala

package snake
package pong
import Fixtures.*
import PongModel.*
import PongRules.{pongUpdate, clamp}
import PongView.pongView
import PongApp.*
import M09Helpers.drawn
class M11PongSuite extends munit.FunSuite:
private def pongState(v: ujson.Value): PongState = PongState(
ball = point(v("ball")),
vel = Velocity(v("vel")("dx").num.toInt, v("vel")("dy").num.toInt),
player = v("player").num.toInt,
cpu = v("cpu").num.toInt,
held = Held(v("held")("up").bool, v("held")("down").bool),
score = Score(v("score")("player").num.toInt, v("score")("cpu").num.toInt),
status = PongStatus.valueOf(v("status").str),
seed = v("seed").num.toLong,
frame = v("frame").num.toInt
)
// `Paddle` only has Up and Down, so a JSON key like "constructor" decodes to None instead of crashing.
private def paddle(v: ujson.Value): Option[Paddle] = Paddle.values.find(_.toString == v.str)
/** Decode a PongAction; `None` when its key is not a `Paddle` (such an action cannot even be built in Scala). */
private def pongAction(v: ujson.Value): Option[PongAction] = v("tag").str match
case "Press" => paddle(v("key")).map(PongAction.Press(_))
case "Release" => paddle(v("key")).map(PongAction.Release(_))
case "Frame" => Some(PongAction.Frame)
case "Restart" => Some(PongAction.Restart)
case other => throw new IllegalArgumentException(s"unknown pong action tag: $other")
cases("m11-pong-update").foreach { c =>
test(s"pongUpdate: ${c("name").str}") {
val a = c("args")
val state = pongState(a(0))
pongAction(a(1)) match
case Some(action) => assertEquals(pongUpdate(state, action), pongState(c("expected")))
// An unknown key is ruled out by the types: the Paddle enum has no such case. What the
// fixture asks for (state unchanged) therefore holds by construction; check that it says so.
case None => assertEquals(pongState(c("expected")), state)
}
}
cases("m11-pong-view").foreach { c =>
test(s"pongView: ${c("name").str}") {
assertEquals(pongView(pongState(c("args")(0))), list(c("expected"))(shape))
}
}
cases("m11-pong-key").foreach { c =>
test(s"pongKey: ${c("name").str}") {
val a = c("args")
val expected = opt(c("expected"))(e => pongAction(e).getOrElse(fail(s"undecodable expected action: $e")))
assertEquals(pongKey(a(0).bool, a(1).str), expected)
}
}
private def time(t: Long): Event = Event.Time(t)
private def down(key: String): Event = Event.KeyDown(key)
private def up(key: String): Event = Event.KeyUp(key)
test("the court is 80 by 50 cells of 8 pixels, with 8-cell paddles") {
assertEquals(List(W, H, PCELL, PADDLE, PLAYER_X, CPU_X, WIN), List(80, 50, 8, 8, 2, 77, 5))
}
test("initialPong puts the ball in the middle, both paddles centred, and nobody ahead") {
assertEquals(
initialPong(5),
PongState(Point(40, 25), Velocity(1, 1), 21, 21, Held(false, false), Score(0, 0), PongStatus.Playing, 5, 0)
)
}
test("clamp keeps a number inside [lo, hi]") {
assertEquals(List(clamp(0, 42, -1), clamp(0, 42, 17), clamp(0, 42, 43)), List(0, 17, 42))
}
test("pongUpdate does not modify the state it is given") {
val s = initialPong(0)
pongUpdate(s, PongAction.Frame)
assertEquals(s, initialPong(0))
}
// 3000 frames with no one touching the keys: the ball must always stay on the court and the match must end.
test("an idle match stays on the court and ends once someone reaches five") {
val states = List.fill(3000)(PongAction.Frame).scanLeft(initialPong(3))(pongUpdate)
assert(states.forall(s => s.ball.y >= 0 && s.ball.y < H && s.ball.x >= 0 && s.ball.x < W))
assert(states.forall(s => s.player >= 0 && s.player <= H - PADDLE && s.cpu >= 0 && s.cpu <= H - PADDLE))
val last = states.last
assertEquals(last.status, PongStatus.Over)
assertEquals(math.max(last.score.player, last.score.cpu), WIN)
}
test("the clock runs every 30 ms") { assertEquals(FRAME_MS_PONG, 30L) }
test("pongActions turns the clock into Frames and keys into Press, Release and Restart") {
assertEquals(
pongActions.run(List(time(30), down("ArrowUp"), up("ArrowUp"), down("s"), down("r"), up("r"), down("x"), time(90))),
List(
PongAction.Frame,
PongAction.Press(Paddle.Up),
PongAction.Release(Paddle.Up),
PongAction.Press(Paddle.Down),
PongAction.Restart,
PongAction.Frame,
PongAction.Frame
)
)
}
test("pongStates starts with the new match, then yields one state per action") {
val out = pongStates(42, pongActions).run(List(time(30), down("w"), time(60)))
assertEquals(out.length, 4)
assertEquals(out.head, initialPong(42))
assertEquals(out.map(_.frame), List(0, 1, 1, 2))
assertEquals(out.map(_.held.up), List(false, false, true, true))
assertEquals(out(3).player, 20)
}
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))
val (first, next) = traced.result.step(time(0))
val (second, _) = next.step(time(30))
assertEquals(drawn(first.head), pongView(initialPong(42)))
assertEquals(drawn(second.head), pongView(pongUpdate(initialPong(42), PongAction.Frame)))
}