feat: NCS-10 Implement Pawn Promotion (#12)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #12 Reviewed-by: Leon Hermann <lq@blackhole.local> Co-authored-by: Janis <janis-e@gmx.de> Co-committed-by: Janis <janis-e@gmx.de>
This commit was merged in pull request #12.
This commit is contained in:
@@ -2,7 +2,9 @@ package de.nowchess.chess.controller
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -293,3 +295,172 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
|
||||
captured shouldBe Some(Piece.WhitePawn)
|
||||
case other => fail(s"Expected Moved but got $other")
|
||||
|
||||
// ──── pawn promotion detection ───────────────────────────────────────────
|
||||
|
||||
test("processMove detects white pawn reaching R8 and returns PromotionRequired"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
|
||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
||||
result match
|
||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
||||
from should be (sq(File.E, Rank.R7))
|
||||
to should be (sq(File.E, Rank.R8))
|
||||
turn should be (Color.White)
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
test("processMove detects black pawn reaching R1 and returns PromotionRequired"):
|
||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
||||
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
|
||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
||||
result match
|
||||
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
|
||||
from should be (sq(File.E, Rank.R2))
|
||||
to should be (sq(File.E, Rank.R1))
|
||||
turn should be (Color.Black)
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"):
|
||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
||||
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
|
||||
result should matchPattern { case _: MoveResult.PromotionRequired => }
|
||||
result match
|
||||
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
|
||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
// ──── completePromotion ──────────────────────────────────────────────────
|
||||
|
||||
test("completePromotion applies move and places queen"):
|
||||
// Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals)
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result should matchPattern { case _: MoveResult.Moved => }
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
test("completePromotion with rook underpromotion"):
|
||||
// Black king on h1: not attacked by rook on e8 (different file and rank)
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Rook, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
||||
case _ => fail("Expected Moved with Rook")
|
||||
|
||||
test("completePromotion with bishop underpromotion"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Bishop, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
|
||||
case _ => fail("Expected Moved with Bishop")
|
||||
|
||||
test("completePromotion with knight underpromotion"):
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Knight, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
||||
case _ => fail("Expected Moved with Knight")
|
||||
|
||||
test("completePromotion captures opponent piece"):
|
||||
// Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1)
|
||||
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.D, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
||||
newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
|
||||
case _ => fail("Expected Moved with captured piece")
|
||||
|
||||
test("completePromotion for black pawn to R1"):
|
||||
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R2), sq(File.E, Rank.R1),
|
||||
PromotionPiece.Knight, Color.Black
|
||||
)
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, newHistory, _, _) =>
|
||||
newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
|
||||
case _ => fail("Expected Moved")
|
||||
|
||||
test("completePromotion evaluates check after promotion"):
|
||||
val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result should matchPattern { case _: MoveResult.MovedInCheck => }
|
||||
|
||||
test("completePromotion full round-trip via processMove then completePromotion"):
|
||||
// Black king on h1: not attacked by queen on e8
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
|
||||
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
|
||||
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
|
||||
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
|
||||
result should matchPattern { case _: MoveResult.Moved => }
|
||||
result match
|
||||
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
|
||||
finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
case _ => fail("Expected Moved")
|
||||
case _ => fail("Expected PromotionRequired")
|
||||
|
||||
test("completePromotion results in checkmate when promotion delivers checkmate"):
|
||||
// Black king a8, white pawn h7, white king b6.
|
||||
// After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check;
|
||||
// a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape.
|
||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.H, Rank.R7), sq(File.H, Rank.R8),
|
||||
PromotionPiece.Queen, Color.White
|
||||
)
|
||||
result should matchPattern { case MoveResult.Checkmate(_) => }
|
||||
result match
|
||||
case MoveResult.Checkmate(winner) => winner should be (Color.White)
|
||||
case _ => fail("Expected Checkmate")
|
||||
|
||||
test("completePromotion results in stalemate when promotion stalemates opponent"):
|
||||
// Black king a8, white pawn b7, white bishop c7, white king b6.
|
||||
// After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6;
|
||||
// b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves.
|
||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||
val result = GameController.completePromotion(
|
||||
board, GameHistory.empty,
|
||||
sq(File.B, Rank.R7), sq(File.B, Rank.R8),
|
||||
PromotionPiece.Knight, Color.White
|
||||
)
|
||||
result should be (MoveResult.Stalemate)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class GameEnginePromotionTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
|
||||
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
|
||||
val events = collection.mutable.ListBuffer[GameEvent]()
|
||||
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
|
||||
events
|
||||
|
||||
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = new GameEngine(initialBoard = promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
|
||||
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
|
||||
}
|
||||
|
||||
test("isPendingPromotion is true after PromotionRequired input") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = new GameEngine(initialBoard = promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
|
||||
engine.isPendingPromotion should be (true)
|
||||
}
|
||||
|
||||
test("isPendingPromotion is false before any promotion input") {
|
||||
val engine = new GameEngine()
|
||||
engine.isPendingPromotion should be (false)
|
||||
}
|
||||
|
||||
test("completePromotion fires MoveExecutedEvent with promoted piece") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = new GameEngine(initialBoard = promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
|
||||
engine.history.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
|
||||
}
|
||||
|
||||
test("completePromotion with rook underpromotion") {
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val engine = new GameEngine(initialBoard = promotionBoard)
|
||||
captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Rook)
|
||||
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
|
||||
}
|
||||
|
||||
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
|
||||
val engine = new GameEngine()
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
||||
engine.isPendingPromotion should be (false)
|
||||
}
|
||||
|
||||
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
|
||||
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
|
||||
val engine = new GameEngine(initialBoard = promotionBoard)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Moved when promotion doesn't give check") {
|
||||
// White pawn on e7, black king on a2 (far away, not in check after promotion)
|
||||
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
|
||||
val engine = new GameEngine(initialBoard = board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
||||
}
|
||||
|
||||
test("completePromotion results in Checkmate when promotion delivers checkmate") {
|
||||
// Black king on a8, white king on b6, white pawn on h7
|
||||
// h7->h8=Q delivers checkmate
|
||||
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
|
||||
val engine = new GameEngine(initialBoard = board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("h7h8")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
|
||||
}
|
||||
|
||||
test("completePromotion results in Stalemate when promotion creates stalemate") {
|
||||
// Black king on a8, white pawn on b7, white bishop on c7, white king on b6
|
||||
// b7->b8=N: no check; Ka8 has no legal moves -> stalemate
|
||||
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
|
||||
val engine = new GameEngine(initialBoard = board)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("b7b8")
|
||||
engine.completePromotion(PromotionPiece.Knight)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
|
||||
}
|
||||
|
||||
test("completePromotion with black pawn promotion results in Moved") {
|
||||
// Black pawn e2, white king h3 (not on rank 1 or file e), black king a8
|
||||
// e2->e1=Q: queen on e1 does not attack h3 -> normal Moved
|
||||
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
|
||||
val engine = new GameEngine(initialBoard = board, initialTurn = Color.Black)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen)))
|
||||
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
|
||||
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
|
||||
}
|
||||
|
||||
test("completePromotion catch-all fires InvalidMoveEvent for unexpected MoveResult") {
|
||||
// Inject a function that returns an unexpected MoveResult to hit the catch-all case
|
||||
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val stubFn: (de.nowchess.api.board.Board, de.nowchess.chess.logic.GameHistory, Square, Square, PromotionPiece, Color) => de.nowchess.chess.controller.MoveResult =
|
||||
(_, _, _, _, _, _) => de.nowchess.chess.controller.MoveResult.NoPiece
|
||||
val engine = new GameEngine(initialBoard = promotionBoard, completePromotionFn = stubFn)
|
||||
val events = captureEvents(engine)
|
||||
|
||||
engine.processUserInput("e7e8")
|
||||
engine.isPendingPromotion should be (true)
|
||||
|
||||
engine.completePromotion(PromotionPiece.Queen)
|
||||
|
||||
engine.isPendingPromotion should be (false)
|
||||
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -39,3 +40,32 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
|
||||
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
history.moves should have length 1
|
||||
history.moves.head.castleSide shouldBe None
|
||||
|
||||
test("Move with promotion records the promotion piece"):
|
||||
val move = HistoryMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
|
||||
move.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
|
||||
test("Normal move has no promotion piece"):
|
||||
val move = HistoryMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), None, None)
|
||||
move.promotionPiece should be (None)
|
||||
|
||||
test("addMove with promotion stores promotionPiece"):
|
||||
val history = GameHistory.empty
|
||||
val newHistory = history.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
|
||||
|
||||
test("addMove with castleSide only uses promotionPiece default (None)"):
|
||||
val history = GameHistory.empty
|
||||
// With overload 3 removed, this uses the 4-param version and triggers addMove$default$4
|
||||
val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some(CastleSide.Kingside))
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.castleSide should be (Some(CastleSide.Kingside))
|
||||
newHistory.moves.head.promotionPiece should be (None)
|
||||
|
||||
test("addMove using named parameters with only promotion, using castleSide default"):
|
||||
val history = GameHistory.empty
|
||||
val newHistory = history.addMove(from = sq(File.E, Rank.R7), to = sq(File.E, Rank.R8), promotionPiece = Some(PromotionPiece.Queen))
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.castleSide should be (None)
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.nowchess.chess.logic
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.api.game.CastlingRights
|
||||
import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -255,3 +256,25 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
|
||||
|
||||
// ──── isPromotionMove ────────────────────────────────────────────────
|
||||
|
||||
test("White pawn reaching R8 is a promotion move"):
|
||||
val b = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true)
|
||||
|
||||
test("Black pawn reaching R1 is a promotion move"):
|
||||
val b = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true)
|
||||
|
||||
test("Pawn capturing to back rank is a promotion move"):
|
||||
val b = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.D, Rank.R8)) should be (true)
|
||||
|
||||
test("Pawn not reaching back rank is not a promotion move"):
|
||||
val b = FenParser.parseBoard("8/8/8/4P3/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false)
|
||||
|
||||
test("Non-pawn piece is never a promotion move"):
|
||||
val b = FenParser.parseBoard("8/8/8/4Q3/8/8/8/8").get
|
||||
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -63,3 +64,39 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
|
||||
pgn.contains("O-O-O") shouldBe true
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Queen as =Q suffix") {
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn should include ("e7e8=Q")
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Rook as =R suffix") {
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn should include ("e7e8=R")
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Bishop as =B suffix") {
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn should include ("e7e8=B")
|
||||
}
|
||||
|
||||
test("exportGame encodes promotion to Knight as =N suffix") {
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn should include ("e7e8=N")
|
||||
}
|
||||
|
||||
test("exportGame does not add suffix for normal moves") {
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn should include ("e2e4")
|
||||
pgn should not include ("=")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package de.nowchess.chess.notation
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.PromotionPiece
|
||||
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
|
||||
import de.nowchess.chess.notation.FenParser
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -332,3 +334,118 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
||||
result.isDefined shouldBe true
|
||||
result.get.to shouldBe Square(File.D, Rank.R1)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
|
||||
result.isDefined should be (true)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
result.get.to should be (Square(File.E, Rank.R8))
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Rook") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Rook))
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Bishop") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Bishop))
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove preserves promotion to Knight") {
|
||||
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||
val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White)
|
||||
result.get.promotionPiece should be (Some(PromotionPiece.Knight))
|
||||
}
|
||||
|
||||
test("parsePgn applies promoted piece to board for subsequent moves") {
|
||||
// Build a board with a white pawn on e7 plus the two kings
|
||||
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
|
||||
val pieces: Map[Square, Piece] = Map(
|
||||
Square(File.E, Rank.R7) -> Piece(Color.White, PieceType.Pawn),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
|
||||
Square(File.H, Rank.R1) -> Piece(Color.Black, PieceType.King)
|
||||
)
|
||||
val board = Board(pieces)
|
||||
val move = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
|
||||
move.isDefined should be (true)
|
||||
move.get.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
// After applying the promotion the square e8 should hold a White Queen
|
||||
val (boardAfterPawnMove, _) = board.withMove(move.get.from, move.get.to)
|
||||
val promotedBoard = boardAfterPawnMove.updated(move.get.to, Piece(Color.White, PieceType.Queen))
|
||||
promotedBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
|
||||
}
|
||||
|
||||
test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") {
|
||||
// This test exercises lines 53-58 in PgnParser.parseMovesText which contain
|
||||
// the pattern match over PromotionPiece for Queen, Rook, Bishop, Knight
|
||||
val pgn = """[Event "Promotion Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. a2a3 h7h5 2. a3a4 h5h4 3. a4a5 h4h3 4. a5a6 h3h2 5. a6a7 h2h1=Q 6. a7a8=R 1-0
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
|
||||
game.isDefined shouldBe true
|
||||
// Move 10 is h2h1=Q (black pawn promotes to queen)
|
||||
val blackPromotionToQ = game.get.moves(9) // 0-indexed
|
||||
blackPromotionToQ.promotionPiece shouldBe Some(PromotionPiece.Queen)
|
||||
|
||||
// Move 11 is a7a8=R (white pawn promotes to rook)
|
||||
val whitePromotionToR = game.get.moves(10)
|
||||
whitePromotionToR.promotionPiece shouldBe Some(PromotionPiece.Rook)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Rook through full PGN parse") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. a2a3 h7h6 2. a3a4 h6h5 3. a4a5 h5h4 4. a5a6 h4h3 5. a6a7 h3h2 6. a7a8=R
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Rook)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Bishop through full PGN parse") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. b2b3 h7h6 2. b3b4 h6h5 3. b4b5 h5h4 4. b5b6 h4h3 5. b6b7 h3h2 6. b7b8=B
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Bishop)
|
||||
}
|
||||
|
||||
test("parseAlgebraicMove promotion with Knight through full PGN parse") {
|
||||
val pgn = """[Event "Test"]
|
||||
[White "A"]
|
||||
[Black "B"]
|
||||
|
||||
1. c2c3 h7h6 2. c3c4 h6h5 3. c4c5 h5h4 4. c5c6 h4h3 5. c6c7 h3h2 6. c7c8=N
|
||||
"""
|
||||
val game = PgnParser.parsePgn(pgn)
|
||||
game.isDefined shouldBe true
|
||||
val lastMove = game.get.moves.last
|
||||
lastMove.promotionPiece shouldBe Some(PromotionPiece.Knight)
|
||||
}
|
||||
|
||||
test("extractPromotion returns None for invalid promotion letter") {
|
||||
// Regex =([A-Z]) now captures any uppercase letter, so =X is matched but case _ => None fires
|
||||
val result = PgnParser.extractPromotion("e7e8=X")
|
||||
result shouldBe None
|
||||
}
|
||||
|
||||
test("extractPromotion returns None when no promotion in notation") {
|
||||
val result = PgnParser.extractPromotion("e7e8")
|
||||
result shouldBe None
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user