feat: NCS-25 Add linters to keep quality up (#27)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #27
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #27.
This commit is contained in:
2026-04-12 20:58:39 +02:00
committed by Janis
parent 3cb3160731
commit fd4e67d4f7
79 changed files with 1671 additions and 1457 deletions
@@ -6,10 +6,9 @@ import java.nio.charset.StandardCharsets
import scala.util.Try
/** Service for persisting and loading game states to/from disk.
*
* Abstracts file I/O operations away from the UI layer.
* Handles both reading and writing game files.
*/
*
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
*/
trait GameFileService:
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
@@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService:
()
}.fold(
ex => Left(s"Failed to save file: ${ex.getMessage}"),
_ => Right(())
_ => Right(()),
)
/** Load a game context from a file using the specified importer. */
@@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService:
importer.importGameContext(json)
}.fold(
ex => Left(s"Failed to load file: ${ex.getMessage}"),
result => result
result => result,
)
@@ -15,28 +15,22 @@ object FenExporter extends GameContextExport:
/** 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 += pieceToFenChar(piece)
case None =>
emptyCount += 1
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
rankChars.mkString
val (result, emptyCount) = rankSquares.foldLeft(("", 0)):
case ((acc, empty), square) =>
board.pieceAt(square) match
case Some(piece) =>
val flushed = if empty > 0 then acc + empty.toString else acc
(flushed + pieceToFenChar(piece), 0)
case None =>
(acc, empty + 1)
if emptyCount > 0 then result + emptyCount.toString else result
/** Convert a GameContext to a complete FEN string. */
def gameContextToFen(context: GameContext): String =
val piecePlacement = boardToFen(context.board)
val activeColor = if context.turn == Color.White then "w" else "b"
val castling = castlingString(context.castlingRights)
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
val activeColor = if context.turn == Color.White then "w" else "b"
val castling = castlingString(context.castlingRights)
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
val fullMoveNumber = 1 + (context.moves.length / 2)
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
@@ -44,10 +38,10 @@ object FenExporter extends GameContextExport:
/** Convert castling rights to FEN notation. */
private def castlingString(rights: CastlingRights): String =
val wk = if rights.whiteKingSide then "K" else ""
val wq = if rights.whiteQueenSide then "Q" else ""
val bk = if rights.blackKingSide then "k" else ""
val bq = if rights.blackQueenSide then "q" else ""
val wk = if rights.whiteKingSide then "K" else ""
val wq = if rights.whiteQueenSide then "Q" else ""
val bk = if rights.blackKingSide then "k" else ""
val bq = if rights.blackQueenSide then "q" else ""
val result = s"$wk$wq$bk$bq"
if result.isEmpty then "-" else result
@@ -61,4 +55,3 @@ object FenExporter extends GameContextExport:
case PieceType.Queen => 'q'
case PieceType.King => 'k'
if piece.color == Color.White then base.toUpper else base
@@ -6,28 +6,27 @@ import de.nowchess.io.GameContextImport
object FenParser extends GameContextImport:
/** Parse a complete FEN string into a GameContext.
* Returns Left with error message if the format is invalid. */
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
*/
def parseFen(fen: String): Either[String, GameContext] =
val parts = fen.trim.split("\\s+")
if parts.length != 6 then
Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
else
for
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
yield GameContext(
board = board,
turn = activeColor,
castlingRights = castlingRights,
enPassantSquare = enPassant,
halfMoveClock = halfMoveClock,
moves = List.empty
moves = List.empty,
)
def importGameContext(input: String): Either[String, GameContext] =
@@ -41,25 +40,26 @@ object FenParser extends GameContextImport:
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
private def parseCastling(s: String): Option[CastlingRights] =
if s == "-" then
Some(CastlingRights.None)
if s == "-" then Some(CastlingRights.None)
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
Some(CastlingRights(
whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q')
))
else
None
Some(
CastlingRights(
whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q'),
),
)
else None
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
private def parseEnPassant(s: String): Option[Option[Square]] =
if s == "-" then Some(None)
else Square.fromAlgebraic(s).map(Some(_))
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
* Returns None if the format is invalid. */
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board. Returns None if the format
* is invalid.
*/
def parseBoard(fen: String): Option[Board] =
val rankStrings = fen.split("/", -1)
if rankStrings.length != 8 then None
@@ -73,28 +73,22 @@ object FenParser extends GameContextImport:
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
* Returns None if the rank string contains invalid characters or the wrong number of files. */
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs. Returns None if the
* rank string contains invalid characters or the wrong number of files.
*/
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
var fileIdx = 0
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
var failed = false
for c <- rankStr if !failed do
if fileIdx > 7 then
failed = true
else if c.isDigit then
fileIdx += c.asDigit
else
charToPiece(c) match
case None => failed = true
case Some(piece) =>
val file = File.values(fileIdx)
squares += (Square(file, rank) -> piece)
fileIdx += 1
val (fileIdx, failed, squares) = rankStr.foldLeft((0, false, List.empty[(Square, Piece)])):
case ((idx, true, acc), _) => (idx, true, acc)
case ((idx, false, acc), c) =>
if idx > 7 then (idx, true, acc)
else if c.isDigit then (idx + c.asDigit, false, acc)
else
charToPiece(c) match
case None => (idx, true, acc)
case Some(piece) =>
(idx + 1, false, acc :+ (Square(File.values(idx), rank) -> piece))
if failed || fileIdx != 8 then None
else Some(squares.toList)
else Some(squares)
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
private def charToPiece(c: Char): Option[Piece] =
@@ -108,4 +102,3 @@ object FenParser extends GameContextImport:
case 'k' => Some(PieceType.King)
case _ => None
pieceTypeOpt.map(pt => Piece(color, pt))
@@ -14,7 +14,7 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def pieceChar: Parser[Piece] =
"[prnbqkPRNBQK]".r ^^ { s =>
val c = s.head
val c = s.head
val color = if c.isUpper then Color.White else Color.Black
Piece(color, charToPieceType(c.toLower))
}
@@ -29,8 +29,9 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
/** Parse rank string for a given Rank, producing (Square, Piece) pairs.
* Fails if total file count != 8 or any piece placement exceeds board bounds. */
/** Parse rank string for a given Rank, producing (Square, Piece) pairs. Fails if total file count != 8 or any piece
* placement exceeds board bounds.
*/
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
rankTokens >> { tokens =>
buildSquares(rank, tokens) match
@@ -45,16 +46,15 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
/** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */
private def boardParser: Parser[Board] =
rankParser(Rank.R8) ~
(rankSep ~> rankParser(Rank.R7)) ~
(rankSep ~> rankParser(Rank.R6)) ~
(rankSep ~> rankParser(Rank.R5)) ~
(rankSep ~> rankParser(Rank.R4)) ~
(rankSep ~> rankParser(Rank.R3)) ~
(rankSep ~> rankParser(Rank.R2)) ~
(rankSep ~> rankParser(Rank.R1)) ^^ {
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
(rankSep ~> rankParser(Rank.R7)) ~
(rankSep ~> rankParser(Rank.R6)) ~
(rankSep ~> rankParser(Rank.R5)) ~
(rankSep ~> rankParser(Rank.R4)) ~
(rankSep ~> rankParser(Rank.R3)) ~
(rankSep ~> rankParser(Rank.R2)) ~
(rankSep ~> rankParser(Rank.R1)) ^^ { case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
}
}
// ── Color parser ─────────────────────────────────────────────────────────
@@ -68,20 +68,20 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def castlingParser: Parser[CastlingRights] =
"-" ^^^ CastlingRights.None |
"[KQkq]{1,4}".r ^^ { s =>
CastlingRights(
whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q')
)
}
"[KQkq]{1,4}".r ^^ { s =>
CastlingRights(
whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q'),
)
}
// ── En passant parser ────────────────────────────────────────────────────
private def enPassantParser: Parser[Option[Square]] =
"-" ^^^ Option.empty[Square] |
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
// ── Clock parser ─────────────────────────────────────────────────────────
@@ -92,17 +92,17 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
private def fenParser: Parser[GameContext] =
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
GameContext(
board = board,
turn = color,
castlingRights = castling,
enPassantSquare = ep,
halfMoveClock = halfMove,
moves = List.empty
)
}
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
GameContext(
board = board,
turn = color,
castlingRights = castling,
enPassantSquare = ep,
halfMoveClock = halfMove,
moves = List.empty,
)
}
// ── Public API ───────────────────────────────────────────────────────────
@@ -13,7 +13,7 @@ object FenParserFastParse extends GameContextImport:
private def pieceChar(using P[Any]): P[Piece] =
CharIn("prnbqkPRNBQK").!.map { s =>
val c = s.head
val c = s.head
val color = if c.isUpper then Color.White else Color.Black
Piece(color, charToPieceType(c.toLower))
}
@@ -39,13 +39,13 @@ object FenParserFastParse extends GameContextImport:
private def boardParser(using P[Any]): P[Board] =
(rankParser(Rank.R8) ~ sep ~
rankParser(Rank.R7) ~ sep ~
rankParser(Rank.R6) ~ sep ~
rankParser(Rank.R5) ~ sep ~
rankParser(Rank.R4) ~ sep ~
rankParser(Rank.R3) ~ sep ~
rankParser(Rank.R2) ~ sep ~
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
rankParser(Rank.R7) ~ sep ~
rankParser(Rank.R6) ~ sep ~
rankParser(Rank.R5) ~ sep ~
rankParser(Rank.R4) ~ sep ~
rankParser(Rank.R3) ~ sep ~
rankParser(Rank.R2) ~ sep ~
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
}
@@ -61,20 +61,20 @@ object FenParserFastParse extends GameContextImport:
private def castlingParser(using P[Any]): P[CastlingRights] =
LiteralStr("-").map(_ => CastlingRights.None) |
CharsWhileIn("KQkq").!.map { s =>
CastlingRights(
whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q')
)
}
CharsWhileIn("KQkq").!.map { s =>
CastlingRights(
whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q'),
)
}
// ── En passant parser ────────────────────────────────────────────────────
private def enPassantParser(using P[Any]): P[Option[Square]] =
LiteralStr("-").map(_ => Option.empty[Square]) |
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
// ── Clock parser ─────────────────────────────────────────────────────────
@@ -89,15 +89,15 @@ object FenParserFastParse extends GameContextImport:
private def fenParser(using P[Any]): P[GameContext] =
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
case (board, color, castling, ep, halfMove, _) =>
GameContext(
board = board,
turn = color,
castlingRights = castling,
board = board,
turn = color,
castlingRights = castling,
enPassantSquare = ep,
halfMoveClock = halfMove,
moves = List.empty
halfMoveClock = halfMove,
moves = List.empty,
)
}
@@ -14,19 +14,20 @@ private[fen] object FenParserSupport:
'n' -> PieceType.Knight,
'b' -> PieceType.Bishop,
'q' -> PieceType.Queen,
'k' -> PieceType.King
'k' -> PieceType.King,
)
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
case (None, _) => None
case (Some((acc, fileIdx)), PieceToken(piece)) =>
if fileIdx > 7 then None
else
val sq = Square(File.values(fileIdx), rank)
Some((acc :+ (sq -> piece), fileIdx + 1))
case (Some((acc, fileIdx)), EmptyToken(n)) =>
val next = fileIdx + n
if next > 8 then None
else Some((acc, next))
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
tokens
.foldLeft(Option((List.empty[(Square, Piece)], 0))):
case (None, _) => None
case (Some((acc, fileIdx)), PieceToken(piece)) =>
if fileIdx > 7 then None
else
val sq = Square(File.values(fileIdx), rank)
Some((acc :+ (sq -> piece), fileIdx + 1))
case (Some((acc, fileIdx)), EmptyToken(n)) =>
val next = fileIdx + n
if next > 8 then None
else Some((acc, next))
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
@@ -8,31 +8,31 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.io.GameContextExport
import de.nowchess.io.pgn.PgnExporter
import java.time.{LocalDate, ZonedDateTime, ZoneId}
import java.time.{LocalDate, ZoneId, ZonedDateTime}
/** Exports a GameContext to a comprehensive JSON format using Jackson.
*
* The JSON includes:
* - Game metadata (players, event, date, result)
* - Board state (all pieces and their positions)
* - Current game state (turn, castling rights, en passant, half-move clock)
* - Move history in both algebraic notation (PGN) and detailed move objects
* - Captured pieces tracking (which pieces have been removed)
* - Timestamp for record-keeping
*/
*
* The JSON includes:
* - Game metadata (players, event, date, result)
* - Board state (all pieces and their positions)
* - Current game state (turn, castling rights, en passant, half-move clock)
* - Move history in both algebraic notation (PGN) and detailed move objects
* - Captured pieces tracking (which pieces have been removed)
* - Timestamp for record-keeping
*/
object JsonExporter extends GameContextExport:
private val mapper = createMapper()
private def createMapper(): ObjectMapper =
val mapper = new ObjectMapper()
.registerModule(DefaultScalaModule)
// Configure pretty printer with custom spacing to match test expectations
val indenter = new DefaultIndenter(" ", "\n")
val printer = new DefaultPrettyPrinter()
val printer = new DefaultPrettyPrinter()
printer.indentArraysWith(indenter)
printer.indentObjectsWith(indenter)
mapper.setDefaultPrettyPrinter(printer)
mapper.enable(SerializationFeature.INDENT_OUTPUT)
mapper
@@ -42,18 +42,19 @@ object JsonExporter extends GameContextExport:
formatJson(mapper.writeValueAsString(record))
private def buildGameRecord(context: GameContext): JsonGameRecord =
val pgn = try {
Some(PgnExporter.exportGameContext(context))
} catch {
case _: Exception => None
}
val pgn =
try
Some(PgnExporter.exportGameContext(context))
catch {
case _: Exception => None
}
JsonGameRecord(
metadata = Some(buildMetadata()),
gameState = Some(buildGameState(context)),
moveHistory = pgn,
moves = Some(buildMoves(context.moves)),
capturedPieces = Some(buildCapturedPieces(context.board)),
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString),
)
private def buildMetadata(): JsonMetadata =
@@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport:
event = Some("Game"),
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
date = Some(LocalDate.now().toString),
result = Some("*")
result = Some("*"),
)
private def buildGameState(context: GameContext): JsonGameState =
@@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport:
turn = Some(context.turn.label),
castlingRights = Some(buildCastlingRights(context.castlingRights)),
enPassantSquare = context.enPassantSquare.map(_.toString),
halfMoveClock = Some(context.halfMoveClock)
halfMoveClock = Some(context.halfMoveClock),
)
private def buildBoardPieces(board: Board): List[JsonPiece] =
@@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport:
Some(rights.whiteKingSide),
Some(rights.whiteQueenSide),
Some(rights.blackKingSide),
Some(rights.blackQueenSide)
Some(rights.blackQueenSide),
)
private def buildMoves(moves: List[Move]): List[JsonMove] =
@@ -128,12 +129,11 @@ object JsonExporter extends GameContextExport:
val captured = Square.all.flatMap { square =>
initialBoard.pieceAt(square).flatMap { initialPiece =>
board.pieceAt(square) match
case None => Some(initialPiece)
case None => Some(initialPiece)
case Some(_) => None
}
}
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
(blackCaptured, whiteCaptured)
@@ -1,55 +1,55 @@
package de.nowchess.io.json
case class JsonMetadata(
event: Option[String] = None,
players: Option[Map[String, String]] = None,
date: Option[String] = None,
result: Option[String] = None
event: Option[String] = None,
players: Option[Map[String, String]] = None,
date: Option[String] = None,
result: Option[String] = None,
)
case class JsonPiece(
square: Option[String] = None,
color: Option[String] = None,
piece: Option[String] = None
square: Option[String] = None,
color: Option[String] = None,
piece: Option[String] = None,
)
case class JsonCastlingRights(
whiteKingSide: Option[Boolean] = None,
whiteQueenSide: Option[Boolean] = None,
blackKingSide: Option[Boolean] = None,
blackQueenSide: Option[Boolean] = None
whiteKingSide: Option[Boolean] = None,
whiteQueenSide: Option[Boolean] = None,
blackKingSide: Option[Boolean] = None,
blackQueenSide: Option[Boolean] = None,
)
case class JsonGameState(
board: Option[List[JsonPiece]] = None,
turn: Option[String] = None,
castlingRights: Option[JsonCastlingRights] = None,
enPassantSquare: Option[String] = None,
halfMoveClock: Option[Int] = None
board: Option[List[JsonPiece]] = None,
turn: Option[String] = None,
castlingRights: Option[JsonCastlingRights] = None,
enPassantSquare: Option[String] = None,
halfMoveClock: Option[Int] = None,
)
case class JsonCapturedPieces(
byWhite: Option[List[String]] = None,
byBlack: Option[List[String]] = None
byWhite: Option[List[String]] = None,
byBlack: Option[List[String]] = None,
)
case class JsonMoveType(
`type`: Option[String] = None,
isCapture: Option[Boolean] = None,
promotionPiece: Option[String] = None
`type`: Option[String] = None,
isCapture: Option[Boolean] = None,
promotionPiece: Option[String] = None,
)
case class JsonMove(
from: Option[String] = None,
to: Option[String] = None,
`type`: Option[JsonMoveType] = None
from: Option[String] = None,
to: Option[String] = None,
`type`: Option[JsonMoveType] = None,
)
case class JsonGameRecord(
metadata: Option[JsonMetadata] = None,
gameState: Option[JsonGameState] = None,
moveHistory: Option[String] = None,
moves: Option[List[JsonMove]] = None,
capturedPieces: Option[JsonCapturedPieces] = None,
timestamp: Option[String] = None
metadata: Option[JsonMetadata] = None,
gameState: Option[JsonGameState] = None,
moveHistory: Option[String] = None,
moves: Option[List[JsonMove]] = None,
capturedPieces: Option[JsonCapturedPieces] = None,
timestamp: Option[String] = None,
)
@@ -1,6 +1,6 @@
package de.nowchess.io.json
import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature}
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.*
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
@@ -9,17 +9,17 @@ import de.nowchess.io.GameContextImport
import scala.util.Try
/** Imports a GameContext from JSON format using Jackson.
*
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
* - Board state
* - Current turn
* - Castling rights
* - En passant square
* - Half-move clock
* - Move history
*
* Returns Left(error message) if the JSON is malformed or invalid.
*/
*
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
* - Board state
* - Current turn
* - Castling rights
* - En passant square
* - Half-move clock
* - Move history
*
* Returns Left(error message) if the JSON is malformed or invalid.
*/
object JsonParser extends GameContextImport:
private val mapper = new ObjectMapper()
@@ -27,20 +27,20 @@ object JsonParser extends GameContextImport:
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
def importGameContext(input: String): Either[String, GameContext] =
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
.left.map(e => "JSON parsing error: " + e.getMessage)
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
.map(e => "JSON parsing error: " + e.getMessage)
.flatMap { data =>
val gs = data.gameState.getOrElse(JsonGameState())
val gs = data.gameState.getOrElse(JsonGameState())
val rawBoard = gs.board.getOrElse(Nil)
val rawTurn = gs.turn.getOrElse("White")
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
val rawHmc = gs.halfMoveClock.getOrElse(0)
val rawTurn = gs.turn.getOrElse("White")
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
val rawHmc = gs.halfMoveClock.getOrElse(0)
val rawMoves = data.moves.getOrElse(Nil)
for
board <- parseBoard(rawBoard)
turn <- parseTurn(rawTurn)
castlingRights = parseCastlingRights(rawCr)
turn <- parseTurn(rawTurn)
castlingRights = parseCastlingRights(rawCr)
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
moves <- parseMoves(rawMoves)
yield GameContext(
@@ -49,16 +49,16 @@ object JsonParser extends GameContextImport:
castlingRights = castlingRights,
enPassantSquare = enPassantSquare,
halfMoveClock = rawHmc,
moves = moves
moves = moves,
)
}
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
val parsedPieces = pieces.flatMap { p =>
for
sq <- p.square.flatMap(Square.fromAlgebraic)
sq <- p.square.flatMap(Square.fromAlgebraic)
color <- p.color.flatMap(parseColor)
pt <- p.piece.flatMap(parsePieceType)
pt <- p.piece.flatMap(parsePieceType)
yield (sq, Piece(color, pt))
}
Right(Board(parsedPieces.toMap))
@@ -73,27 +73,27 @@ object JsonParser extends GameContextImport:
private def parsePieceType(pt: String): Option[PieceType] =
pt match
case "Pawn" => Some(PieceType.Pawn)
case "Pawn" => Some(PieceType.Pawn)
case "Knight" => Some(PieceType.Knight)
case "Bishop" => Some(PieceType.Bishop)
case "Rook" => Some(PieceType.Rook)
case "Queen" => Some(PieceType.Queen)
case "King" => Some(PieceType.King)
case _ => None
case "Rook" => Some(PieceType.Rook)
case "Queen" => Some(PieceType.Queen)
case "King" => Some(PieceType.King)
case _ => None
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
CastlingRights(
cr.whiteKingSide.getOrElse(false),
cr.whiteQueenSide.getOrElse(false),
cr.blackKingSide.getOrElse(false),
cr.blackQueenSide.getOrElse(false)
cr.blackQueenSide.getOrElse(false),
)
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
Right(moves.flatMap { m =>
for
from <- m.from.flatMap(Square.fromAlgebraic)
to <- m.to.flatMap(Square.fromAlgebraic)
from <- m.from.flatMap(Square.fromAlgebraic)
to <- m.to.flatMap(Square.fromAlgebraic)
moveType <- m.`type`.flatMap(parseMoveType)
yield Move(from, to, moveType)
})
@@ -110,10 +110,10 @@ object JsonParser extends GameContextImport:
Some(MoveType.EnPassant)
case Some("promotion") =>
val piece = mt.promotionPiece match
case Some("queen") => PromotionPiece.Queen
case Some("rook") => PromotionPiece.Rook
case Some("queen") => PromotionPiece.Queen
case Some("rook") => PromotionPiece.Rook
case Some("bishop") => PromotionPiece.Bishop
case Some("knight") => PromotionPiece.Knight
case _ => PromotionPiece.Queen // default
case _ => PromotionPiece.Queen // default
Some(MoveType.Promotion(piece))
case _ => None
@@ -11,39 +11,38 @@ object PgnExporter extends GameContextExport:
/** Export a GameContext to PGN format. */
def exportGameContext(context: GameContext): String =
val headers = Map(
"Event" -> "?",
"White" -> "?",
"Black" -> "?",
"Result" -> "*"
"Event" -> "?",
"White" -> "?",
"Black" -> "?",
"Result" -> "*",
)
exportGame(headers, context.moves)
/** Export a game with headers and moves to PGN format. */
def exportGame(headers: Map[String, String], moves: List[Move]): String =
val headerLines = headers.map { case (key, value) =>
s"""[$key "$value"]"""
}.mkString("\n")
val moveText = if moves.isEmpty then ""
else
var ctx = GameContext.initial
val sanMoves = moves.map { move =>
val algebraic = moveToAlgebraic(move, ctx.board)
ctx = DefaultRules.applyMove(ctx)(move)
algebraic
val headerLines = headers
.map { case (key, value) =>
s"""[$key "$value"]"""
}
.mkString("\n")
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
val moveNum = moveNumber + 1
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
val moveText =
if moves.isEmpty then ""
else
val contexts = moves.scanLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move))
val sanMoves = moves.zip(contexts).map { case (move, ctx) => moveToAlgebraic(move, ctx.board) }
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
val groupedMoves = sanMoves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
val moveNum = moveNumber + 1
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(_._1).getOrElse("")
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(_._1).getOrElse("")
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines
@@ -55,7 +54,7 @@ object PgnExporter extends GameContextExport:
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => s"${move.from.file.toString.toLowerCase}x${move.to}"
case MoveType.Promotion(pp) =>
case MoveType.Promotion(pp) =>
val promSuffix = pp match
case PromotionPiece.Queen => "=Q"
case PromotionPiece.Rook => "=R"
@@ -76,5 +75,3 @@ object PgnExporter extends GameContextExport:
case PieceType.Rook => s"R$capStr$dest"
case PieceType.Queen => s"Q$capStr$dest"
case PieceType.King => s"K$capStr$dest"
@@ -8,38 +8,40 @@ import de.nowchess.rules.sets.DefaultRules
/** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame(
headers: Map[String, String],
moves: List[Move]
headers: Map[String, String],
moves: List[Move],
)
object PgnParser extends GameContextImport:
/** Strictly validate a PGN text.
* Returns Right(PgnGame) if every move token is a legal move in the evolving position.
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token. */
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token.
*/
def validatePgn(pgn: String): Either[String, PgnGame] =
val lines = pgn.split("\n").map(_.trim)
val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ")
val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ")
validateMovesText(moveText).map(moves => PgnGame(headers, moves))
/** Import a PGN text into a GameContext by validating and replaying all moves.
* Returns Right(GameContext) with all moves applied and .moves populated.
* Returns Left(error message) if validation fails or move replay encounters an issue. */
/** Import a PGN text into a GameContext by validating and replaying all moves. Returns Right(GameContext) with all
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
* issue.
*/
def importGameContext(input: String): Either[String, GameContext] =
validatePgn(input).flatMap { game =>
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
}
/** Parse a complete PGN text into a PgnGame with headers and moves.
* Always succeeds (returns Some); malformed tokens are silently skipped. */
/** Parse a complete PGN text into a PgnGame with headers and moves. Always succeeds (returns Some); malformed tokens
* are silently skipped.
*/
def parsePgn(pgn: String): Option[PgnGame] =
val lines = pgn.split("\n").map(_.trim)
val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ")
val moves = parseMovesText(moveText)
val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ")
val moves = parseMovesText(moveText)
Some(PgnGame(headers, moves))
/** Parse PGN header lines of the form [Key "Value"]. */
@@ -51,25 +53,25 @@ object PgnParser extends GameContextImport:
private def parseMovesText(moveText: String): List[Move] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
val (_, _, moves) = tokens.foldLeft(
(GameContext.initial, Color.White, List.empty[Move])
(GameContext.initial, Color.White, List.empty[Move]),
):
case (state @ (ctx, color, acc), token) =>
if isMoveNumberOrResult(token) then state
else
parseAlgebraicMove(token, ctx, color) match
case None => state
case None => state
case Some(move) =>
val nextCtx = DefaultRules.applyMove(ctx)(move)
val nextCtx = DefaultRules.applyMove(ctx)(move)
(nextCtx, color.opposite, acc :+ move)
moves
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
private def isMoveNumberOrResult(token: String): Boolean =
token.matches("""\d+\.""") ||
token == "*" ||
token == "1-0" ||
token == "0-1" ||
token == "1/2-1/2"
token == "*" ||
token == "1-0" ||
token == "0-1" ||
token == "1/2-1/2"
/** Parse a single algebraic notation token into a Move, given the current game context. */
def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] =
@@ -98,47 +100,52 @@ object PgnParser extends GameContextImport:
if clean.length < 2 then None
else
val destStr = clean.takeRight(2)
Square.fromAlgebraic(destStr).flatMap: toSquare =>
val disambig = clean.dropRight(2)
Square
.fromAlgebraic(destStr)
.flatMap: toSquare =>
val disambig = clean.dropRight(2)
val requiredPieceType: Option[PieceType] =
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
else if clean.head.isUpper then charToPieceType(clean.head)
else Some(PieceType.Pawn)
val requiredPieceType: Option[PieceType] =
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
else if clean.head.isUpper then charToPieceType(clean.head)
else Some(PieceType.Pawn)
val hint =
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
else disambig
val hint =
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
else disambig
val promotion = extractPromotion(notation)
val promotion = extractPromotion(notation)
// Get all legal moves for this color that reach toSquare
val allLegal = DefaultRules.allLegalMoves(ctx)
val candidates = allLegal.filter { move =>
move.to == toSquare &&
ctx.board.pieceAt(move.from).exists(p =>
p.color == color &&
requiredPieceType.forall(_ == p.pieceType)
) &&
(hint.isEmpty || matchesHint(move.from, hint)) &&
promotionMatches(move, promotion)
}
// Get all legal moves for this color that reach toSquare
val allLegal = DefaultRules.allLegalMoves(ctx)
val candidates = allLegal.filter { move =>
move.to == toSquare &&
ctx.board
.pieceAt(move.from)
.exists(p =>
p.color == color &&
requiredPieceType.forall(_ == p.pieceType),
) &&
(hint.isEmpty || matchesHint(move.from, hint)) &&
promotionMatches(move, promotion)
}
candidates.headOption
candidates.headOption
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean =
hint.forall(c =>
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true
else true,
)
private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean =
promotion match
case None => move.moveType match
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
case _ => false
case None =>
move.moveType match
case MoveType.Normal(_) | MoveType.EnPassant | MoveType.CastleKingside | MoveType.CastleQueenside => true
case _ => false
case Some(pp) => move.moveType == MoveType.Promotion(pp)
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
@@ -168,17 +175,18 @@ object PgnParser extends GameContextImport:
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
private def validateMovesText(moveText: String): Either[String, List[Move]] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])]) {
case (acc, token) =>
tokens
.foldLeft(
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
) { case (acc, token) =>
acc.flatMap { case (ctx, color, moves) =>
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
else
parseAlgebraicMove(token, ctx, color) match
case None => Left(s"Illegal or impossible move: '$token'")
case None => Left(s"Illegal or impossible move: '$token'")
case Some(move) =>
val nextCtx = DefaultRules.applyMove(ctx)(move)
val nextCtx = DefaultRules.applyMove(ctx)(move)
Right((nextCtx, color.opposite, moves :+ move))
}
}.map(_._3)
}
.map(_._3)
@@ -1,7 +1,7 @@
package de.nowchess.io
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.move.Move
import de.nowchess.io.json.{JsonExporter, JsonParser}
import java.nio.file.{Files, Paths}
@@ -15,37 +15,35 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val tmpFile = Files.createTempFile("chess_test_", ".json")
try
val context = GameContext.initial
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
assert(result.isRight)
assert(Files.exists(tmpFile))
assert(Files.size(tmpFile) > 0)
finally
Files.deleteIfExists(tmpFile)
finally Files.deleteIfExists(tmpFile)
}
test("loadGameFromFile: reads JSON file successfully") {
val tmpFile = Files.createTempFile("chess_test_", ".json")
try
val originalContext = GameContext.initial
// Save
FileSystemGameService.saveGameToFile(originalContext, tmpFile, JsonExporter)
// Load
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(result.isRight)
val loaded = result.getOrElse(GameContext.initial)
assert(loaded == originalContext)
finally
Files.deleteIfExists(tmpFile)
finally Files.deleteIfExists(tmpFile)
}
test("loadGameFromFile: returns error on missing file") {
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json")
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
assert(result.isLeft)
}
@@ -57,16 +55,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val context = GameContext.initial
.withMove(move1)
.withMove(move2)
val saveResult = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
assert(saveResult.isRight)
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(loadResult.isRight)
val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 2)
finally
Files.deleteIfExists(tmpFile)
finally Files.deleteIfExists(tmpFile)
}
test("saveGameToFile: overwrites existing file") {
@@ -76,18 +73,17 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val context1 = GameContext.initial
FileSystemGameService.saveGameToFile(context1, tmpFile, JsonExporter)
val size1 = Files.size(tmpFile)
// Write second file (should overwrite)
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val context2 = GameContext.initial.withMove(move)
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(loadResult.isRight)
val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 1)
finally
Files.deleteIfExists(tmpFile)
finally Files.deleteIfExists(tmpFile)
}
test("loadGameFromFile: handles invalid JSON in file") {
@@ -95,10 +91,9 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
try
Files.write(tmpFile, "{ invalid json}".getBytes())
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(result.isLeft)
finally
Files.deleteIfExists(tmpFile)
finally Files.deleteIfExists(tmpFile)
}
test("round-trip: save and load preserves game state") {
@@ -110,16 +105,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
.withMove(move1)
.withMove(move2)
.withHalfMoveClock(3)
FileSystemGameService.saveGameToFile(original, tmpFile, JsonExporter)
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(loadResult.isRight)
val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 2)
assert(loaded.halfMoveClock == 3)
finally
Files.deleteIfExists(tmpFile)
finally Files.deleteIfExists(tmpFile)
}
test("saveGameToFile: handles exporter that throws exception") {
@@ -127,13 +121,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
try
val context = GameContext.initial
val faultyExporter = new GameContextExport {
def exportGameContext(c: GameContext): String =
def exportGameContext(c: GameContext): String =
throw new RuntimeException("Export failed")
}
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
assert(result.isLeft)
assert(result.left.toOption.get.contains("Failed to save file"))
finally
Files.deleteIfExists(tmpFile)
finally Files.deleteIfExists(tmpFile)
}
@@ -9,16 +9,18 @@ import org.scalatest.matchers.should.Matchers
class FenExporterTest extends AnyFunSuite with Matchers:
private def context(
piecePlacement: String,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
halfMoveClock: Int,
moveCount: Int
piecePlacement: String,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
halfMoveClock: Int,
moveCount: Int,
): GameContext =
val board = FenParser.parseBoard(piecePlacement).getOrElse(
fail(s"Invalid test board FEN: $piecePlacement")
)
val board = FenParser
.parseBoard(piecePlacement)
.getOrElse(
fail(s"Invalid test board FEN: $piecePlacement"),
)
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
GameContext(
board = board,
@@ -26,7 +28,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = castlingRights,
enPassantSquare = enPassantSquare,
halfMoveClock = halfMoveClock,
moves = List.fill(moveCount)(dummyMove)
moves = List.fill(moveCount)(dummyMove),
)
test("exportGameContextToFen handles initial and typical developed position"):
@@ -39,7 +41,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.E, Rank.R3)),
halfMoveClock = 0,
moveCount = 0
moveCount = 0,
)
FenExporter.gameContextToFen(gameContext) shouldBe
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
@@ -51,7 +53,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.None,
enPassantSquare = None,
halfMoveClock = 0,
moveCount = 0
moveCount = 0,
)
FenExporter.gameContextToFen(noCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
@@ -63,11 +65,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
whiteKingSide = true,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = true
blackQueenSide = true,
),
enPassantSquare = None,
halfMoveClock = 5,
moveCount = 4
moveCount = 4,
)
FenExporter.gameContextToFen(partialCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
@@ -78,7 +80,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.C, Rank.R6)),
halfMoveClock = 2,
moveCount = 4
moveCount = 4,
)
FenExporter.gameContextToFen(withEnPassant) shouldBe
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
@@ -90,7 +92,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
castlingRights = CastlingRights.All,
enPassantSquare = None,
halfMoveClock = 42,
moves = List.empty
moves = List.empty,
)
val fen = FenExporter.gameContextToFen(gameContext)
FenParser.parseFen(fen) match
@@ -101,4 +103,3 @@ class FenExporterTest extends AnyFunSuite with Matchers:
val ctx = GameContext.initial
FenExporter.exportGameContext(ctx) shouldBe FenExporter.gameContextToFen(ctx)
@@ -8,34 +8,52 @@ class FenParserCombinatorsTest extends AnyFunSuite with Matchers:
test("parseBoard parses canonical positions and supports round-trip"):
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val empty = "8/8/8/8/8/8/8/8"
val empty = "8/8/8/8/8/8/8/8"
val partial = "8/8/4k3/8/4K3/8/8/8"
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(
Some(Piece.WhitePawn),
)
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(
Some(Piece.BlackKing),
)
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(
Some(Piece.BlackKing),
)
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"):
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0
)
FenParserCombinators
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0,
)
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
)
FenParserCombinators
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
)
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false
)
FenParserCombinators
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false,
)
test("parseFen rejects invalid color and castling tokens"):
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
@@ -8,7 +8,7 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
test("parseBoard parses canonical positions and supports round-trip"):
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val empty = "8/8/8/8/8/8/8/8"
val empty = "8/8/8/8/8/8/8/8"
val partial = "8/8/4k3/8/4K3/8/8/8"
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
@@ -20,22 +20,34 @@ class FenParserFastParseTest extends AnyFunSuite with Matchers:
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"):
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0
)
FenParserFastParse
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0,
)
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
)
FenParserFastParse
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
)
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false
)
FenParserFastParse
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false,
)
test("parseFen rejects invalid color and castling tokens"):
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
@@ -8,7 +8,7 @@ class FenParserTest extends AnyFunSuite with Matchers:
test("parseBoard parses canonical positions and supports round-trip"):
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val empty = "8/8/8/8/8/8/8/8"
val empty = "8/8/8/8/8/8/8/8"
val partial = "8/8/4k3/8/4K3/8/8/8"
FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
@@ -20,22 +20,34 @@ class FenParserTest extends AnyFunSuite with Matchers:
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("parseFen parses full state for common valid inputs"):
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0
)
FenParser
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0,
)
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
)
FenParser
.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1")
.fold(
_ => fail(),
ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)),
)
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false
)
FenParser
.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1")
.fold(
_ => fail(),
ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false,
)
test("parseFen rejects invalid color and castling tokens"):
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
@@ -52,4 +64,3 @@ class FenParserTest extends AnyFunSuite with Matchers:
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
@@ -1,7 +1,7 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType}
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -13,71 +13,71 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
(PromotionPiece.Queen, "queen"),
(PromotionPiece.Rook, "rook"),
(PromotionPiece.Bishop, "bishop"),
(PromotionPiece.Knight, "knight")
(PromotionPiece.Knight, "knight"),
)
for ((piece, expectedName) <- promotions) do
for (piece, expectedName) <- promotions do
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
// Empty boards can cause issues in PgnExporter, using initial
val ctx = GameContext.initial.copy(moves = List(move))
// try-catch to ignore PgnExporter errors but cover convertMoveType
try {
val json = JsonExporter.exportGameContext(ctx)
json should include (s""""$expectedName"""")
json should include(s""""$expectedName"""")
} catch { case _: Exception => }
}
test("export normal non-capture move") {
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
val ctx = GameContext.initial.copy(moves = List(quietMove))
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"normal\"")
val ctx = GameContext.initial.copy(moves = List(quietMove))
val json = JsonExporter.exportGameContext(ctx)
json should include("\"normal\"")
}
test("export normal capture move manually") {
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
val ctx = GameContext.initial.copy(moves = List(move))
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"normal\"")
json should include ("\"isCapture\": true")
json should include("\"normal\"")
json should include("\"isCapture\": true")
} catch { case _: Exception => }
}
test("export all move type categories") {
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
val ctx = GameContext.initial.copy(moves = List(move))
val ctx = GameContext.initial.copy(moves = List(move))
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"moves\"")
json should include ("\"from\"")
json should include ("\"to\"")
json should include("\"moves\"")
json should include("\"from\"")
json should include("\"to\"")
}
test("export castle queenside move") {
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
val ctx = GameContext.initial.copy(moves = List(move))
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"castleQueenside\"")
json should include("\"castleQueenside\"")
} catch { case _: Exception => }
}
test("export castle kingside move") {
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
val ctx = GameContext.initial.copy(moves = List(move))
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"castleKingside\"")
json should include("\"castleKingside\"")
} catch { case _: Exception => }
}
test("export en passant move manually") {
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
val ctx = GameContext.initial.copy(moves = List(move))
val ctx = GameContext.initial.copy(moves = List(move))
try {
val json = JsonExporter.exportGameContext(ctx)
json should include ("\"enPassant\"")
json should include ("\"isCapture\": true")
json should include("\"enPassant\"")
json should include("\"isCapture\": true")
} catch { case _: Exception => }
}
@@ -1,7 +1,7 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Board, Square, Piece, Color, PieceType, File, Rank, CastlingRights}
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -10,8 +10,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: exports initial position") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"metadata\"")
json should include("\"gameState\"")
json should include("\"moveHistory\"")
@@ -21,8 +21,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: includes board pieces") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"a1\"")
json should include("\"Rook\"")
json should include("\"White\"")
@@ -30,24 +30,24 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: includes turn information") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"turn\": \"White\"")
}
test("exportGameContext: includes castling rights") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"whiteKingSide\": true")
json should include("\"whiteQueenSide\": true")
}
test("exportGameContext: exports with moves") {
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"moves\"")
json should include("\"from\"")
json should include("\"to\"")
@@ -57,8 +57,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: valid JSON structure") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should startWith("{")
json should endWith("}")
json should include("\"metadata\": {")
@@ -67,47 +67,47 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: empty move history for initial position") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"moves\": []")
}
test("exportGameContext: exports en passant square") {
val epSquare = Some(Square(File.E, Rank.R3))
val context = GameContext.initial.copy(enPassantSquare = epSquare)
val json = JsonExporter.exportGameContext(context)
val context = GameContext.initial.copy(enPassantSquare = epSquare)
val json = JsonExporter.exportGameContext(context)
json should include("\"enPassantSquare\": \"e3\"")
}
test("exportGameContext: exports null en passant square") {
val context = GameContext.initial.copy(enPassantSquare = None)
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"enPassantSquare\": null")
}
test("exportGameContext: exports different move destinations") {
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
json should include("\"moves\"")
}
test("exportGameContext: exports empty board") {
val emptyBoard = Board(Map.empty)
val context = GameContext.initial.copy(board = emptyBoard)
val json = JsonExporter.exportGameContext(context)
val context = GameContext.initial.copy(board = emptyBoard)
val json = JsonExporter.exportGameContext(context)
json should include("\"board\": []")
}
test("exportGameContext: exports all castling rights disabled") {
val noCastling = CastlingRights(false, false, false, false)
val context = GameContext.initial.withCastlingRights(noCastling)
val json = JsonExporter.exportGameContext(context)
val context = GameContext.initial.withCastlingRights(noCastling)
val json = JsonExporter.exportGameContext(context)
json should include("\"whiteKingSide\": false")
json should include("\"whiteQueenSide\": false")
json should include("\"blackKingSide\": false")
@@ -40,7 +40,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
Some("White"),
Some(JsonCastlingRights()),
Some("e3"),
Some(5)
Some(5),
)
assert(gs.board.contains(Nil))
assert(gs.halfMoveClock.contains(5))
@@ -88,7 +88,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
Some(""),
Some(Nil),
Some(JsonCapturedPieces()),
Some("2026-04-08T00:00:00Z")
Some("2026-04-08T00:00:00Z"),
)
assert(record.metadata.nonEmpty)
assert(record.timestamp.nonEmpty)
@@ -8,7 +8,7 @@ import org.scalatest.matchers.should.Matchers
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
test("parse invalid turn color returns error") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"turn": "Invalid", "board": []},
"moves": []
@@ -19,7 +19,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
}
test("parse invalid piece type filters it out") {
val json = """{
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
@@ -36,7 +36,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
}
test("parse invalid color in board filters piece") {
val json = """{
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
@@ -53,7 +53,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
}
test("parse with missing turn uses default") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"board": []},
"moves": []
@@ -65,7 +65,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
}
test("parse with missing board uses empty") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"turn": "White"},
"moves": []
@@ -77,7 +77,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
}
test("parse with missing moves uses empty list") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []}
}"""
@@ -88,7 +88,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
}
test("parse invalid square in board filters it") {
val json = """{
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
@@ -105,7 +105,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
}
test("parse all valid piece types") {
val json = """{
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
@@ -124,11 +124,16 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
assert(result.isRight)
val ctx = result.toOption.get
assert(ctx.board.pieces.size == 6)
assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn)
assert(
ctx.board
.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1))
.get
.pieceType == PieceType.Pawn,
)
}
test("parse with all castling rights false") {
val json = """{
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
@@ -8,7 +8,7 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
test("parse completely invalid JSON returns error") {
val invalidJson = "{ this is not valid json at all }"
val result = JsonParser.importGameContext(invalidJson)
val result = JsonParser.importGameContext(invalidJson)
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
}
@@ -26,26 +26,26 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
test("parse malformed JSON object returns error") {
val malformed = """{"metadata": {"unclosed": """
val result = JsonParser.importGameContext(malformed)
val result = JsonParser.importGameContext(malformed)
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
}
test("parse invalid JSON array returns error") {
val invalidArray = "[1, 2, 3"
val result = JsonParser.importGameContext(invalidArray)
val result = JsonParser.importGameContext(invalidArray)
assert(result.isLeft)
}
test("parse JSON with missing required fields") {
val json = """{"metadata": {}}"""
val json = """{"metadata": {}}"""
val result = JsonParser.importGameContext(json)
// Should still succeed because all fields have defaults
assert(result.isRight)
}
test("parse valid JSON with invalid turn falls back to default") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": []
@@ -1,7 +1,7 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank}
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
test("parse all move type variations") {
val json = """{
val json = """{
"metadata": {"event": "Game", "result": "*"},
"gameState": {"turn": "White", "board": []},
"moves": [
@@ -34,7 +34,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
}
test("parse invalid move type defaults to None") {
val json = """{
val json = """{
"metadata": {"event": "Game"},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
@@ -45,7 +45,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
}
test("parse promotion with default piece") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
@@ -56,7 +56,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
}
test("parse move with missing from/to skips it") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
@@ -69,26 +69,26 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
}
test("parse with invalid JSON returns error") {
val json = """{"invalid json"""
val json = """{"invalid json"""
val result = JsonParser.importGameContext(json)
assert(result.isLeft)
}
test("parse normal move with isCapture true") {
val json = """{
val json = """{
"metadata": {},
"gameState": {"turn": "White", "board": []},
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
val ctx = result.toOption.get
val ctx = result.toOption.get
val move = ctx.moves.head
assert(move.moveType == MoveType.Normal(true))
}
test("parse board with invalid pieces filters them") {
val json = """{
val json = """{
"metadata": {},
"gameState": {
"turn": "White",
@@ -1,7 +1,7 @@
package de.nowchess.io.json
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Color, File, Rank, Square, CastlingRights}
import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -9,39 +9,39 @@ import org.scalatest.matchers.should.Matchers
class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: parses valid JSON") {
val json = JsonExporter.exportGameContext(GameContext.initial)
val json = JsonExporter.exportGameContext(GameContext.initial)
val result = JsonParser.importGameContext(json)
assert(result.isRight)
}
test("importGameContext: restores board state") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result == Right(context))
}
test("importGameContext: restores turn") {
val context = GameContext.initial.withTurn(Color.Black)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.turn) == Right(Color.Black))
}
test("importGameContext: restores moves") {
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.moves.length) == Right(1))
}
test("importGameContext: handles empty board") {
val json = """{
val json = """{
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
"gameState": {
"board": [],
@@ -56,30 +56,31 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
"timestamp": "2026-04-06T00:00:00Z"
}"""
val result = JsonParser.importGameContext(json)
assert(result.isRight)
assert(result.map(_.board.pieces.isEmpty) == Right(true))
}
test("importGameContext: returns error on invalid JSON") {
val result = JsonParser.importGameContext("not valid json {{{")
assert(result.isLeft)
}
test("importGameContext: handles missing fields with defaults") {
val json = "{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
val json =
"{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
val result = JsonParser.importGameContext(json)
assert(result.isRight)
}
test("importGameContext: handles castling rights") {
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
val context = GameContext.initial.withCastlingRights(newCastling)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val context = GameContext.initial.withCastlingRights(newCastling)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
}
@@ -91,7 +92,7 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
.withMove(move2)
.withTurn(Color.White)
val json = JsonExporter.exportGameContext(context)
val json = JsonExporter.exportGameContext(context)
val restored = JsonParser.importGameContext(json)
assert(restored.map(_.moves.length) == Right(2))
@@ -100,55 +101,55 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: handles half-move clock") {
val context = GameContext.initial.withHalfMoveClock(5)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.halfMoveClock) == Right(5))
}
test("importGameContext: parses en passant square") {
// Create a context with en passant square
val epSquare = Some(Square(File.E, Rank.R3))
val context = GameContext.initial.copy(enPassantSquare = epSquare)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val context = GameContext.initial.copy(enPassantSquare = epSquare)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.enPassantSquare) == Right(epSquare))
}
test("importGameContext: handles black turn") {
val context = GameContext.initial.withTurn(Color.Black)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.turn) == Right(Color.Black))
}
test("importGameContext: preserves basic moves in JSON round-trip") {
// Use simple move without explicit moveType to let system handle it
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.isRight)
assert(result.map(_.moves.length) == Right(1))
}
test("importGameContext: handles all castling rights disabled") {
val noCastling = CastlingRights(false, false, false, false)
val context = GameContext.initial.withCastlingRights(noCastling)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val context = GameContext.initial.withCastlingRights(noCastling)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.castlingRights) == Right(noCastling))
}
test("importGameContext: handles mixed castling rights") {
val mixed = CastlingRights(true, false, false, true)
val mixed = CastlingRights(true, false, false, true)
val context = GameContext.initial.withCastlingRights(mixed)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.castlingRights) == Right(mixed))
}
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
class PgnExporterTest extends AnyFunSuite with Matchers:
test("exportGame renders headers and basic move text"):
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val emptyPgn = PgnExporter.exportGame(headers, List.empty)
emptyPgn.contains("[Event \"Test\"]") shouldBe true
emptyPgn.contains("[White \"A\"]") shouldBe true
@@ -19,13 +19,19 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
test("exportGame renders castling grouping and result markers"):
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
PgnExporter.exportGame(
Map("Event" -> "Test"),
List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)),
) should include("O-O")
PgnExporter.exportGame(
Map("Event" -> "Test"),
List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)),
) should include("O-O-O")
val seq = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal()),
)
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
grouped should include("1. e4 c5")
@@ -37,23 +43,24 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
test("exportGame handles promotion suffixes and normal move formatting"):
List(
PromotionPiece.Queen -> "=Q",
PromotionPiece.Rook -> "=R",
PromotionPiece.Queen -> "=Q",
PromotionPiece.Rook -> "=R",
PromotionPiece.Bishop -> "=B",
PromotionPiece.Knight -> "=N"
PromotionPiece.Knight -> "=N",
).foreach { (piece, suffix) =>
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
}
val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
val normal =
PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
normal should include("e4")
normal should not include "="
test("exportGameContext preserves moves and default headers"):
val moves = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal())
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal()),
)
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
withMoves.contains("e4") shouldBe true
@@ -78,7 +85,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
Move(sq("c7"), sq("c6")),
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
Move(sq("e1"), sq("e2"), MoveType.Normal(true)),
)
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
@@ -91,18 +98,17 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
pgn should include("Kxe2")
test("exportGame emits en-passant and promotion capture notation"):
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
pgn should include("exd3")
pgn should include("exf8=Q")
pawnCapturePgn should include("exd3")
quietPromotionPgn should include("e8=Q")
@@ -10,7 +10,7 @@ import org.scalatest.matchers.should.Matchers
class PgnParserTest extends AnyFunSuite with Matchers:
test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
val headerOnly = """[Event "Test Game"]
val headerOnly = """[Event "Test Game"]
[White "Alice"]
[Black "Bob"]
[Result "1-0"]"""
@@ -30,72 +30,116 @@ class PgnParserTest extends AnyFunSuite with Matchers:
capture.map(_.moves.length) shouldBe Some(3)
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
val whiteKs = PgnParser.parsePgn("""[Event "Test"]
val whiteKs = PgnParser
.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""")
.get
.moves
.last
whiteKs.moveType shouldBe MoveType.CastleKingside
whiteKs.from shouldBe Square(File.E, Rank.R1)
whiteKs.to shouldBe Square(File.G, Rank.R1)
val whiteQs = PgnParser.parsePgn("""[Event "Test"]
val whiteQs = PgnParser
.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""")
.get
.moves
.last
whiteQs.moveType shouldBe MoveType.CastleQueenside
whiteQs.from shouldBe Square(File.E, Rank.R1)
whiteQs.to shouldBe Square(File.C, Rank.R1)
val blackKs = PgnParser.parsePgn("""[Event "Test"]
val blackKs = PgnParser
.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""")
.get
.moves
.last
blackKs.moveType shouldBe MoveType.CastleKingside
blackKs.from shouldBe Square(File.E, Rank.R8)
val blackQs = PgnParser.parsePgn("""[Event "Test"]
val blackQs = PgnParser
.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""")
.get
.moves
.last
blackQs.moveType shouldBe MoveType.CastleQueenside
blackQs.from shouldBe Square(File.E, Rank.R8)
blackQs.to shouldBe Square(File.C, Rank.R8)
PgnParser.parsePgn("""[Event "Test"]
PgnParser
.parsePgn("""[Event "Test"]
1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
PgnParser.parsePgn("""[Event "Test"]
1. e4 e5 1-0""")
.map(_.moves.length) shouldBe Some(2)
PgnParser
.parsePgn("""[Event "Test"]
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
1. e4 INVALID e5""")
.map(_.moves.length) shouldBe Some(2)
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
val board = Board.initial
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
File.E,
Rank.R4,
)
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(
File.F,
Rank.R3,
)
val rookPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
)
val rankPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
)
PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
PgnParser
.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White)
.get
.from shouldBe Square(File.A, Rank.R1)
PgnParser
.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White)
.get
.from shouldBe Square(File.A, Rank.R1)
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
king.isDefined shouldBe true
king.get.from shouldBe Square(File.E, Rank.R1)
king.get.to shouldBe Square(File.E, Rank.R2)
test("parseAlgebraicMove handles all promotion targets"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
PgnParser
.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
.get
.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
PgnParser
.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White)
.get
.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
PgnParser
.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White)
.get
.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
PgnParser
.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White)
.get
.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
test("importGameContext accepts valid and empty PGN"):
val pgn = """[Event "Test"]
@@ -119,7 +163,7 @@ class PgnParserTest extends AnyFunSuite with Matchers:
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
test("parseAlgebraicMove rejects notation with invalid promotion piece"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
val context = GameContext.initial.withBoard(board)
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
@@ -128,4 +172,3 @@ class PgnParserTest extends AnyFunSuite with Matchers:
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
parsed.map(_.moves.size) shouldBe Some(2)
@@ -37,17 +37,20 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
test("validatePgn rejects impossible illegal and garbage tokens"):
PgnParser.validatePgn("""[Event "Test"]
PgnParser
.validatePgn("""[Event "Test"]
1. Qd4
""").isLeft shouldBe true
PgnParser.validatePgn("""[Event "Test"]
PgnParser
.validatePgn("""[Event "Test"]
1. O-O
""").isLeft shouldBe true
PgnParser.validatePgn("""[Event "Test"]
PgnParser
.validatePgn("""[Event "Test"]
1. e4 GARBAGE e5
""").isLeft shouldBe true
@@ -55,4 +58,3 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
test("validatePgn accepts empty move text and minimal valid header"):
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true