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-session-update").foreach { c => test(s"sessionUpdate: ${c("name").str}") { val a = c("args") assertEquals(App.sessionUpdate(session(a(0)), action(a(1))), session(c("expected"))) } } cases("m10-session-view").foreach { c => test(s"sessionView: ${c("name").str}") { assertEquals(App.sessionView(session(c("args")(0))), list(c("expected"))(shape)) } } cases("m10-input").foreach { c => test(s"keyToAction: ${c("name").str}") { assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action)) } } // ---- the turn queue (Exercise 10.6) test("steer is what update does with a Turn") { cases("m10-update").filter(_("args")(1)("tag").str == "Turn").foreach { c => val a = c("args") assertEquals(Game.steer(gameState(a(0)), direction(a(1)("dir"))), gameState(c("expected")), c("name").str) } } test("Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after") { val start = Spawn.newGame(42) // heading Right from (10, 10); the apple is far away val states = List(Action.Turn(Direction.Up), Action.Turn(Direction.Left), Action.Tick, Action.Tick) .scanLeft(start)(Game.update) assertEquals(states.drop(2).map(_.snake.head), List(Point(10, 10), Point(10, 9), Point(9, 9))) assertEquals(states.map(_.queued), List(None, None, Some(Direction.Left), None, None)) } 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)) ) } // The drawings are compared by their first shapes, the game's view: Exercise 10.7 adds the best score after it. private def assertStartsWithView(io: IO[Unit], state: GameState): Unit = val expected = View.view(state) assertEquals(drawn(io).take(expected.length), expected) test("main (seed 42) draws a moved snake after eight 25 ms frames") { val traced = IO.trace(App.main, 42) assertEquals(traced.ops.head, Op.RandomSeed) val (first, afterFirst) = traced.result.step(time(0)) assertStartsWithView(first.head, 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 = 0) assertStartsWithView(drawings(7), moved) } // ---- clampTime: a long silence from the clock (a background tab) must not turn into hundreds of frames private def down(key: String): Event = Event.KeyDown(key) test("clampTime passes small steps of time through unchanged") { assertEquals(Stream.interval(10).clampTime(100).run(List(5L, 10L, 35L).map(time)), List(10L, 20L, 30L)) } test("clampTime lets a long gap count as maxGap, and carries on from there") { assertEquals(Stream.interval(10).clampTime(25).run(List(10L, 5000L, 5010L).map(time)), List(10L, 20L, 30L, 40L)) } test("clampTime clamps the very first step too (the clock starts at 0)") { assertEquals(Stream.interval(10).clampTime(25).run(List(time(1000))), List(10L, 20L)) } test("clampTime never moves the clock backwards") { assertEquals( Stream.interval(10).clampTime(100).run(List(30L, 10L, 40L).map(time)), List(10L, 20L, 30L, 40L, 50L, 60L) ) } test("clampTime passes key inputs through, and they do not disturb the clock") { assertEquals(Stream.keyDowns.clampTime(5).run(List(down("a"), time(50), down("b"))), List("a", "b")) assertEquals(Stream.interval(10).clampTime(25).run(List(time(10), down("a"), time(20))), List(10L, 20L)) } test("clampTime tells the inner stream exactly the clamped time, and a repeated time adds nothing") { assertEquals(Stream.interval(1).clampTime(100).run(List(5L, 5L, 6L).map(time)), List(1L, 2L, 3L, 4L, 5L, 6L)) } test("running the same clamped stream twice gives the same outputs") { val s = Stream.interval(10).clampTime(25) val xs = List(10L, 5000L, 5010L).map(time) assertEquals(s.run(xs), s.run(xs)) } test("the game accepts at most 250 ms of time at once") { assertEquals(App.MAX_GAP_MS, 250L) } test("main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds") { val (_, afterFirst) = IO.trace(App.main, 42).result.step(time(0)) val (drawings, next) = afterFirst.step(time(10000)) assertEquals(drawings.length.toLong, App.MAX_GAP_MS / App.FRAME_MS) assertEquals(next.step(time(10025))._1.length, 1) } // ---- a persisted high score (Exercise 10.7) private val loadOp: Op = Op.LoadBest private def drawOps(scene: Scene): List[Op] = scene.map(Op.Draw(_)) test("loadBest records a LoadBest op and is answered with the best given to trace") { assertEquals(IO.trace(IO.loadBest, 0, 17), Traced(17, List(loadOp))) } test("trace answers LoadBest with 0 when no best is given") { assertEquals(IO.trace(IO.loadBest, 9).result, 0) } test("saveBest records a SaveBest op with the score and produces ()") { assertEquals(IO.trace(IO.saveBest(7), 0), Traced((), List(Op.SaveBest(7)))) } test("a program can load the best, then save a higher one") { assertEquals(IO.trace(IO.loadBest.flatMap(b => IO.saveBest(b + 1)), 0, 4).ops, List(loadOp, Op.SaveBest(5))) } test("sessions starts with the new game and the stored best, then yields one session per action") { val out = App.sessions(42, 3, App.speedActions).run(List(time(25), Event.KeyDown(" "))) val s0 = Session(Spawn.newGame(42), 3, improved = false) val s1 = App.sessionUpdate(s0, Action.Frame) assertEquals(out, List(s0, s1, App.sessionUpdate(s1, Action.TogglePause))) } test("renderSession draws the session view and saves nothing when the best did not improve") { val s = Session(Spawn.newGame(42), 5, improved = false) assertEquals(IO.trace(App.renderSession(s), 0).ops, drawOps(App.sessionView(s))) } test("renderSession draws the session view, then saves the best, when the best improved") { val s = Session(Spawn.newGame(42).copy(score = 6), 6, improved = true) assertEquals(IO.trace(App.renderSession(s), 0).ops, drawOps(App.sessionView(s)) :+ Op.SaveBest(6)) } test("main asks for the seed, then the stored best, and draws the first session") { val traced = IO.trace(App.main, 42, 9) assertEquals(traced.ops, List(Op.RandomSeed, loadOp)) val (first, _) = traced.result.step(time(0)) assertEquals(drawn(first.head), App.sessionView(Session(Spawn.newGame(42), 9, improved = false))) } test("play passes the best to the program: the first scene shows it") { assertEquals( App.play(App.main, 42, List(time(0)), 9), List(drawOps(App.sessionView(Session(Spawn.newGame(42), 9, improved = false)))) ) } // The golden fixture: a seed, a scripted list of inputs, a stored best, and the scene of the last drawing. cases("m10-golden").foreach { c => val a = c("args") def played = App.play(App.main, a(0).num.toLong, list(a(1))(event), a(2).num.toInt) val expected = list(c("expected"))(shape) test(s"play(main): ${c("name").str}") { assertEquals(played.last.collect { case Op.Draw(shape) => shape }, expected) } test(s"play(main) saves once per improvement: ${c("name").str}") { val best = a(2).num.toInt val saves = played.flatten.collect { case Op.SaveBest(score) => score } val finalBest = expected.collectFirst { case Shape.Text(_, _, t, _, _, _) if t.startsWith("Best: ") => t.drop(6).toInt }.get assertEquals(saves, ((best + 1) to finalBest).toList) } } // The runtime with a fake platform (M09Runtime.test.scala) that records the two new calls. test("runIO answers LoadBest with platform.loadBest() and continues with it") { val p = FakePlatform(42, 11) assertEquals(Runtime.runIO(IO.loadBest.map(_ + 1), p), 12) assertEquals(p.calls.toList, List(List("loadBest"))) } test("runIO maps SaveBest to platform.saveBest(score)") { val p = FakePlatform() Runtime.runIO(IO.saveBest(8).flatMap(_ => IO.pure("done")), p) assertEquals(p.calls.toList, List(List("saveBest", 8))) } test("runApp asks for the seed and loads the best once, at start, and draws nothing before the first input") { val p = FakePlatform(42, 3) Runtime.runApp(App.main, p) assertEquals(p.calls.toList, List(List("randomSeed"), List("loadBest"))) p.fireTime(0) p.fireTime(25) assertEquals(p.calls.count(_.head == "loadBest"), 1) assert(p.calls.exists(c => c.head == "fillText" && c(3) == "Best: 3")) }