From ec8be64f82398d1c02da41a4e27bfc8941255e59 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 28 Aug 2026 15:50:00 +0200 Subject: [PATCH] test(protocol): add uci, clock, and time-control tests --- tests/TimeControl.test.scala | 47 ++++++++++++++++++++++++++ tests/Uci.test.scala | 64 ++++++++++++++++++++++++++++++++++++ tests/UciClock.test.scala | 36 ++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 tests/TimeControl.test.scala create mode 100644 tests/Uci.test.scala create mode 100644 tests/UciClock.test.scala diff --git a/tests/TimeControl.test.scala b/tests/TimeControl.test.scala new file mode 100644 index 0000000..4d1f2a6 --- /dev/null +++ b/tests/TimeControl.test.scala @@ -0,0 +1,47 @@ +package gambit + +class TimeControlTest extends munit.FunSuite: + import TimeControl.Limits + + test("movetime is honored minus a safety overhead") { + assertEquals(TimeControl.budgetMs(Limits(movetime = 200L), true), 175L) + } + + test("infinite and depth-only searches carry no time budget") { + assertEquals(TimeControl.budgetMs(Limits(infinite = true), true), 0L) + assertEquals(TimeControl.budgetMs(Limits(depth = 12), true), 0L) + assertEquals(TimeControl.budgetMs(Limits(), true), 0L) + } + + test("a fixed number of moves to go splits the clock evenly") { + assertEquals(TimeControl.budgetMs(Limits(wtime = 60000L, btime = 60000L, movestogo = 30), true), 1975L) + } + + test("increment is folded into the per-move budget") { + assertEquals(TimeControl.budgetMs(Limits(wtime = 1000L, winc = 1000L), true), 758L) + } + + test("the side to move selects its own clock") { + val limits = Limits(wtime = 60000L, btime = 5000L, movestogo = 10) + assertEquals(TimeControl.budgetMs(limits, true), 5975L) + assertEquals(TimeControl.budgetMs(limits, false), 475L) + } + + test("very low time never returns a non-positive budget") { + assert(TimeControl.budgetMs(Limits(wtime = 40L), true) >= 1L) + assert(TimeControl.budgetMs(Limits(wtime = 0L, winc = 10L), true) >= 1L) + } + + test("plan couples a depth cap with the time budget") { + assertEquals(TimeControl.plan(Limits(depth = 8), true), TimeControl.Plan(8, 0L)) + assertEquals(TimeControl.plan(Limits(movetime = 1000L), true), TimeControl.Plan(64, 975L)) + } + + test("parse reads every clock token and ignores unknown ones") { + val limits = TimeControl.parse("wtime 300000 btime 250000 winc 2000 binc 1500 movestogo 40 ponder".split(" ").toList) + assertEquals(limits.wtime, 300000L) + assertEquals(limits.btime, 250000L) + assertEquals(limits.winc, 2000L) + assertEquals(limits.binc, 1500L) + assertEquals(limits.movestogo, 40) + } diff --git a/tests/Uci.test.scala b/tests/Uci.test.scala new file mode 100644 index 0000000..ea9c653 --- /dev/null +++ b/tests/Uci.test.scala @@ -0,0 +1,64 @@ +package gambit + +class UciTest extends munit.FunSuite: + + test("uci handshake replies with id and uciok") { + val out = new Uci().handle("uci") + assertEquals(out, List("id name gambit", "id author gambit", "uciok")) + } + + test("isready replies readyok") { + assertEquals(new Uci().handle("isready"), List("readyok")) + } + + test("position startpos with moves then go returns a legal bestmove") { + val uci = new Uci() + uci.handle("ucinewgame") + uci.handle("position startpos moves e2e4 e7e5 g1f3") + val out = uci.handle("go depth 4") + val best = out.last + assert(best.startsWith("bestmove "), s"got $best") + val move = best.stripPrefix("bestmove ") + assert(move.matches("[a-h][1-8][a-h][1-8][nbrq]?"), s"malformed move $move") + assert(move != "0000", "expected a real move") + } + + test("position fen with trailing moves is parsed and searched") { + val uci = new Uci() + uci.handle("position fen 4k3/8/8/8/8/8/3q4/4K1R1 w - - 0 1 moves g1g2") + val res = uci.handle("go depth 4") + assert(res.last.startsWith("bestmove "), res.mkString(" | ")) + } + + test("go reports mate for a forced mate in one") { + val uci = new Uci() + uci.handle("position fen 6k1/5ppp/8/8/8/8/8/R6K w - - 0 1") + val out = uci.handle("go depth 3") + assert(out.exists(_.contains("score mate 1")), out.mkString(" | ")) + assertEquals(out.last, "bestmove a1a8") + } + + test("go reports mate in two and the mating first move") { + val uci = new Uci() + uci.handle("position fen 7k/R7/5K2/8/8/8/8/8 w - - 0 1") + val out = uci.handle("go depth 5") + assert(out.exists(_.contains("score mate 2")), out.mkString(" | ")) + assertEquals(out.last, "bestmove f6g6") + } + + test("movetime search still returns a bestmove") { + val uci = new Uci() + uci.handle("position startpos") + val out = uci.handle("go movetime 200") + assert(out.last.startsWith("bestmove ")) + assert(out.nonEmpty) + } + + test("run consumes a command stream up to quit") { + val collected = scala.collection.mutable.ListBuffer[String]() + val input = List("uci", "isready", "quit", "go depth 3").iterator + new Uci().run(input, collected += _) + assert(collected.contains("uciok")) + assert(collected.contains("readyok")) + assert(!collected.exists(_.startsWith("bestmove")), "commands after quit must be ignored") + } diff --git a/tests/UciClock.test.scala b/tests/UciClock.test.scala new file mode 100644 index 0000000..f1847cb --- /dev/null +++ b/tests/UciClock.test.scala @@ -0,0 +1,36 @@ +package gambit + +class UciClockTest extends munit.FunSuite: + + test("go with a wtime/btime clock returns a legal bestmove") { + val uci = new Uci() + uci.handle("position startpos") + val out = uci.handle("go wtime 3000 btime 3000 movestogo 20") + val best = out.last + assert(best.startsWith("bestmove "), out.mkString(" | ")) + val move = best.stripPrefix("bestmove ") + assert(move.matches("[a-h][1-8][a-h][1-8][nbrq]?"), s"malformed move $move") + assert(out.exists(_.startsWith("info depth ")), "clock search must still stream info") + } + + test("go with an increment-only sudden-death clock still moves") { + val uci = new Uci() + uci.handle("position startpos moves e2e4 c7c5") + val out = uci.handle("go wtime 1500 btime 1500 winc 100 binc 100") + assert(out.last.startsWith("bestmove ")) + assert(out.last.stripPrefix("bestmove ") != "0000") + } + + test("stop over the streaming loop aborts an infinite search and prints a bestmove") { + val collected = scala.collection.mutable.ListBuffer[String]() + val script = List[() => String]( + () => "position startpos", + () => "go infinite", + () => { Thread.sleep(200); "stop" }, + () => "quit" + ) + val input = script.iterator.map(_()) + new Uci().run(input, collected += _) + assert(collected.exists(_.startsWith("bestmove ")), collected.mkString(" | ")) + assert(collected.exists(_.startsWith("info depth ")), "an infinite search should report progress") + }