feat: improve PGN loading with error handling for promotions and add tests for en passant capture
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-01 19:55:55 +02:00
parent 57a5185212
commit 23e727a7dc
4 changed files with 60 additions and 12 deletions
@@ -221,17 +221,40 @@ class GameEngine(
case Left(err) => case Left(err) =>
Left(err) Left(err)
case Right(game) => case Right(game) =>
val initialBoardBeforeLoad = currentBoard
val initialHistoryBeforeLoad = currentHistory
val initialTurnBeforeLoad = currentTurn
currentBoard = Board.initial currentBoard = Board.initial
currentHistory = GameHistory.empty currentHistory = GameHistory.empty
currentTurn = Color.White currentTurn = Color.White
pendingPromotion = None pendingPromotion = None
invoker.clear() invoker.clear()
game.moves.foreach { move =>
handleParsedMove(move.from, move.to, s"${move.from}${move.to}") var error: Option[String] = None
move.promotionPiece.foreach(completePromotion) import scala.util.control.Breaks._
breakable {
game.moves.foreach { move =>
handleParsedMove(move.from, move.to, s"${move.from}${move.to}")
move.promotionPiece.foreach(completePromotion)
// If the move failed to execute properly, stop and report
// (validatePgn should have caught this, but we're being safe)
if pendingPromotion.isDefined && move.promotionPiece.isEmpty then
error = Some(s"Promotion required for move ${move.from}${move.to}")
break()
}
} }
notifyObservers(PgnLoadedEvent(currentBoard, currentHistory, currentTurn))
Right(()) error match
case Some(err) =>
currentBoard = initialBoardBeforeLoad
currentHistory = initialHistoryBeforeLoad
currentTurn = initialTurnBeforeLoad
Left(err)
case None =>
notifyObservers(PgnLoadedEvent(currentBoard, currentHistory, currentTurn))
Right(())
} }
/** Load an arbitrary board position, clearing all history and undo/redo state. */ /** Load an arbitrary board position, clearing all history and undo/redo state. */
@@ -73,7 +73,7 @@ object MoveValidator:
val fi = from.file.ordinal val fi = from.file.ordinal
val ri = from.rank.ordinal val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1 val dir = if color == Color.White then 1 else -1
val startRank = if color == Color.White then 1 else 6 // R2 = ordinal 1, R7 = ordinal 6 val startRank = if color == Color.White then Rank.R2.ordinal else Rank.R7.ordinal
val oneStep = squareAt(fi, ri + dir) val oneStep = squareAt(fi, ri + dir)
@@ -1,7 +1,7 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
@@ -91,10 +91,22 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
val pgn = val pgn =
"""[Event "T"] """[Event "T"]
1. a3 h6 2. a4 h5 3. a5 h4 4. a6 h3 5. a7 h2 6. a8=Q 1. b4 h6 2. b5 h5 3. b6 h4 4. bxa7 h3 5. a8=Q
""" """
engine.loadPgn(pgn) shouldBe Right(()) engine.loadPgn(pgn) shouldBe Right(())
engine.history.moves.length shouldBe 12 engine.history.moves.length shouldBe 9
test("loadPgn: en passant capture in PGN is replayed correctly"):
val engine = new GameEngine()
val pgn =
"""[Event "T"]
1. e4 e6 2. e5 d5 3. exd6
"""
engine.loadPgn(pgn) shouldBe Right(())
engine.history.moves.length shouldBe 5
engine.board.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece(Color.White, PieceType.Pawn))
engine.board.pieceAt(Square(File.D, Rank.R5)) shouldBe None
// ── undo/redo notation events ───────────────────────────────────────────── // ── undo/redo notation events ─────────────────────────────────────────────
@@ -150,9 +162,8 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
val engine = new GameEngine() val engine = new GameEngine()
val cap = new EventCapture() val cap = new EventCapture()
engine.subscribe(cap) engine.subscribe(cap)
// e4, e5, then Black plays d5, White pawn on e4 captures on d5 // e4, then Black plays d5, White pawn on e4 captures on d5
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("d7d5") engine.processUserInput("d7d5")
engine.processUserInput("e4d5") // white pawn captures black pawn engine.processUserInput("e4d5") // white pawn captures black pawn
engine.undo() engine.undo()
@@ -100,7 +100,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
val pgn = val pgn =
"""[Event "Test"] """[Event "Test"]
1. a4 h6 2. a5 h5 3. a6 h4 4. a7 h3 5. a8=Q h2 1. b4 h6 2. b5 h5 3. b6 h4 4. bxa7 h3 5. a8=Q
""" """
PgnParser.validatePgn(pgn) match PgnParser.validatePgn(pgn) match
case Right(game) => case Right(game) =>
@@ -108,6 +108,20 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
game.moves.last.promotionPiece shouldBe Some(PromotionPiece.Queen) game.moves.last.promotionPiece shouldBe Some(PromotionPiece.Queen)
case Left(err) => fail(s"Expected Right but got Left($err)") case Left(err) => fail(s"Expected Right but got Left($err)")
test("validatePgn: en passant capture is parsed correctly"):
val pgn =
"""[Event "Test"]
1. e4 e6 2. e5 d5 3. exd6
"""
PgnParser.validatePgn(pgn) match
case Right(game) =>
game.moves.length shouldBe 5
val epMove = game.moves.last
epMove.isCapture shouldBe true
epMove.pieceType shouldBe PieceType.Pawn
case Left(err) => fail(s"Expected Right but got Left($err)")
test("validatePgn: disambiguation with two rooks is accepted"): test("validatePgn: disambiguation with two rooks is accepted"):
val pieces: Map[Square, Piece] = Map( val pieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),