feat: I/O json export import with 100% coverage
Build & Test (NowChessSystems) TeamCity build finished

- Full FEN parser tests
- Json exporter with all move types covered
- CastleKingside branch coverage

Co-Authored-By: Claude Opus 4.6
This commit is contained in:
shahdlala66
2026-04-08 21:01:41 +02:00
parent 9d11d25b99
commit 2cec27d471
50 changed files with 1866 additions and 603 deletions
-11
View File
@@ -1,12 +1 @@
## (2026-04-06)
## (2026-04-07)
## (2026-04-07)
### Bug Fixes
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
## (2026-04-08)
### Bug Fixes
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
@@ -9,10 +9,10 @@ import de.nowchess.api.move.Move
*/
trait RuleSet:
/** All pseudo-legal moves for the piece on `square` (ignores check). */
def candidateMoves(context: GameContext)(square: Square): List[Move]
def candidateMoves(context: GameContext, square: Square): List[Move]
/** Legal moves for `square`: candidates that don't leave own king in check. */
def legalMoves(context: GameContext)(square: Square): List[Move]
def legalMoves(context: GameContext, square: Square): List[Move]
/** All legal moves for the side to move. */
def allLegalMoves(context: GameContext): List[Move]
@@ -36,4 +36,4 @@ trait RuleSet:
* Handles all special move types: castling, en passant, promotion.
* Updates castling rights, en passant square, half-move clock, turn, and move history.
*/
def applyMove(context: GameContext)(move: Move): GameContext
def applyMove(context: GameContext, move: Move): GameContext
@@ -26,7 +26,7 @@ object DefaultRules extends RuleSet:
// ── Public API ─────────────────────────────────────────────────────
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
override def candidateMoves(context: GameContext, square: Square): List[Move] =
context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
if piece.color != context.turn then List.empty[Move]
else piece.pieceType match
@@ -38,13 +38,13 @@ object DefaultRules extends RuleSet:
case PieceType.King => kingCandidates(context, square, piece.color)
}
override def legalMoves(context: GameContext)(square: Square): List[Move] =
candidateMoves(context)(square).filter { move =>
override def legalMoves(context: GameContext, square: Square): List[Move] =
candidateMoves(context, square).filter { move =>
!leavesKingInCheck(context, move)
}
override def allLegalMoves(context: GameContext): List[Move] =
Square.all.flatMap(sq => legalMoves(context)(sq)).toList
Square.all.flatMap(sq => legalMoves(context, sq)).toList
override def isCheck(context: GameContext): Boolean =
kingSquare(context.board, context.turn)
@@ -163,12 +163,6 @@ object DefaultRules extends RuleSet:
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
moves.toList
private def queensideBSquare(kingToAlg: String): List[String] =
kingToAlg match
case "c1" => List("b1")
case "c8" => List("b8")
case _ => List.empty
private def addCastleMove(
context: GameContext,
moves: scala.collection.mutable.ListBuffer[Move],
@@ -176,8 +170,7 @@ object DefaultRules extends RuleSet:
castlingMove: CastlingMove
): Unit =
if castlingRight then
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
.flatMap(Square.fromAlgebraic)
val clearSqs = List(castlingMove.middleAlg, castlingMove.kingToAlg).flatMap(Square.fromAlgebraic)
if squaresEmpty(context.board, clearSqs) then
for
kf <- Square.fromAlgebraic(castlingMove.kingFromAlg)
@@ -291,7 +284,7 @@ object DefaultRules extends RuleSet:
// ── Move application ───────────────────────────────────────────────
override def applyMove(context: GameContext)(move: Move): GameContext =
override def applyMove(context: GameContext, move: Move): GameContext =
val color = context.turn
val board = context.board
@@ -52,14 +52,14 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove toggles turn and records move"):
val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(GameContext.initial)(move)
val next = DefaultRules.applyMove(GameContext.initial, move)
next.turn shouldBe Color.Black
next.moves.lastOption shouldBe Some(move)
test("applyMove sets en passant square after double pawn push"):
val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(GameContext.initial)(move)
val next = DefaultRules.applyMove(GameContext.initial, move)
next.enPassantSquare shouldBe Some(sq("e3"))
@@ -67,7 +67,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
val move = Move(sq("e2"), sq("e3"))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.enPassantSquare shouldBe None
@@ -75,7 +75,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.halfMoveClock shouldBe 0
@@ -83,7 +83,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
val move = Move(sq("g1"), sq("f3"))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.halfMoveClock shouldBe 8
@@ -91,7 +91,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.halfMoveClock shouldBe 0
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
@@ -100,7 +100,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("e2"))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.whiteKingSide shouldBe false
next.castlingRights.whiteQueenSide shouldBe false
@@ -111,7 +111,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
val move = Move(sq("h1"), sq("h2"))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.whiteKingSide shouldBe false
next.castlingRights.whiteQueenSide shouldBe true
@@ -120,7 +120,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackQueenSide shouldBe false
@@ -128,7 +128,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("g1")) shouldBe Some(Piece(Color.White, PieceType.King))
next.board.pieceAt(sq("f1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
@@ -139,7 +139,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("c1")) shouldBe Some(Piece(Color.White, PieceType.King))
next.board.pieceAt(sq("d1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
@@ -150,7 +150,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("d6")) shouldBe Some(Piece(Color.White, PieceType.Pawn))
next.board.pieceAt(sq("d5")) shouldBe None
@@ -160,7 +160,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Knight))
next.board.pieceAt(sq("a7")) shouldBe None
@@ -168,12 +168,12 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("candidateMoves returns empty for opponent piece on selected square"):
val context = GameContext.initial.withTurn(Color.Black)
DefaultRules.candidateMoves(context)(sq("e2")) shouldBe empty
DefaultRules.candidateMoves(context, sq("e2")) shouldBe empty
test("legalMoves keeps king safe by filtering pinned bishop moves"):
val context = contextFromFen("8/8/8/8/8/8/r1B1K3/8 w - - 0 1")
val bishopMoves = DefaultRules.legalMoves(context)(sq("c2"))
val bishopMoves = DefaultRules.legalMoves(context, sq("c2"))
bishopMoves shouldBe empty
@@ -181,7 +181,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.whiteKingSide shouldBe false
next.castlingRights.whiteQueenSide shouldBe false
@@ -198,8 +198,8 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
moves = List.empty
)
val afterA1Capture = DefaultRules.applyMove(context)(Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
val afterH1Capture = DefaultRules.applyMove(afterA1Capture)(Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
val afterA1Capture = DefaultRules.applyMove(context, Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
val afterH1Capture = DefaultRules.applyMove(afterA1Capture, Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
afterH1Capture.castlingRights.whiteKingSide shouldBe false
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
@@ -212,21 +212,21 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("candidateMoves for rook includes enemy capture move"):
val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
val rookMoves = DefaultRules.candidateMoves(context)(sq("a1"))
val rookMoves = DefaultRules.candidateMoves(context, sq("a1"))
rookMoves.exists(m => m.to == sq("h1") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
test("candidateMoves for knight includes enemy capture move"):
val context = contextFromFen("4k3/8/8/8/8/3p4/5N2/4K3 w - - 0 1")
val knightMoves = DefaultRules.candidateMoves(context)(sq("f2"))
val knightMoves = DefaultRules.candidateMoves(context, sq("f2"))
knightMoves.exists(m => m.to == sq("d3") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
test("candidateMoves includes black kingside and queenside castling options"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val kingMoves = DefaultRules.candidateMoves(context)(sq("e8"))
val kingMoves = DefaultRules.candidateMoves(context, sq("e8"))
kingMoves.exists(_.moveType == MoveType.CastleKingside) shouldBe true
kingMoves.exists(_.moveType == MoveType.CastleQueenside) shouldBe true
@@ -235,7 +235,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("g8")) shouldBe Some(Piece(Color.Black, PieceType.King))
next.board.pieceAt(sq("f8")) shouldBe Some(Piece(Color.Black, PieceType.Rook))
@@ -246,7 +246,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("h8"), sq("h7"))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackKingSide shouldBe false
next.castlingRights.blackQueenSide shouldBe true
@@ -255,7 +255,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("a8"), sq("a7"))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackKingSide shouldBe true
next.castlingRights.blackQueenSide shouldBe false
@@ -264,7 +264,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context)(move)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackKingSide shouldBe false
@@ -272,7 +272,7 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
val to = sq("a1")
val pawnMoves = DefaultRules.candidateMoves(context)(sq("a2"))
val pawnMoves = DefaultRules.candidateMoves(context, sq("a2"))
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
promotions.toSet shouldBe Set(
@@ -285,9 +285,9 @@ class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
test("applyMove promotion supports queen rook and bishop targets"):
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val queen = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
val rook = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
val bishop = DefaultRules.applyMove(base)(Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
val queen = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
val rook = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
val bishop = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
@@ -52,7 +52,7 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
val moves = rules.allLegalMoves(context)
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
moves.filter(m => m.from == Square(File.E, Rank.R1)).nonEmpty shouldBe true
test("king cannot move to square attacked by opponent"):
// FEN: white king e1, black rook e2 defended by black king e3
@@ -109,28 +109,6 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
castles.isEmpty shouldBe true
test("castling queenside is illegal when knight blocks on b8"):
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
val board = Board(Map(
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King)
))
val context = GameContext(
board = board,
turn = Color.Black,
castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty
)
val moves = rules.allLegalMoves(context)
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
castles.isEmpty shouldBe true
// ── En passant legality ──────────────────────────────────────────
test("en passant is legal when en passant square is set"):
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=0
PATCH=4
PATCH=1