feat(scala): m01–m06 port with shared fixtures
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
045029639e
commit
1e186733d1
@@ -2,8 +2,7 @@ package snake
|
||||
|
||||
import java.nio.file.{Files, Paths}
|
||||
|
||||
/** Loads shared fixtures from ../fixtures/<name>.json.
|
||||
* Typed decoders (point, direction, status, action, state, opt) arrive with the model (Task 11). */
|
||||
/** Loads shared fixtures from ../fixtures/<name>.json and decodes them into the course's types. */
|
||||
object Fixtures:
|
||||
private val dir = Paths.get(sys.props.getOrElse("user.dir", ".")).resolve("..").resolve("fixtures").normalize
|
||||
|
||||
@@ -16,3 +15,38 @@ object Fixtures:
|
||||
/** Decode a nullable JSON value into an Option. */
|
||||
def opt[A](v: ujson.Value)(f: ujson.Value => A): Option[A] =
|
||||
if v.isNull then None else Some(f(v))
|
||||
|
||||
def point(v: ujson.Value): Point = Point(v("x").num.toInt, v("y").num.toInt)
|
||||
|
||||
def direction(v: ujson.Value): Direction = Direction.valueOf(v.str)
|
||||
|
||||
def status(v: ujson.Value): Status = Status.valueOf(v.str)
|
||||
|
||||
def action(v: ujson.Value): Action = v("tag").str match
|
||||
case "Tick" => Action.Tick
|
||||
case "Turn" => Action.Turn(direction(v("dir")))
|
||||
case "TogglePause" => Action.TogglePause
|
||||
case "Restart" => Action.Restart
|
||||
case "Frame" => Action.Frame
|
||||
case other => throw new IllegalArgumentException(s"unknown action tag: $other")
|
||||
|
||||
def gameState(v: ujson.Value): GameState = GameState(
|
||||
width = v("width").num.toInt,
|
||||
height = v("height").num.toInt,
|
||||
snake = v("snake").arr.toList.map(point),
|
||||
dir = direction(v("dir")),
|
||||
nextDir = direction(v("nextDir")),
|
||||
apple = opt(v("apple"))(point),
|
||||
seed = v("seed").num.toLong,
|
||||
score = v("score").num.toInt,
|
||||
status = status(v("status")),
|
||||
frame = v("frame").num.toInt
|
||||
)
|
||||
|
||||
def shape(v: ujson.Value): Shape = v("tag").str match
|
||||
case "Rect" => Shape.Rect(v("x").num.toInt, v("y").num.toInt, v("w").num.toInt, v("h").num.toInt, v("color").str)
|
||||
case "Text" =>
|
||||
Shape.Text(v("x").num.toInt, v("y").num.toInt, v("text").str, v("color").str, v("size").num.toInt, v("align").str)
|
||||
case other => throw new IllegalArgumentException(s"unknown shape tag: $other")
|
||||
|
||||
def list[A](v: ujson.Value)(f: ujson.Value => A): List[A] = v.arr.toList.map(f)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package snake
|
||||
|
||||
class M01UtilSuite extends munit.FunSuite:
|
||||
test("range(0) is empty") { assertEquals(Util.range(0), Nil) }
|
||||
test("range(3) counts from zero up to n-1") { assertEquals(Util.range(3), List(0, 1, 2)) }
|
||||
|
||||
test("dropLast of an empty list is empty") { assertEquals(Util.dropLast(List.empty[Int]), Nil) }
|
||||
test("dropLast removes only the last element") { assertEquals(Util.dropLast(List(1, 2, 3)), List(1, 2)) }
|
||||
test("dropLast leaves its input untouched") {
|
||||
val xs = List(1, 2, 3)
|
||||
assertEquals(Util.dropLast(xs), List(1, 2))
|
||||
assertEquals(xs, List(1, 2, 3))
|
||||
}
|
||||
|
||||
private val sameParity = (a: Int, b: Int) => a % 2 == b % 2
|
||||
test("contains finds an element using the supplied equality") {
|
||||
assertEquals(Util.contains(List(1, 2, 3), 5, sameParity), true)
|
||||
}
|
||||
test("contains is false when no element is equal") { assertEquals(Util.contains(List(2, 4), 3, sameParity), false) }
|
||||
test("contains is false for an empty list") { assertEquals(Util.contains(List.empty[Int], 1, _ == _), false) }
|
||||
test("contains compares structurally when given a structural equality") {
|
||||
assertEquals(Util.contains(List(Point(1, 2)), Point(1, 2), _ == _), true)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package snake
|
||||
|
||||
import Fixtures.*
|
||||
|
||||
class M02ModelSuite extends munit.FunSuite:
|
||||
cases("m02-delta").foreach { c =>
|
||||
test(s"delta: ${c("name").str}") {
|
||||
assertEquals(Model.delta(direction(c("args")(0))), point(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m02-opposite").foreach { c =>
|
||||
test(s"opposite: ${c("name").str}") {
|
||||
assertEquals(Model.opposite(direction(c("args")(0))), direction(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
// pointEquals has no Scala function: case classes already compare structurally with `==`.
|
||||
// The shared fixture is kept so every track checks the same four situations.
|
||||
cases("m02-point-equals").foreach { c =>
|
||||
test(s"pointEquals (case-class ==): ${c("name").str}") {
|
||||
val a = point(c("args")(0))
|
||||
val b = point(c("args")(1))
|
||||
assertEquals(a == b, c("expected").bool)
|
||||
}
|
||||
}
|
||||
|
||||
cases("m02-initial-state").foreach { c =>
|
||||
test(s"initialState: ${c("name").str}") {
|
||||
assertEquals(Model.initialState(c("args")(0).num.toLong), gameState(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
test("Point holds its coordinates") {
|
||||
val p = Point(3, 4)
|
||||
assertEquals((p.x, p.y), (3, 4))
|
||||
}
|
||||
|
||||
test("opposite is its own inverse") {
|
||||
Direction.values.foreach(d => assertEquals(Model.opposite(Model.opposite(d)), d))
|
||||
}
|
||||
|
||||
test("Action.Turn carries the direction") {
|
||||
assertEquals(Action.Turn(Direction.Left), Action.Turn(Direction.Left))
|
||||
assertNotEquals(Action.Turn(Direction.Left), Action.Turn(Direction.Right))
|
||||
Action.Turn(Direction.Left) match
|
||||
case Action.Turn(d) => assertEquals(d, Direction.Left)
|
||||
case other => fail(s"expected Turn, got $other")
|
||||
}
|
||||
|
||||
test("the other Actions are distinct constants") {
|
||||
val all = List(Action.Tick, Action.TogglePause, Action.Restart, Action.Frame)
|
||||
assertEquals(all.distinct.length, 4)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package snake
|
||||
|
||||
import Fixtures.*
|
||||
|
||||
class M03RulesSuite extends munit.FunSuite:
|
||||
cases("m03-move").foreach { c =>
|
||||
test(s"move: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
assertEquals(Rules.move(point(a(0)), direction(a(1))), point(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m03-in-bounds").foreach { c =>
|
||||
test(s"inBounds: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
assertEquals(Rules.inBounds(gameState(a(0)), point(a(1))), c("expected").bool)
|
||||
}
|
||||
}
|
||||
|
||||
cases("m03-turn").foreach { c =>
|
||||
test(s"turn: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
assertEquals(Rules.turn(gameState(a(0)), direction(a(1))), gameState(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m03-advance").foreach { c =>
|
||||
test(s"advance: ${c("name").str}") {
|
||||
assertEquals(Rules.advance(gameState(c("args")(0))), gameState(c("expected")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package snake
|
||||
|
||||
import Fixtures.*
|
||||
|
||||
class M04RandomSuite extends munit.FunSuite:
|
||||
// Same values as the JS and F# tracks: if mulberry32 drifts (signed math, missing unsigned conversion), these fail.
|
||||
cases("m04-random").foreach { c =>
|
||||
test(s"nextUint32: ${c("name").str}") {
|
||||
val e = c("expected").arr
|
||||
assertEquals(Random.nextUint32.run(c("args")(0).num.toLong), (e(0).num.toLong, e(1).num.toLong))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m04-int-below").foreach { c =>
|
||||
test(s"intBelow: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
val e = c("expected").arr
|
||||
assertEquals(Random.intBelow(a(0).num.toInt).run(a(1).num.toLong), (e(0).num.toInt, e(1).num.toLong))
|
||||
}
|
||||
}
|
||||
|
||||
test("the recorded steps chain: each step starts from the previous step's seed") {
|
||||
val cs = cases("m04-random")
|
||||
cs.zipWithIndex.foreach { (c, i) =>
|
||||
if !c("name").str.endsWith("-step-1") then
|
||||
assertEquals(c("args")(0).num.toLong, cs(i - 1)("expected")(1).num.toLong)
|
||||
}
|
||||
}
|
||||
|
||||
test("pure returns its value and leaves the seed alone") {
|
||||
assertEquals(Random.pure("x").run(99L), ("x", 99L))
|
||||
}
|
||||
|
||||
test("map applies f to the value and keeps the seed from the underlying step") {
|
||||
val (v, s) = Random.nextUint32.run(5L)
|
||||
assertEquals(Random.nextUint32.map(_ + 1).run(5L), (v + 1, s))
|
||||
}
|
||||
|
||||
test("map with the identity function changes nothing") {
|
||||
assertEquals(Random.nextUint32.map(identity).run(5L), Random.nextUint32.run(5L))
|
||||
}
|
||||
|
||||
test("flatMap threads the seed through both steps") {
|
||||
val (a, s1) = Random.nextUint32.run(5L)
|
||||
val (b, s2) = Random.nextUint32.run(s1)
|
||||
val both = Random.nextUint32.flatMap(x => Random.nextUint32.map(y => (x, y)))
|
||||
assertEquals(both.run(5L), ((a, b), s2))
|
||||
}
|
||||
|
||||
test("flatMap with pure on the left or right changes nothing") {
|
||||
assertEquals(
|
||||
Random.pure(3L).flatMap(x => Random.nextUint32.map(_ + x)).run(5L),
|
||||
Random.nextUint32.map(_ + 3L).run(5L)
|
||||
)
|
||||
assertEquals(Random.nextUint32.flatMap(Random.pure).run(5L), Random.nextUint32.run(5L))
|
||||
}
|
||||
|
||||
test("intBelow always lands in [0, n)") {
|
||||
def draws(n: Int, seed: Long, count: Int): List[Int] =
|
||||
if count == 0 then Nil
|
||||
else
|
||||
val (v, s) = Random.intBelow(n).run(seed)
|
||||
v :: draws(n, s, count - 1)
|
||||
List(1, 2, 7, 400).foreach { n =>
|
||||
draws(n, 123L, 50).foreach(v => assert(v >= 0 && v < n, s"$v not in [0, $n)"))
|
||||
}
|
||||
}
|
||||
|
||||
test("running a Random does not mutate anything: same seed, same answer") {
|
||||
assertEquals(Random.nextUint32.run(8L), Random.nextUint32.run(8L))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package snake
|
||||
|
||||
import Fixtures.*
|
||||
|
||||
class M04SpawnSuite extends munit.FunSuite:
|
||||
cases("m04-free-cells").foreach { c =>
|
||||
test(s"freeCells: ${c("name").str}") {
|
||||
assertEquals(Spawn.freeCells(gameState(c("args")(0))), list(c("expected"))(point))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m04-spawn").foreach { c =>
|
||||
test(s"spawnApple: ${c("name").str}") {
|
||||
assertEquals(Spawn.spawnApple(gameState(c("args")(0))), gameState(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m04-new-game").foreach { c =>
|
||||
test(s"newGame: ${c("name").str}") {
|
||||
assertEquals(Spawn.newGame(c("args")(0).num.toLong), gameState(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m04-tick").foreach { c =>
|
||||
test(s"tick: ${c("name").str}") {
|
||||
assertEquals(Spawn.tick(gameState(c("args")(0))), gameState(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
// 3x2 board, one-cell snake, about to eat the apple at (1,0)
|
||||
private val eating = GameState(3, 2, List(Point(0, 0)), Direction.Right, Direction.Right,
|
||||
Some(Point(1, 0)), 0L, 0, Status.Playing, 0)
|
||||
|
||||
test("tick: eating the apple spawns a new one in the same step") {
|
||||
// snake becomes (1,0),(0,0); free cells are (2,0),(0,1),(1,1),(2,1); seed 0 draws 1144304738 % 4 = 2 -> (1,1)
|
||||
assertEquals(
|
||||
Spawn.tick(eating),
|
||||
eating.copy(snake = List(Point(1, 0), Point(0, 0)), apple = Some(Point(1, 1)), score = 1, seed = 1831565813L)
|
||||
)
|
||||
}
|
||||
|
||||
test("tick is spawnApple after advance") {
|
||||
assertEquals(Spawn.tick(eating), Spawn.spawnApple(Rules.advance(eating)))
|
||||
}
|
||||
|
||||
test("tick leaves the seed alone when no apple needs spawning") {
|
||||
assertEquals(Spawn.tick(eating.copy(apple = Some(Point(2, 1)))).seed, 0L)
|
||||
}
|
||||
|
||||
test("newGame is reproducible: same seed, same game") {
|
||||
assertEquals(Spawn.newGame(42L), Spawn.newGame(42L))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package snake
|
||||
|
||||
import Fixtures.*
|
||||
|
||||
class M05GameSuite extends munit.FunSuite:
|
||||
cases("m05-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("m05-replay").foreach { c =>
|
||||
test(s"replay: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
assertEquals(Game.replay(gameState(a(0)), list(a(1))(action)), gameState(c("expected")))
|
||||
}
|
||||
}
|
||||
|
||||
cases("m05-history").foreach { c =>
|
||||
test(s"history: ${c("name").str}") {
|
||||
val a = c("args")
|
||||
assertEquals(Game.history(gameState(a(0)), list(a(1))(action)), list(c("expected"))(gameState))
|
||||
}
|
||||
}
|
||||
|
||||
private val replayArgs = cases("m05-replay")(0)("args")
|
||||
private val start = gameState(replayArgs(0))
|
||||
private val actions = list(replayArgs(1))(action)
|
||||
|
||||
test("history has one more state than there are actions") {
|
||||
assertEquals(Game.history(start, actions).length, actions.length + 1)
|
||||
}
|
||||
|
||||
test("history starts at the start state and ends where replay ends") {
|
||||
val states = Game.history(start, actions)
|
||||
assertEquals(states.head, start)
|
||||
assertEquals(states.last, Game.replay(start, actions))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package snake
|
||||
|
||||
import Fixtures.*
|
||||
|
||||
class M06ViewSuite extends munit.FunSuite:
|
||||
test("CELL is 20 pixels") { assertEquals(View.CELL, 20) }
|
||||
|
||||
cases("m06-view").foreach { c =>
|
||||
test(s"view: ${c("name").str}") {
|
||||
assertEquals(View.view(gameState(c("args")(0))), list(c("expected"))(shape))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package snake
|
||||
|
||||
import java.nio.file.{Files, Path, Paths}
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
class PuritySuite extends munit.FunSuite:
|
||||
private val srcDir = Paths.get(sys.props.getOrElse("user.dir", ".")).resolve("src")
|
||||
|
||||
// Files that are allowed to touch the outside world (a future web/ directory lives outside src/).
|
||||
private def excluded(file: Path): Boolean = file.getFileName.toString == "Runtime.scala"
|
||||
|
||||
private val banned: List[(String, scala.util.matching.Regex, String)] = List(
|
||||
("var", """\bvar\b""".r, "var n = 0"),
|
||||
("mutable", """\bmutable\b""".r, "val b = scala.collection.mutable.ListBuffer[Int]()"),
|
||||
("while", """\bwhile\b""".r, "while n < 3 do n += 1"),
|
||||
("scala.util.Random", """\bscala\.util\.Random\b""".r, "val r = scala.util.Random.nextInt()"),
|
||||
("System.", """\bSystem\.""".r, "val t = System.currentTimeMillis()"),
|
||||
("println", """\bprintln\b""".r, "println(\"hi\")")
|
||||
)
|
||||
|
||||
private def stripComments(code: String): String =
|
||||
code.replaceAll("""(?s)/\*.*?\*/""", "").replaceAll("""(?m)//.*$""", "")
|
||||
|
||||
private def violations(code: String): List[String] =
|
||||
val clean = stripComments(code)
|
||||
banned.collect { case (name, re, _) if re.findFirstIn(clean).isDefined => name }
|
||||
|
||||
private def scalaFiles(dir: Path): List[Path] =
|
||||
if !Files.exists(dir) then Nil
|
||||
else Files.walk(dir).iterator.asScala.filter(f => f.toString.endsWith(".scala")).toList.sorted
|
||||
|
||||
banned.foreach { (name, _, sample) =>
|
||||
test(s"purity scanner flags a known-bad sample using $name") {
|
||||
assert(violations(sample).contains(name))
|
||||
}
|
||||
}
|
||||
|
||||
test("purity scanner ignores banned words inside comments") {
|
||||
val code = "// var mutable println\n/* while (x) System.exit(1) */\nval a = 1 // scala.util.Random\n"
|
||||
assertEquals(violations(code), Nil)
|
||||
}
|
||||
|
||||
test("purity scanner accepts ordinary pure code") {
|
||||
assertEquals(violations("def f(xs: List[Int]): List[Int] = xs.map(_ + 1)"), Nil)
|
||||
}
|
||||
|
||||
scalaFiles(srcDir).filterNot(excluded).foreach { f =>
|
||||
test(s"purity: ${srcDir.relativize(f)} has no banned constructs") {
|
||||
assertEquals(violations(Files.readString(f)), Nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user