refactor(core): add tests for GameEngine move notation and event handling
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-04 17:36:15 +02:00
parent 9e5ef5a059
commit 6283db85c0
3 changed files with 207 additions and 0 deletions
@@ -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
@@ -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")
@@ -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")
}