From 5a66a057b34af23ef3efc8888f08fbde767c95f6 Mon Sep 17 00:00:00 2001 From: Janis Date: Thu, 16 Apr 2026 21:55:55 +0200 Subject: [PATCH] feat: Add tests for bot behavior including king position, illegal moves, and game events --- .../nowchess/api/game/GameContextTest.scala | 6 + .../chess/engine/GameEngineWithBotTest.scala | 135 +++++++++++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala b/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala index 0b57258..3161ced 100644 --- a/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala @@ -71,3 +71,9 @@ class GameContextTest extends AnyFunSuite with Matchers: test("withResult clears result"): val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black))) ctx.withResult(None).result shouldBe None + + test("kingSquare returns white king position"): + GameContext.initial.kingSquare(Color.White) shouldBe Some(Square(File.E, Rank.R1)) + + test("kingSquare returns black king position"): + GameContext.initial.kingSquare(Color.Black) shouldBe Some(Square(File.E, Rank.R8)) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala index 20d9376..ffa4f55 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala @@ -1,7 +1,9 @@ package de.nowchess.chess.engine -import de.nowchess.api.board.Color +import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square} +import de.nowchess.api.bot.Bot import de.nowchess.api.game.{BotParticipant, GameContext, Human} +import de.nowchess.api.move.{Move, MoveType} import de.nowchess.api.player.{PlayerId, PlayerInfo} import de.nowchess.bot.bots.ClassicalBot import de.nowchess.bot.{BotController, BotDifficulty} @@ -12,6 +14,14 @@ import org.scalatest.matchers.should.Matchers import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger} +private class NoMoveBot extends Bot: + def name: String = "nomove" + def nextMove(context: GameContext): Option[Move] = None + +private class FixedMoveBot(move: Move) extends Bot: + def name: String = "fixed" + def nextMove(context: GameContext): Option[Move] = Some(move) + class GameEngineWithBotTest extends AnyFunSuite with Matchers: test("GameEngine can play against a ClassicalBot"): @@ -114,3 +124,126 @@ class GameEngineWithBotTest extends AnyFunSuite with Matchers: // Game should not be ended (checkmate/stalemate) engine.context.moves.nonEmpty should be(true) + test("startGame triggers bot when the starting player is a bot"): + val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))) + val engine = GameEngine( + GameContext.initial, + DefaultRules, + Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))), + ) + val movesMade = new AtomicInteger(0) + engine.subscribe(new Observer: + def onGameEvent(event: GameEvent): Unit = event match + case _: MoveExecutedEvent => movesMade.incrementAndGet() + case _ => () + ) + engine.startGame() + Thread.sleep(500) + movesMade.get() should be >= 1 + + test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"): + val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal()) + val bot = new FixedMoveBot(illegalMove) + val engine = GameEngine( + GameContext.initial, + DefaultRules, + Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)), + ) + val invalidCount = new AtomicInteger(0) + engine.subscribe(new Observer: + def onGameEvent(event: GameEvent): Unit = event match + case _: InvalidMoveEvent => invalidCount.incrementAndGet() + case _ => () + ) + engine.processUserInput("e2e4") + Thread.sleep(1000) + invalidCount.get() should be >= 1 + + test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"): + val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal()) + val bot = new FixedMoveBot(invalidMove) + val engine = GameEngine( + GameContext.initial, + DefaultRules, + Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)), + ) + val invalidCount = new AtomicInteger(0) + engine.subscribe(new Observer: + def onGameEvent(event: GameEvent): Unit = event match + case _: InvalidMoveEvent => invalidCount.incrementAndGet() + case _ => () + ) + engine.processUserInput("e2e4") + Thread.sleep(1000) + invalidCount.get() should be >= 1 + + test("handleBotNoMove fires CheckmateEvent when position is checkmate"): + // White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it + val board = Board(Map( + Square(File.A, Rank.R1) -> Piece.WhiteKing, + Square(File.B, Rank.R2) -> Piece.BlackQueen, + Square(File.B, Rank.R8) -> Piece.BlackRook, + Square(File.H, Rank.R8) -> Piece.BlackKing, + )) + val ctx = GameContext.initial.copy( + board = board, + turn = Color.White, + castlingRights = CastlingRights(false, false, false, false), + enPassantSquare = None, + halfMoveClock = 0, + moves = List.empty, + ) + val engine = GameEngine(ctx, DefaultRules, Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")))) + val checkmateCount = new AtomicInteger(0) + engine.subscribe(new Observer: + def onGameEvent(event: GameEvent): Unit = event match + case _: CheckmateEvent => checkmateCount.incrementAndGet() + case _ => () + ) + engine.startGame() + Thread.sleep(1000) + checkmateCount.get() should be >= 1 + + test("handleBotNoMove fires DrawEvent when position is stalemate"): + // White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2) + val board = Board(Map( + Square(File.A, Rank.R1) -> Piece.WhiteKing, + Square(File.B, Rank.R3) -> Piece.BlackQueen, + Square(File.H, Rank.R8) -> Piece.BlackKing, + )) + val ctx = GameContext.initial.copy( + board = board, + turn = Color.White, + castlingRights = CastlingRights(false, false, false, false), + enPassantSquare = None, + halfMoveClock = 0, + moves = List.empty, + ) + val engine = GameEngine(ctx, DefaultRules, Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")))) + val drawCount = new AtomicInteger(0) + engine.subscribe(new Observer: + def onGameEvent(event: GameEvent): Unit = event match + case _: DrawEvent => drawCount.incrementAndGet() + case _ => () + ) + engine.startGame() + Thread.sleep(1000) + drawCount.get() should be >= 1 + + test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"): + val engine = GameEngine( + GameContext.initial, + DefaultRules, + Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))), + ) + val unexpectedEvents = new AtomicInteger(0) + engine.subscribe(new Observer: + def onGameEvent(event: GameEvent): Unit = event match + case _: CheckmateEvent => unexpectedEvents.incrementAndGet() + case _: DrawEvent => unexpectedEvents.incrementAndGet() + case _ => () + ) + engine.startGame() + Thread.sleep(500) + unexpectedEvents.get() shouldBe 0 +