feat: NCS-9 En passant implementation (#8)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
- Add EnPassantCalculator to derive the en passant target square from GameHistory, detect en passant captures, and compute the captured pawn's square - Extend MoveValidator.legalTargets to include the en passant diagonal square in pawn legal targets - Extend GameController.processMove to remove the captured pawn from the board when an en passant capture is played Details En passant is derived purely from the last HistoryMove — no new state is introduced. If the last move was a double pawn push, the target square is the square the pawn passed through. The board mutation follows the same pattern as castling: board.withMove moves the capturing pawn, then board.removed removes the captured pawn from its actual square (which differs from the destination square). Test Plan - EnPassantCalculatorTest — 14 unit tests covering target derivation, captured square calculation, and capture detection for both colors - MoveValidatorTest — 5 new tests: ep target included/excluded based on history, adjacency filter, both colors, case _ branch coverage - GameControllerTest — 2 integration tests: white and black en passant capture removes pawn from board and returns correct captured piece - 100% scoverage (line/branch/method) confirmed Co-authored-by: LQ63 <lkhermann@web.de> Reviewed-on: #8 Reviewed-by: Janis <janis-e@gmx.de> Co-authored-by: Leon Hermann <lq@blackhole.local> Co-committed-by: Leon Hermann <lq@blackhole.local>
This commit was merged in pull request #8.
This commit is contained in:
@@ -365,3 +365,39 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
|
||||
castlingRights(newHistory, Color.White).queenSide shouldBe false
|
||||
case other => fail(s"Expected Moved or MovedInCheck, got $other")
|
||||
|
||||
// ──── en passant ────────────────────────────────────────────────────────
|
||||
|
||||
test("en passant capture removes the captured pawn from the board"):
|
||||
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
|
||||
val b = Board(Map(
|
||||
Square(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
Square(File.D, Rank.R5) -> Piece.BlackPawn,
|
||||
Square(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
Square(File.E, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
|
||||
val result = GameController.processMove(b, h, Color.White, "e5d6")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
||||
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
|
||||
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
|
||||
captured shouldBe Some(Piece.BlackPawn)
|
||||
case other => fail(s"Expected Moved but got $other")
|
||||
|
||||
test("en passant capture by black removes the captured white pawn"):
|
||||
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
|
||||
val b = Board(Map(
|
||||
Square(File.D, Rank.R4) -> Piece.BlackPawn,
|
||||
Square(File.E, Rank.R4) -> Piece.WhitePawn,
|
||||
Square(File.E, Rank.R8) -> Piece.BlackKing,
|
||||
Square(File.E, Rank.R1) -> Piece.WhiteKing
|
||||
))
|
||||
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val result = GameController.processMove(b, h, Color.Black, "d4e3")
|
||||
result match
|
||||
case MoveResult.Moved(newBoard, _, captured, _) =>
|
||||
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package de.nowchess.chess.logic
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class EnPassantCalculatorTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
||||
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
|
||||
|
||||
// ──── enPassantTarget ────────────────────────────────────────────────
|
||||
|
||||
test("enPassantTarget returns None for empty history"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None
|
||||
|
||||
test("enPassantTarget returns None when last move was a single pawn push"):
|
||||
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
||||
|
||||
test("enPassantTarget returns None when last move was not a pawn"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
|
||||
|
||||
test("enPassantTarget returns e3 after white pawn double push e2-e4"):
|
||||
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3))
|
||||
|
||||
test("enPassantTarget returns e6 after black pawn double push e7-e5"):
|
||||
val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6))
|
||||
|
||||
test("enPassantTarget returns d3 after white pawn double push d2-d4"):
|
||||
val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
|
||||
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3))
|
||||
|
||||
// ──── capturedPawnSquare ─────────────────────────────────────────────
|
||||
|
||||
test("capturedPawnSquare for white capturing on e6 returns e5"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5)
|
||||
|
||||
test("capturedPawnSquare for black capturing on e3 returns e4"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4)
|
||||
|
||||
test("capturedPawnSquare for white capturing on d6 returns d5"):
|
||||
EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5)
|
||||
|
||||
// ──── isEnPassant ────────────────────────────────────────────────────
|
||||
|
||||
test("isEnPassant returns true for valid white en passant capture"):
|
||||
// White pawn on e5, black pawn just double-pushed to d5 (ep target = d6)
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true
|
||||
|
||||
test("isEnPassant returns true for valid black en passant capture"):
|
||||
// Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3)
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true
|
||||
|
||||
test("isEnPassant returns false when no en passant target in history"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when piece at from is not a pawn"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhiteRook,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when to does not match ep target"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false
|
||||
|
||||
test("isEnPassant returns false when from square is empty"):
|
||||
val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
|
||||
@@ -211,3 +211,47 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
|
||||
sq(File.E, Rank.R4) -> Piece.BlackRook
|
||||
)
|
||||
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
|
||||
|
||||
// ──── Pawn – en passant targets ──────────────────────────────────────
|
||||
|
||||
test("white pawn includes ep target in legal moves after black double push"):
|
||||
// Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6))
|
||||
|
||||
test("white pawn does not include ep target without a preceding double push"):
|
||||
val b = board(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
|
||||
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6)
|
||||
|
||||
test("black pawn includes ep target in legal moves after white double push"):
|
||||
// White pawn just double-pushed to e4 (ep target = e3); black pawn on d4
|
||||
val b = board(
|
||||
sq(File.D, Rank.R4) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R4) -> Piece.WhitePawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3))
|
||||
|
||||
test("pawn on wrong file does not get ep target from adjacent double push"):
|
||||
// White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5
|
||||
val b = board(
|
||||
sq(File.A, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R5) -> Piece.BlackPawn
|
||||
)
|
||||
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
|
||||
MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6)
|
||||
|
||||
// ──── History-aware legalTargets fallback for non-pawn non-king pieces ─────
|
||||
|
||||
test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"):
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user