refactor(core): add tests for GameEngine move notation and event handling
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user