From cc62cd22d3c7b1c5286a100fdafb4ac655b94ddb Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 28 Mar 2026 18:33:03 +0100 Subject: [PATCH] feat: add FEN exporter and round-trip tests Implements FenExporter.boardToFen() converting Board to FEN piece-placement string, and adds three round-trip tests (initial position, empty board, partial position). Co-Authored-By: Claude Sonnet 4.6 --- .../nowchess/chess/notation/FenExporter.scala | 41 +++++++++++++++++++ .../chess/notation/FenParserTest.scala | 21 ++++++++++ 2 files changed, 62 insertions(+) create mode 100644 modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala new file mode 100644 index 0000000..f5aba5b --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/FenExporter.scala @@ -0,0 +1,41 @@ +package de.nowchess.chess.notation + +import de.nowchess.api.board.* + +object FenExporter: + + /** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */ + def boardToFen(board: Board): String = + Rank.values.reverse + .map(rank => buildRankString(board, rank)) + .mkString("/") + + /** Build the FEN representation for a single rank. */ + private def buildRankString(board: Board, rank: Rank): String = + val rankSquares = File.values.map(file => Square(file, rank)) + val rankChars = scala.collection.mutable.ListBuffer[Char]() + var emptyCount = 0 + + for square <- rankSquares do + board.pieceAt(square) match + case Some(piece) => + if emptyCount > 0 then + rankChars += emptyCount.toString.charAt(0) + emptyCount = 0 + rankChars += pieceToPgnChar(piece) + case None => + emptyCount += 1 + + if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0) + rankChars.mkString + + /** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */ + private def pieceToPgnChar(piece: Piece): Char = + val base = piece.pieceType match + case PieceType.Pawn => 'p' + case PieceType.Knight => 'n' + case PieceType.Bishop => 'b' + case PieceType.Rook => 'r' + case PieceType.Queen => 'q' + case PieceType.King => 'k' + if piece.color == Color.White then base.toUpper else base diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala index 9914dce..ce77a54 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenParserTest.scala @@ -40,3 +40,24 @@ class FenParserTest extends AnyFunSuite with Matchers: board.map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing)) board.map(_.pieceAt(Square(File.E, Rank.R4))) shouldBe Some(Some(Piece.WhiteKing)) + + test("testRoundTripInitialPosition"): + val originalFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" + val board = FenParser.parseBoard(originalFen) + val exportedFen = board.map(FenExporter.boardToFen) + + exportedFen shouldBe Some(originalFen) + + test("testRoundTripEmptyBoard"): + val originalFen = "8/8/8/8/8/8/8/8" + val board = FenParser.parseBoard(originalFen) + val exportedFen = board.map(FenExporter.boardToFen) + + exportedFen shouldBe Some(originalFen) + + test("testRoundTripPartialPosition"): + val originalFen = "8/8/4k3/8/4K3/8/8/8" + val board = FenParser.parseBoard(originalFen) + val exportedFen = board.map(FenExporter.boardToFen) + + exportedFen shouldBe Some(originalFen)