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