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(
|
engine.context.moves.head.to shouldBe de.nowchess.api.board.Square(
|
||||||
de.nowchess.api.board.File.D, de.nowchess.api.board.Rank.R4
|
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.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
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