From 6283db85c017a165b411aec8d0c78d0c68e629a0 Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 4 Apr 2026 17:36:15 +0200 Subject: [PATCH] refactor(core): add tests for GameEngine move notation and event handling --- .../chess/engine/GameEngineLoadPgnTest.scala | 31 +++++ .../chess/engine/GameEngineNotationTest.scala | 127 ++++++++++++++++++ .../engine/GameEnginePromotionTest.scala | 49 +++++++ 3 files changed, 207 insertions(+) create mode 100644 modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala index 865ad21..136d1c2 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala @@ -147,3 +147,34 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: engine.context.moves.head.to shouldBe de.nowchess.api.board.Square( de.nowchess.api.board.File.D, de.nowchess.api.board.Rank.R4 ) + + test("loadPosition: fires BoardResetEvent and updates context"): + import de.nowchess.api.board.{Board, Color} + import de.nowchess.api.game.GameContext + import de.nowchess.chess.notation.FenParser + + val engine = new GameEngine() + val cap = new EventCapture() + engine.subscribe(cap) + + // Make a move so there is history to clear + engine.processUserInput("e2e4") + engine.canUndo shouldBe true + + // Load a custom position + val customBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val customCtx = GameContext.initial.withBoard(customBoard).withTurn(Color.Black) + cap.events.clear() + engine.loadPosition(customCtx) + + // BoardResetEvent fired + cap.events.last shouldBe a[BoardResetEvent] + + // Engine state reflects the loaded position + engine.board shouldBe customBoard + engine.turn shouldBe Color.Black + engine.context.moves shouldBe empty + + // Undo/redo history cleared + engine.canUndo shouldBe false + engine.canRedo shouldBe false diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala new file mode 100644 index 0000000..d4a3f83 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala @@ -0,0 +1,127 @@ +package de.nowchess.chess.engine + +import de.nowchess.api.board.{Board, Color, File, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.PromotionPiece +import de.nowchess.chess.notation.FenParser +import de.nowchess.chess.observer.* +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +/** Tests that exercise moveToPgn branches not covered by other test files: + * - CastleQueenside (line 223) + * - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255) + * - Promotion(Bishop) notation (line 230) + * - King normal move notation (line 246) + */ +class GameEngineNotationTest extends AnyFunSuite with Matchers: + + private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] = + val buf = collection.mutable.ListBuffer[GameEvent]() + engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = buf += e }) + buf + + // ── Queenside castling (line 223) ────────────────────────────────── + + test("undo after queenside castling emits MoveUndoneEvent with O-O-O notation"): + // FEN: White king on e1, queenside rook on a1, b1/c1/d1 clear, black king away + val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get + // Castling rights: white queen-side only (no king-side rook present) + val castlingRights = de.nowchess.api.board.CastlingRights( + whiteKingSide = false, + whiteQueenSide = true, + blackKingSide = false, + blackQueenSide = false + ) + val ctx = GameContext.initial + .withBoard(board) + .withTurn(Color.White) + .withCastlingRights(castlingRights) + + val engine = new GameEngine(ctx) + val events = captureEvents(engine) + + // White castles queenside: e1c1 + engine.processUserInput("e1c1") + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + + events.clear() + engine.undo() + + val evt = events.collect { case e: MoveUndoneEvent => e }.head + evt.pgnNotation shouldBe "O-O-O" + + // ── En passant notation + computeCaptured (lines 224-225, 254-255) ─ + + test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"): + // White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6 + val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get + val epSquare = Square.fromAlgebraic("d6") + val ctx = GameContext.initial + .withBoard(board) + .withTurn(Color.White) + .withEnPassantSquare(epSquare) + .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) + + val engine = new GameEngine(ctx) + val events = captureEvents(engine) + + // White pawn on e5 captures en passant to d6 + engine.processUserInput("e5d6") + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + + // Verify the captured pawn was found (computeCaptured EnPassant branch) + val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head + moveEvt.capturedPiece shouldBe defined + moveEvt.capturedPiece.get should include ("black") + + events.clear() + engine.undo() + + val undoEvt = events.collect { case e: MoveUndoneEvent => e }.head + undoEvt.pgnNotation shouldBe "exd6" + + // ── Bishop underpromotion notation (line 230) ────────────────────── + + test("undo after bishop underpromotion emits MoveUndoneEvent with =B notation"): + val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/7K").get + val ctx = GameContext.initial + .withBoard(board) + .withTurn(Color.White) + .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) + + val engine = new GameEngine(ctx) + val events = captureEvents(engine) + + engine.processUserInput("e7e8") + engine.completePromotion(PromotionPiece.Bishop) + + events.clear() + engine.undo() + + val evt = events.collect { case e: MoveUndoneEvent => e }.head + evt.pgnNotation shouldBe "e8=B" + + // ── King normal move notation (line 246) ─────────────────────────── + + test("undo after king move emits MoveUndoneEvent with K notation"): + // White king on e1, no castling rights, black king far away + val board = FenParser.parseBoard("k7/8/8/8/8/8/8/4K3").get + val ctx = GameContext.initial + .withBoard(board) + .withTurn(Color.White) + .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) + + val engine = new GameEngine(ctx) + val events = captureEvents(engine) + + // King moves e1 -> f1 + engine.processUserInput("e1f1") + events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) + + events.clear() + engine.undo() + + val evt = events.collect { case e: MoveUndoneEvent => e }.head + evt.pgnNotation should startWith ("K") + evt.pgnNotation should include ("f1") diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index abe0e72..14d6771 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -145,3 +145,52 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) } + + test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") { + // Custom RuleSet: delegates all methods to StandardRules except legalMoves, + // which strips Promotion move types and returns Normal moves instead. + // This makes completePromotion unable to find Move(from, to, Promotion(Queen)), + // triggering the "Error completing promotion." branch. + val delegatingRuleSet: RuleSet = new RuleSet: + def candidateMoves(context: GameContext, square: Square): List[Move] = + StandardRules.candidateMoves(context, square) + def legalMoves(context: GameContext, square: Square): List[Move] = + StandardRules.legalMoves(context, square).map { m => + m.moveType match + case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal) + case _ => m + } + def allLegalMoves(context: GameContext): List[Move] = + StandardRules.allLegalMoves(context) + def isCheck(context: GameContext): Boolean = + StandardRules.isCheck(context) + def isCheckmate(context: GameContext): Boolean = + StandardRules.isCheckmate(context) + def isStalemate(context: GameContext): Boolean = + StandardRules.isStalemate(context) + def isInsufficientMaterial(context: GameContext): Boolean = + StandardRules.isInsufficientMaterial(context) + def isFiftyMoveRule(context: GameContext): Boolean = + StandardRules.isFiftyMoveRule(context) + def applyMove(context: GameContext, move: Move): GameContext = + StandardRules.applyMove(context, move) + + val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get + val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White) + val engine = new GameEngine(initialCtx, delegatingRuleSet) + val events = captureEvents(engine) + + // isPromotionMove will fire because pawn is on rank 7 heading to rank 8, + // and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion + engine.processUserInput("e7e8") + engine.isPendingPromotion should be (true) + + // completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves, + // but only Normal moves exist → fires InvalidMoveEvent + engine.completePromotion(PromotionPiece.Queen) + + engine.isPendingPromotion should be (false) + events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) + val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last + invalidEvt.reason should include ("Error completing promotion") + }