refactor: clean up code formatting and improve readability in ChessBoardView and related files
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-12 20:58:26 +02:00
parent 5f2f12a02b
commit 52ae4244da
13 changed files with 311 additions and 317 deletions
@@ -6,10 +6,9 @@ import java.nio.charset.StandardCharsets
import scala.util.Try import scala.util.Try
/** Service for persisting and loading game states to/from disk. /** Service for persisting and loading game states to/from disk.
* *
* Abstracts file I/O operations away from the UI layer. * Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
* Handles both reading and writing game files. */
*/
trait GameFileService: trait GameFileService:
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
@@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService:
() ()
}.fold( }.fold(
ex => Left(s"Failed to save file: ${ex.getMessage}"), ex => Left(s"Failed to save file: ${ex.getMessage}"),
_ => Right(()) _ => Right(()),
) )
/** Load a game context from a file using the specified importer. */ /** Load a game context from a file using the specified importer. */
@@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService:
importer.importGameContext(json) importer.importGameContext(json)
}.fold( }.fold(
ex => Left(s"Failed to load file: ${ex.getMessage}"), ex => Left(s"Failed to load file: ${ex.getMessage}"),
result => result result => result,
) )
@@ -8,18 +8,18 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.io.GameContextExport import de.nowchess.io.GameContextExport
import de.nowchess.io.pgn.PgnExporter 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. /** Exports a GameContext to a comprehensive JSON format using Jackson.
* *
* The JSON includes: * The JSON includes:
* - Game metadata (players, event, date, result) * - Game metadata (players, event, date, result)
* - Board state (all pieces and their positions) * - Board state (all pieces and their positions)
* - Current game state (turn, castling rights, en passant, half-move clock) * - Current game state (turn, castling rights, en passant, half-move clock)
* - Move history in both algebraic notation (PGN) and detailed move objects * - Move history in both algebraic notation (PGN) and detailed move objects
* - Captured pieces tracking (which pieces have been removed) * - Captured pieces tracking (which pieces have been removed)
* - Timestamp for record-keeping * - Timestamp for record-keeping
*/ */
object JsonExporter extends GameContextExport: object JsonExporter extends GameContextExport:
private val mapper = createMapper() private val mapper = createMapper()
@@ -29,7 +29,7 @@ object JsonExporter extends GameContextExport:
// Configure pretty printer with custom spacing to match test expectations // Configure pretty printer with custom spacing to match test expectations
val indenter = new DefaultIndenter(" ", "\n") val indenter = new DefaultIndenter(" ", "\n")
val printer = new DefaultPrettyPrinter() val printer = new DefaultPrettyPrinter()
printer.indentArraysWith(indenter) printer.indentArraysWith(indenter)
printer.indentObjectsWith(indenter) printer.indentObjectsWith(indenter)
@@ -42,18 +42,19 @@ object JsonExporter extends GameContextExport:
formatJson(mapper.writeValueAsString(record)) formatJson(mapper.writeValueAsString(record))
private def buildGameRecord(context: GameContext): JsonGameRecord = private def buildGameRecord(context: GameContext): JsonGameRecord =
val pgn = try { val pgn =
Some(PgnExporter.exportGameContext(context)) try
} catch { Some(PgnExporter.exportGameContext(context))
case _: Exception => None catch {
} case _: Exception => None
}
JsonGameRecord( JsonGameRecord(
metadata = Some(buildMetadata()), metadata = Some(buildMetadata()),
gameState = Some(buildGameState(context)), gameState = Some(buildGameState(context)),
moveHistory = pgn, moveHistory = pgn,
moves = Some(buildMoves(context.moves)), moves = Some(buildMoves(context.moves)),
capturedPieces = Some(buildCapturedPieces(context.board)), 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 = private def buildMetadata(): JsonMetadata =
@@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport:
event = Some("Game"), event = Some("Game"),
players = Some(Map("white" -> "White Player", "black" -> "Black Player")), players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
date = Some(LocalDate.now().toString), date = Some(LocalDate.now().toString),
result = Some("*") result = Some("*"),
) )
private def buildGameState(context: GameContext): JsonGameState = private def buildGameState(context: GameContext): JsonGameState =
@@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport:
turn = Some(context.turn.label), turn = Some(context.turn.label),
castlingRights = Some(buildCastlingRights(context.castlingRights)), castlingRights = Some(buildCastlingRights(context.castlingRights)),
enPassantSquare = context.enPassantSquare.map(_.toString), enPassantSquare = context.enPassantSquare.map(_.toString),
halfMoveClock = Some(context.halfMoveClock) halfMoveClock = Some(context.halfMoveClock),
) )
private def buildBoardPieces(board: Board): List[JsonPiece] = private def buildBoardPieces(board: Board): List[JsonPiece] =
@@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport:
Some(rights.whiteKingSide), Some(rights.whiteKingSide),
Some(rights.whiteQueenSide), Some(rights.whiteQueenSide),
Some(rights.blackKingSide), Some(rights.blackKingSide),
Some(rights.blackQueenSide) Some(rights.blackQueenSide),
) )
private def buildMoves(moves: List[Move]): List[JsonMove] = private def buildMoves(moves: List[Move]): List[JsonMove] =
@@ -128,7 +129,7 @@ object JsonExporter extends GameContextExport:
val captured = Square.all.flatMap { square => val captured = Square.all.flatMap { square =>
initialBoard.pieceAt(square).flatMap { initialPiece => initialBoard.pieceAt(square).flatMap { initialPiece =>
board.pieceAt(square) match board.pieceAt(square) match
case None => Some(initialPiece) case None => Some(initialPiece)
case Some(_) => None case Some(_) => None
} }
} }
@@ -136,4 +137,3 @@ object JsonExporter extends GameContextExport:
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
(blackCaptured, whiteCaptured) (blackCaptured, whiteCaptured)
@@ -1,55 +1,55 @@
package de.nowchess.io.json package de.nowchess.io.json
case class JsonMetadata( case class JsonMetadata(
event: Option[String] = None, event: Option[String] = None,
players: Option[Map[String, String]] = None, players: Option[Map[String, String]] = None,
date: Option[String] = None, date: Option[String] = None,
result: Option[String] = None result: Option[String] = None,
) )
case class JsonPiece( case class JsonPiece(
square: Option[String] = None, square: Option[String] = None,
color: Option[String] = None, color: Option[String] = None,
piece: Option[String] = None piece: Option[String] = None,
) )
case class JsonCastlingRights( case class JsonCastlingRights(
whiteKingSide: Option[Boolean] = None, whiteKingSide: Option[Boolean] = None,
whiteQueenSide: Option[Boolean] = None, whiteQueenSide: Option[Boolean] = None,
blackKingSide: Option[Boolean] = None, blackKingSide: Option[Boolean] = None,
blackQueenSide: Option[Boolean] = None blackQueenSide: Option[Boolean] = None,
) )
case class JsonGameState( case class JsonGameState(
board: Option[List[JsonPiece]] = None, board: Option[List[JsonPiece]] = None,
turn: Option[String] = None, turn: Option[String] = None,
castlingRights: Option[JsonCastlingRights] = None, castlingRights: Option[JsonCastlingRights] = None,
enPassantSquare: Option[String] = None, enPassantSquare: Option[String] = None,
halfMoveClock: Option[Int] = None halfMoveClock: Option[Int] = None,
) )
case class JsonCapturedPieces( case class JsonCapturedPieces(
byWhite: Option[List[String]] = None, byWhite: Option[List[String]] = None,
byBlack: Option[List[String]] = None byBlack: Option[List[String]] = None,
) )
case class JsonMoveType( case class JsonMoveType(
`type`: Option[String] = None, `type`: Option[String] = None,
isCapture: Option[Boolean] = None, isCapture: Option[Boolean] = None,
promotionPiece: Option[String] = None promotionPiece: Option[String] = None,
) )
case class JsonMove( case class JsonMove(
from: Option[String] = None, from: Option[String] = None,
to: Option[String] = None, to: Option[String] = None,
`type`: Option[JsonMoveType] = None `type`: Option[JsonMoveType] = None,
) )
case class JsonGameRecord( case class JsonGameRecord(
metadata: Option[JsonMetadata] = None, metadata: Option[JsonMetadata] = None,
gameState: Option[JsonGameState] = None, gameState: Option[JsonGameState] = None,
moveHistory: Option[String] = None, moveHistory: Option[String] = None,
moves: Option[List[JsonMove]] = None, moves: Option[List[JsonMove]] = None,
capturedPieces: Option[JsonCapturedPieces] = None, capturedPieces: Option[JsonCapturedPieces] = None,
timestamp: Option[String] = None timestamp: Option[String] = None,
) )
@@ -1,6 +1,6 @@
package de.nowchess.io.json 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 com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
@@ -9,17 +9,17 @@ import de.nowchess.io.GameContextImport
import scala.util.Try import scala.util.Try
/** Imports a GameContext from JSON format using Jackson. /** Imports a GameContext from JSON format using Jackson.
* *
* Parses JSON exported by JsonExporter and reconstructs the GameContext including: * Parses JSON exported by JsonExporter and reconstructs the GameContext including:
* - Board state * - Board state
* - Current turn * - Current turn
* - Castling rights * - Castling rights
* - En passant square * - En passant square
* - Half-move clock * - Half-move clock
* - Move history * - Move history
* *
* Returns Left(error message) if the JSON is malformed or invalid. * Returns Left(error message) if the JSON is malformed or invalid.
*/ */
object JsonParser extends GameContextImport: object JsonParser extends GameContextImport:
private val mapper = new ObjectMapper() private val mapper = new ObjectMapper()
@@ -27,20 +27,20 @@ object JsonParser extends GameContextImport:
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
def importGameContext(input: String): Either[String, GameContext] = def importGameContext(input: String): Either[String, GameContext] =
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
.left.map(e => "JSON parsing error: " + e.getMessage) .map(e => "JSON parsing error: " + e.getMessage)
.flatMap { data => .flatMap { data =>
val gs = data.gameState.getOrElse(JsonGameState()) val gs = data.gameState.getOrElse(JsonGameState())
val rawBoard = gs.board.getOrElse(Nil) val rawBoard = gs.board.getOrElse(Nil)
val rawTurn = gs.turn.getOrElse("White") val rawTurn = gs.turn.getOrElse("White")
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights()) val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
val rawHmc = gs.halfMoveClock.getOrElse(0) val rawHmc = gs.halfMoveClock.getOrElse(0)
val rawMoves = data.moves.getOrElse(Nil) val rawMoves = data.moves.getOrElse(Nil)
for for
board <- parseBoard(rawBoard) board <- parseBoard(rawBoard)
turn <- parseTurn(rawTurn) turn <- parseTurn(rawTurn)
castlingRights = parseCastlingRights(rawCr) castlingRights = parseCastlingRights(rawCr)
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s)) enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
moves <- parseMoves(rawMoves) moves <- parseMoves(rawMoves)
yield GameContext( yield GameContext(
@@ -49,16 +49,16 @@ object JsonParser extends GameContextImport:
castlingRights = castlingRights, castlingRights = castlingRights,
enPassantSquare = enPassantSquare, enPassantSquare = enPassantSquare,
halfMoveClock = rawHmc, halfMoveClock = rawHmc,
moves = moves moves = moves,
) )
} }
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] = private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
val parsedPieces = pieces.flatMap { p => val parsedPieces = pieces.flatMap { p =>
for for
sq <- p.square.flatMap(Square.fromAlgebraic) sq <- p.square.flatMap(Square.fromAlgebraic)
color <- p.color.flatMap(parseColor) color <- p.color.flatMap(parseColor)
pt <- p.piece.flatMap(parsePieceType) pt <- p.piece.flatMap(parsePieceType)
yield (sq, Piece(color, pt)) yield (sq, Piece(color, pt))
} }
Right(Board(parsedPieces.toMap)) Right(Board(parsedPieces.toMap))
@@ -73,27 +73,27 @@ object JsonParser extends GameContextImport:
private def parsePieceType(pt: String): Option[PieceType] = private def parsePieceType(pt: String): Option[PieceType] =
pt match pt match
case "Pawn" => Some(PieceType.Pawn) case "Pawn" => Some(PieceType.Pawn)
case "Knight" => Some(PieceType.Knight) case "Knight" => Some(PieceType.Knight)
case "Bishop" => Some(PieceType.Bishop) case "Bishop" => Some(PieceType.Bishop)
case "Rook" => Some(PieceType.Rook) case "Rook" => Some(PieceType.Rook)
case "Queen" => Some(PieceType.Queen) case "Queen" => Some(PieceType.Queen)
case "King" => Some(PieceType.King) case "King" => Some(PieceType.King)
case _ => None case _ => None
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights = private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
CastlingRights( CastlingRights(
cr.whiteKingSide.getOrElse(false), cr.whiteKingSide.getOrElse(false),
cr.whiteQueenSide.getOrElse(false), cr.whiteQueenSide.getOrElse(false),
cr.blackKingSide.getOrElse(false), cr.blackKingSide.getOrElse(false),
cr.blackQueenSide.getOrElse(false) cr.blackQueenSide.getOrElse(false),
) )
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] = private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
Right(moves.flatMap { m => Right(moves.flatMap { m =>
for for
from <- m.from.flatMap(Square.fromAlgebraic) from <- m.from.flatMap(Square.fromAlgebraic)
to <- m.to.flatMap(Square.fromAlgebraic) to <- m.to.flatMap(Square.fromAlgebraic)
moveType <- m.`type`.flatMap(parseMoveType) moveType <- m.`type`.flatMap(parseMoveType)
yield Move(from, to, moveType) yield Move(from, to, moveType)
}) })
@@ -110,10 +110,10 @@ object JsonParser extends GameContextImport:
Some(MoveType.EnPassant) Some(MoveType.EnPassant)
case Some("promotion") => case Some("promotion") =>
val piece = mt.promotionPiece match val piece = mt.promotionPiece match
case Some("queen") => PromotionPiece.Queen case Some("queen") => PromotionPiece.Queen
case Some("rook") => PromotionPiece.Rook case Some("rook") => PromotionPiece.Rook
case Some("bishop") => PromotionPiece.Bishop case Some("bishop") => PromotionPiece.Bishop
case Some("knight") => PromotionPiece.Knight case Some("knight") => PromotionPiece.Knight
case _ => PromotionPiece.Queen // default case _ => PromotionPiece.Queen // default
Some(MoveType.Promotion(piece)) Some(MoveType.Promotion(piece))
case _ => None case _ => None
@@ -1,7 +1,7 @@
package de.nowchess.io package de.nowchess.io
import de.nowchess.api.game.GameContext 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.api.move.Move
import de.nowchess.io.json.{JsonExporter, JsonParser} import de.nowchess.io.json.{JsonExporter, JsonParser}
import java.nio.file.{Files, Paths} import java.nio.file.{Files, Paths}
@@ -15,13 +15,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val tmpFile = Files.createTempFile("chess_test_", ".json") val tmpFile = Files.createTempFile("chess_test_", ".json")
try try
val context = GameContext.initial val context = GameContext.initial
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter) val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
assert(result.isRight) assert(result.isRight)
assert(Files.exists(tmpFile)) assert(Files.exists(tmpFile))
assert(Files.size(tmpFile) > 0) assert(Files.size(tmpFile) > 0)
finally finally Files.deleteIfExists(tmpFile)
Files.deleteIfExists(tmpFile)
} }
test("loadGameFromFile: reads JSON file successfully") { test("loadGameFromFile: reads JSON file successfully") {
@@ -38,13 +37,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
assert(result.isRight) assert(result.isRight)
val loaded = result.getOrElse(GameContext.initial) val loaded = result.getOrElse(GameContext.initial)
assert(loaded == originalContext) assert(loaded == originalContext)
finally finally Files.deleteIfExists(tmpFile)
Files.deleteIfExists(tmpFile)
} }
test("loadGameFromFile: returns error on missing file") { test("loadGameFromFile: returns error on missing file") {
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json") 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) assert(result.isLeft)
} }
@@ -65,8 +63,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
assert(loadResult.isRight) assert(loadResult.isRight)
val loaded = loadResult.getOrElse(GameContext.initial) val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 2) assert(loaded.moves.length == 2)
finally finally Files.deleteIfExists(tmpFile)
Files.deleteIfExists(tmpFile)
} }
test("saveGameToFile: overwrites existing file") { test("saveGameToFile: overwrites existing file") {
@@ -78,7 +75,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val size1 = Files.size(tmpFile) val size1 = Files.size(tmpFile)
// Write second file (should overwrite) // 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) val context2 = GameContext.initial.withMove(move)
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter) FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
@@ -86,8 +83,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
assert(loadResult.isRight) assert(loadResult.isRight)
val loaded = loadResult.getOrElse(GameContext.initial) val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 1) assert(loaded.moves.length == 1)
finally finally Files.deleteIfExists(tmpFile)
Files.deleteIfExists(tmpFile)
} }
test("loadGameFromFile: handles invalid JSON in file") { test("loadGameFromFile: handles invalid JSON in file") {
@@ -97,8 +93,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser) val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(result.isLeft) assert(result.isLeft)
finally finally Files.deleteIfExists(tmpFile)
Files.deleteIfExists(tmpFile)
} }
test("round-trip: save and load preserves game state") { test("round-trip: save and load preserves game state") {
@@ -118,8 +113,7 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val loaded = loadResult.getOrElse(GameContext.initial) val loaded = loadResult.getOrElse(GameContext.initial)
assert(loaded.moves.length == 2) assert(loaded.moves.length == 2)
assert(loaded.halfMoveClock == 3) assert(loaded.halfMoveClock == 3)
finally finally Files.deleteIfExists(tmpFile)
Files.deleteIfExists(tmpFile)
} }
test("saveGameToFile: handles exporter that throws exception") { test("saveGameToFile: handles exporter that throws exception") {
@@ -134,6 +128,5 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter) val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
assert(result.isLeft) assert(result.isLeft)
assert(result.left.toOption.get.contains("Failed to save file")) assert(result.left.toOption.get.contains("Failed to save file"))
finally finally Files.deleteIfExists(tmpFile)
Files.deleteIfExists(tmpFile)
} }
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext 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 de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -13,71 +13,71 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
(PromotionPiece.Queen, "queen"), (PromotionPiece.Queen, "queen"),
(PromotionPiece.Rook, "rook"), (PromotionPiece.Rook, "rook"),
(PromotionPiece.Bishop, "bishop"), (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)) 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 // Empty boards can cause issues in PgnExporter, using initial
val ctx = GameContext.initial.copy(moves = List(move)) val ctx = GameContext.initial.copy(moves = List(move))
// try-catch to ignore PgnExporter errors but cover convertMoveType // try-catch to ignore PgnExporter errors but cover convertMoveType
try { try {
val json = JsonExporter.exportGameContext(ctx) val json = JsonExporter.exportGameContext(ctx)
json should include (s""""$expectedName"""") json should include(s""""$expectedName"""")
} catch { case _: Exception => } } catch { case _: Exception => }
} }
test("export normal non-capture move") { test("export normal non-capture move") {
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)) 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 ctx = GameContext.initial.copy(moves = List(quietMove))
val json = JsonExporter.exportGameContext(ctx) val json = JsonExporter.exportGameContext(ctx)
json should include ("\"normal\"") json should include("\"normal\"")
} }
test("export normal capture move manually") { test("export normal capture move manually") {
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true)) 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 { try {
val json = JsonExporter.exportGameContext(ctx) val json = JsonExporter.exportGameContext(ctx)
json should include ("\"normal\"") json should include("\"normal\"")
json should include ("\"isCapture\": true") json should include("\"isCapture\": true")
} catch { case _: Exception => } } catch { case _: Exception => }
} }
test("export all move type categories") { test("export all move type categories") {
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4)) 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) val json = JsonExporter.exportGameContext(ctx)
json should include ("\"moves\"") json should include("\"moves\"")
json should include ("\"from\"") json should include("\"from\"")
json should include ("\"to\"") json should include("\"to\"")
} }
test("export castle queenside move") { test("export castle queenside move") {
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside) 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 { try {
val json = JsonExporter.exportGameContext(ctx) val json = JsonExporter.exportGameContext(ctx)
json should include ("\"castleQueenside\"") json should include("\"castleQueenside\"")
} catch { case _: Exception => } } catch { case _: Exception => }
} }
test("export castle kingside move") { test("export castle kingside move") {
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside) 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 { try {
val json = JsonExporter.exportGameContext(ctx) val json = JsonExporter.exportGameContext(ctx)
json should include ("\"castleKingside\"") json should include("\"castleKingside\"")
} catch { case _: Exception => } } catch { case _: Exception => }
} }
test("export en passant move manually") { test("export en passant move manually") {
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) 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 { try {
val json = JsonExporter.exportGameContext(ctx) val json = JsonExporter.exportGameContext(ctx)
json should include ("\"enPassant\"") json should include("\"enPassant\"")
json should include ("\"isCapture\": true") json should include("\"isCapture\": true")
} catch { case _: Exception => } } catch { case _: Exception => }
} }
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext 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 de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -10,7 +10,7 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: exports initial position") { test("exportGameContext: exports initial position") {
val context = GameContext.initial val context = GameContext.initial
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"metadata\"") json should include("\"metadata\"")
json should include("\"gameState\"") json should include("\"gameState\"")
@@ -21,7 +21,7 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: includes board pieces") { test("exportGameContext: includes board pieces") {
val context = GameContext.initial val context = GameContext.initial
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"a1\"") json should include("\"a1\"")
json should include("\"Rook\"") json should include("\"Rook\"")
@@ -30,23 +30,23 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: includes turn information") { test("exportGameContext: includes turn information") {
val context = GameContext.initial val context = GameContext.initial
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"turn\": \"White\"") json should include("\"turn\": \"White\"")
} }
test("exportGameContext: includes castling rights") { test("exportGameContext: includes castling rights") {
val context = GameContext.initial val context = GameContext.initial
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"whiteKingSide\": true") json should include("\"whiteKingSide\": true")
json should include("\"whiteQueenSide\": true") json should include("\"whiteQueenSide\": true")
} }
test("exportGameContext: exports with moves") { 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 context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"moves\"") json should include("\"moves\"")
json should include("\"from\"") json should include("\"from\"")
@@ -57,7 +57,7 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: valid JSON structure") { test("exportGameContext: valid JSON structure") {
val context = GameContext.initial val context = GameContext.initial
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should startWith("{") json should startWith("{")
json should endWith("}") json should endWith("}")
@@ -67,46 +67,46 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: empty move history for initial position") { test("exportGameContext: empty move history for initial position") {
val context = GameContext.initial val context = GameContext.initial
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"moves\": []") json should include("\"moves\": []")
} }
test("exportGameContext: exports en passant square") { test("exportGameContext: exports en passant square") {
val epSquare = Some(Square(File.E, Rank.R3)) val epSquare = Some(Square(File.E, Rank.R3))
val context = GameContext.initial.copy(enPassantSquare = epSquare) val context = GameContext.initial.copy(enPassantSquare = epSquare)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"enPassantSquare\": \"e3\"") json should include("\"enPassantSquare\": \"e3\"")
} }
test("exportGameContext: exports null en passant square") { test("exportGameContext: exports null en passant square") {
val context = GameContext.initial.copy(enPassantSquare = None) val context = GameContext.initial.copy(enPassantSquare = None)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"enPassantSquare\": null") json should include("\"enPassantSquare\": null")
} }
test("exportGameContext: exports different move destinations") { 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 context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"moves\"") json should include("\"moves\"")
} }
test("exportGameContext: exports empty board") { test("exportGameContext: exports empty board") {
val emptyBoard = Board(Map.empty) val emptyBoard = Board(Map.empty)
val context = GameContext.initial.copy(board = emptyBoard) val context = GameContext.initial.copy(board = emptyBoard)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"board\": []") json should include("\"board\": []")
} }
test("exportGameContext: exports all castling rights disabled") { test("exportGameContext: exports all castling rights disabled") {
val noCastling = CastlingRights(false, false, false, false) val noCastling = CastlingRights(false, false, false, false)
val context = GameContext.initial.withCastlingRights(noCastling) val context = GameContext.initial.withCastlingRights(noCastling)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
json should include("\"whiteKingSide\": false") json should include("\"whiteKingSide\": false")
json should include("\"whiteQueenSide\": false") json should include("\"whiteQueenSide\": false")
@@ -40,7 +40,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
Some("White"), Some("White"),
Some(JsonCastlingRights()), Some(JsonCastlingRights()),
Some("e3"), Some("e3"),
Some(5) Some(5),
) )
assert(gs.board.contains(Nil)) assert(gs.board.contains(Nil))
assert(gs.halfMoveClock.contains(5)) assert(gs.halfMoveClock.contains(5))
@@ -88,7 +88,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
Some(""), Some(""),
Some(Nil), Some(Nil),
Some(JsonCapturedPieces()), Some(JsonCapturedPieces()),
Some("2026-04-08T00:00:00Z") Some("2026-04-08T00:00:00Z"),
) )
assert(record.metadata.nonEmpty) assert(record.metadata.nonEmpty)
assert(record.timestamp.nonEmpty) assert(record.timestamp.nonEmpty)
@@ -8,7 +8,7 @@ import org.scalatest.matchers.should.Matchers
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
test("parse invalid turn color returns error") { test("parse invalid turn color returns error") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"turn": "Invalid", "board": []}, "gameState": {"turn": "Invalid", "board": []},
"moves": [] "moves": []
@@ -19,7 +19,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
} }
test("parse invalid piece type filters it out") { test("parse invalid piece type filters it out") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": { "gameState": {
"turn": "White", "turn": "White",
@@ -36,7 +36,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
} }
test("parse invalid color in board filters piece") { test("parse invalid color in board filters piece") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": { "gameState": {
"turn": "White", "turn": "White",
@@ -53,7 +53,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
} }
test("parse with missing turn uses default") { test("parse with missing turn uses default") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"board": []}, "gameState": {"board": []},
"moves": [] "moves": []
@@ -65,7 +65,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
} }
test("parse with missing board uses empty") { test("parse with missing board uses empty") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"turn": "White"}, "gameState": {"turn": "White"},
"moves": [] "moves": []
@@ -77,7 +77,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
} }
test("parse with missing moves uses empty list") { test("parse with missing moves uses empty list") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"turn": "White", "board": []} "gameState": {"turn": "White", "board": []}
}""" }"""
@@ -88,7 +88,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
} }
test("parse invalid square in board filters it") { test("parse invalid square in board filters it") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": { "gameState": {
"turn": "White", "turn": "White",
@@ -105,7 +105,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
} }
test("parse all valid piece types") { test("parse all valid piece types") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": { "gameState": {
"turn": "White", "turn": "White",
@@ -124,11 +124,16 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
assert(result.isRight) assert(result.isRight)
val ctx = result.toOption.get val ctx = result.toOption.get
assert(ctx.board.pieces.size == 6) 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") { test("parse with all castling rights false") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": { "gameState": {
"turn": "White", "turn": "White",
@@ -8,7 +8,7 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
test("parse completely invalid JSON returns error") { test("parse completely invalid JSON returns error") {
val invalidJson = "{ this is not valid json at all }" val invalidJson = "{ this is not valid json at all }"
val result = JsonParser.importGameContext(invalidJson) val result = JsonParser.importGameContext(invalidJson)
assert(result.isLeft) assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error")) 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") { test("parse malformed JSON object returns error") {
val malformed = """{"metadata": {"unclosed": """ val malformed = """{"metadata": {"unclosed": """
val result = JsonParser.importGameContext(malformed) val result = JsonParser.importGameContext(malformed)
assert(result.isLeft) assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error")) assert(result.left.toOption.get.contains("JSON parsing error"))
} }
test("parse invalid JSON array returns error") { test("parse invalid JSON array returns error") {
val invalidArray = "[1, 2, 3" val invalidArray = "[1, 2, 3"
val result = JsonParser.importGameContext(invalidArray) val result = JsonParser.importGameContext(invalidArray)
assert(result.isLeft) assert(result.isLeft)
} }
test("parse JSON with missing required fields") { test("parse JSON with missing required fields") {
val json = """{"metadata": {}}""" val json = """{"metadata": {}}"""
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
// Should still succeed because all fields have defaults // Should still succeed because all fields have defaults
assert(result.isRight) assert(result.isRight)
} }
test("parse valid JSON with invalid turn falls back to default") { test("parse valid JSON with invalid turn falls back to default") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"turn": "White", "board": []}, "gameState": {"turn": "White", "board": []},
"moves": [] "moves": []
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext 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 de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers: class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
test("parse all move type variations") { test("parse all move type variations") {
val json = """{ val json = """{
"metadata": {"event": "Game", "result": "*"}, "metadata": {"event": "Game", "result": "*"},
"gameState": {"turn": "White", "board": []}, "gameState": {"turn": "White", "board": []},
"moves": [ "moves": [
@@ -34,7 +34,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
} }
test("parse invalid move type defaults to None") { test("parse invalid move type defaults to None") {
val json = """{ val json = """{
"metadata": {"event": "Game"}, "metadata": {"event": "Game"},
"gameState": {"turn": "White", "board": []}, "gameState": {"turn": "White", "board": []},
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}] "moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
@@ -45,7 +45,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
} }
test("parse promotion with default piece") { test("parse promotion with default piece") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"turn": "White", "board": []}, "gameState": {"turn": "White", "board": []},
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}] "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") { test("parse move with missing from/to skips it") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"turn": "White", "board": []}, "gameState": {"turn": "White", "board": []},
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}] "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") { test("parse with invalid JSON returns error") {
val json = """{"invalid json""" val json = """{"invalid json"""
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.isLeft) assert(result.isLeft)
} }
test("parse normal move with isCapture true") { test("parse normal move with isCapture true") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": {"turn": "White", "board": []}, "gameState": {"turn": "White", "board": []},
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}] "moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
}""" }"""
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.isRight) assert(result.isRight)
val ctx = result.toOption.get val ctx = result.toOption.get
val move = ctx.moves.head val move = ctx.moves.head
assert(move.moveType == MoveType.Normal(true)) assert(move.moveType == MoveType.Normal(true))
} }
test("parse board with invalid pieces filters them") { test("parse board with invalid pieces filters them") {
val json = """{ val json = """{
"metadata": {}, "metadata": {},
"gameState": { "gameState": {
"turn": "White", "turn": "White",
@@ -1,7 +1,7 @@
package de.nowchess.io.json package de.nowchess.io.json
import de.nowchess.api.game.GameContext 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 de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
class JsonParserSuite extends AnyFunSuite with Matchers: class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: parses valid JSON") { test("importGameContext: parses valid JSON") {
val json = JsonExporter.exportGameContext(GameContext.initial) val json = JsonExporter.exportGameContext(GameContext.initial)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.isRight) assert(result.isRight)
@@ -17,31 +17,31 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: restores board state") { test("importGameContext: restores board state") {
val context = GameContext.initial val context = GameContext.initial
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result == Right(context)) assert(result == Right(context))
} }
test("importGameContext: restores turn") { test("importGameContext: restores turn") {
val context = GameContext.initial.withTurn(Color.Black) val context = GameContext.initial.withTurn(Color.Black)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.turn) == Right(Color.Black)) assert(result.map(_.turn) == Right(Color.Black))
} }
test("importGameContext: restores moves") { 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 context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.moves.length) == Right(1)) assert(result.map(_.moves.length) == Right(1))
} }
test("importGameContext: handles empty board") { test("importGameContext: handles empty board") {
val json = """{ val json = """{
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"}, "metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
"gameState": { "gameState": {
"board": [], "board": [],
@@ -68,7 +68,8 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
} }
test("importGameContext: handles missing fields with defaults") { 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) val result = JsonParser.importGameContext(json)
assert(result.isRight) assert(result.isRight)
@@ -76,9 +77,9 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: handles castling rights") { test("importGameContext: handles castling rights") {
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false) val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
val context = GameContext.initial.withCastlingRights(newCastling) val context = GameContext.initial.withCastlingRights(newCastling)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.castlingRights.whiteKingSide) == Right(false)) assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
} }
@@ -91,7 +92,7 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
.withMove(move2) .withMove(move2)
.withTurn(Color.White) .withTurn(Color.White)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val restored = JsonParser.importGameContext(json) val restored = JsonParser.importGameContext(json)
assert(restored.map(_.moves.length) == Right(2)) assert(restored.map(_.moves.length) == Right(2))
@@ -100,8 +101,8 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: handles half-move clock") { test("importGameContext: handles half-move clock") {
val context = GameContext.initial.withHalfMoveClock(5) val context = GameContext.initial.withHalfMoveClock(5)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.halfMoveClock) == Right(5)) assert(result.map(_.halfMoveClock) == Right(5))
} }
@@ -109,27 +110,27 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: parses en passant square") { test("importGameContext: parses en passant square") {
// Create a context with en passant square // Create a context with en passant square
val epSquare = Some(Square(File.E, Rank.R3)) val epSquare = Some(Square(File.E, Rank.R3))
val context = GameContext.initial.copy(enPassantSquare = epSquare) val context = GameContext.initial.copy(enPassantSquare = epSquare)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.enPassantSquare) == Right(epSquare)) assert(result.map(_.enPassantSquare) == Right(epSquare))
} }
test("importGameContext: handles black turn") { test("importGameContext: handles black turn") {
val context = GameContext.initial.withTurn(Color.Black) val context = GameContext.initial.withTurn(Color.Black)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.turn) == Right(Color.Black)) assert(result.map(_.turn) == Right(Color.Black))
} }
test("importGameContext: preserves basic moves in JSON round-trip") { test("importGameContext: preserves basic moves in JSON round-trip") {
// Use simple move without explicit moveType to let system handle it // 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 context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.isRight) assert(result.isRight)
assert(result.map(_.moves.length) == Right(1)) assert(result.map(_.moves.length) == Right(1))
@@ -137,18 +138,18 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: handles all castling rights disabled") { test("importGameContext: handles all castling rights disabled") {
val noCastling = CastlingRights(false, false, false, false) val noCastling = CastlingRights(false, false, false, false)
val context = GameContext.initial.withCastlingRights(noCastling) val context = GameContext.initial.withCastlingRights(noCastling)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.castlingRights) == Right(noCastling)) assert(result.map(_.castlingRights) == Right(noCastling))
} }
test("importGameContext: handles mixed castling rights") { 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 context = GameContext.initial.withCastlingRights(mixed)
val json = JsonExporter.exportGameContext(context) val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json) val result = JsonParser.importGameContext(json)
assert(result.map(_.castlingRights) == Right(mixed)) assert(result.map(_.castlingRights) == Right(mixed))
} }
@@ -17,30 +17,29 @@ import de.nowchess.chess.engine.GameEngine
import de.nowchess.io.fen.{FenExporter, FenParser} import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser} import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.json.{JsonExporter, JsonParser} import de.nowchess.io.json.{JsonExporter, JsonParser}
import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService} import de.nowchess.io.{FileSystemGameService, GameContextExport, GameContextImport, GameFileService}
import java.nio.file.Paths import java.nio.file.Paths
import scalafx.stage.FileChooser import scalafx.stage.FileChooser
import scalafx.stage.FileChooser.ExtensionFilter import scalafx.stage.FileChooser.ExtensionFilter
/** ScalaFX chess board view that displays the game state. /** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
* Uses chess sprites and color palette. * interactions (clicks) and sends moves to GameEngine.
* Handles user interactions (clicks) and sends moves to GameEngine. */
*/
class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane: class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends BorderPane:
private val squareSize = 70.0 private val squareSize = 70.0
private val comicSansFontFamily = "Comic Sans MS" private val comicSansFontFamily = "Comic Sans MS"
private val boardGrid = new GridPane() private val boardGrid = new GridPane()
private val messageLabel = new Label { private val messageLabel = new Label {
text = "Welcome!" text = "Welcome!"
font = Font.font(comicSansFontFamily, 16) font = Font.font(comicSansFontFamily, 16)
padding = Insets(10) padding = Insets(10)
} }
private var currentBoard: Board = engine.board private var currentBoard: Board = engine.board
private var currentTurn: Color = engine.turn private var currentTurn: Color = engine.turn
private var selectedSquare: Option[Square] = None private var selectedSquare: Option[Square] = None
private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]() private val squareViews = scala.collection.mutable.Map[(Int, Int), StackPane]()
private var undoButton: Button = uninitialized private var undoButton: Button = uninitialized
private var redoButton: Button = uninitialized private var redoButton: Button = uninitialized
@@ -58,7 +57,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 24) font = Font.font(comicSansFontFamily, 24)
style = "-fx-font-weight: bold;" style = "-fx-font-weight: bold;"
}, },
messageLabel messageLabel,
) )
} }
@@ -86,8 +85,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
disable = !engine.canUndo disable = !engine.canUndo
} }
undoButton undoButton
}, }, {
{
redoButton = new Button("Redo") { redoButton = new Button("Redo") {
font = Font.font(comicSansFontFamily, 12) font = Font.font(comicSansFontFamily, 12)
onAction = _ => if engine.canRedo then engine.redo() onAction = _ => if engine.canRedo then engine.redo()
@@ -100,7 +98,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12) font = Font.font(comicSansFontFamily, 12)
onAction = _ => engine.reset() onAction = _ => engine.reset()
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;" style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
} },
) )
}, },
new HBox { new HBox {
@@ -126,7 +124,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12) font = Font.font(comicSansFontFamily, 12)
onAction = _ => doPgnImport() onAction = _ => doPgnImport()
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;" style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
} },
) )
}, },
new HBox { new HBox {
@@ -142,9 +140,9 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
font = Font.font(comicSansFontFamily, 12) font = Font.font(comicSansFontFamily, 12)
onAction = _ => doJsonImport() onAction = _ => doJsonImport()
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;" style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
} },
) )
} },
) )
} }
@@ -165,7 +163,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
updateBoard(currentBoard, currentTurn) updateBoard(currentBoard, currentTurn)
private def createSquare(rank: Int, file: Int): StackPane = private def createSquare(rank: Int, file: Int): StackPane =
val isWhite = (rank + file) % 2 == 0 val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle { val bgRect = new Rectangle {
@@ -185,8 +183,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
square square
private def handleSquareClick(rank: Int, file: Int): Unit = private def handleSquareClick(rank: Int, file: Int): Unit =
if engine.isPendingPromotion then if engine.isPendingPromotion then return // Don't allow moves during promotion
return // Don't allow moves during promotion
val clickedSquare = Square(File.values(file), Rank.values(rank)) val clickedSquare = Square(File.values(file), Rank.values(rank))
@@ -198,10 +195,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
selectedSquare = Some(clickedSquare) selectedSquare = Some(clickedSquare)
highlightSquare(rank, file, PieceSprites.SquareColors.Selected) highlightSquare(rank, file, PieceSprites.SquareColors.Selected)
val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare) val legalDests = engine.ruleSet
.collect { case move if move.from == clickedSquare => move.to } .legalMoves(engine.context)(clickedSquare)
.collect { case move if move.from == clickedSquare => move.to }
legalDests.foreach { sq => legalDests.foreach { sq =>
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
} }
} }
@@ -228,7 +226,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
file <- 0 until 8 file <- 0 until 8
do do
squareViews.get((rank, file)).foreach { stackPane => squareViews.get((rank, file)).foreach { stackPane =>
val isWhite = (rank + file) % 2 == 0 val isWhite = (rank + file) % 2 == 0
val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black val baseColor = if isWhite then PieceSprites.SquareColors.White else PieceSprites.SquareColors.Black
val bgRect = new Rectangle { val bgRect = new Rectangle {
@@ -239,7 +237,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcHeight = 8 arcHeight = 8
} }
val square = Square(File.values(file), Rank.values(rank)) val square = Square(File.values(file), Rank.values(rank))
val pieceOption = board.pieceAt(square) val pieceOption = board.pieceAt(square)
val children = pieceOption match val children = pieceOption match
@@ -267,7 +265,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
arcHeight = 8 arcHeight = 8
} }
val square = Square(File.values(file), Rank.values(rank)) val square = Square(File.values(file), Rank.values(rank))
val pieceOption = currentBoard.pieceAt(square) val pieceOption = currentBoard.pieceAt(square)
stackPane.children = pieceOption match stackPane.children = pieceOption match
@@ -291,11 +289,11 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
val result = dialog.showAndWait() val result = dialog.showAndWait()
result match result match
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen) case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook) case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop) case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight) case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
case _ => engine.completePromotion(PromotionPiece.Queen) // Default case _ => engine.completePromotion(PromotionPiece.Queen) // Default
private def doFenExport(): Unit = private def doFenExport(): Unit =
doExport(FenExporter, "FEN") doExport(FenExporter, "FEN")
@@ -322,10 +320,10 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
val result = FileSystemGameService.saveGameToFile( val result = FileSystemGameService.saveGameToFile(
engine.context, engine.context,
selectedFile.toPath, selectedFile.toPath,
JsonExporter JsonExporter,
) )
result match result match
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}") case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
case Left(err) => showMessage(s"⚠️ Error saving file: $err") case Left(err) => showMessage(s"⚠️ Error saving file: $err")
private def doJsonImport(): Unit = private def doJsonImport(): Unit =
@@ -339,7 +337,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
if selectedFile != null then if selectedFile != null then
val result = FileSystemGameService.loadGameFromFile( val result = FileSystemGameService.loadGameFromFile(
selectedFile.toPath, selectedFile.toPath,
JsonParser JsonParser,
) )
result match result match
case Right(gameContext) => case Right(gameContext) =>
@@ -353,7 +351,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
showCopyDialog(s"$formatName Export", exported) showCopyDialog(s"$formatName Export", exported)
} }
private def doImport(importer: GameContextImport, formatName: String): Unit = { private def doImport(importer: GameContextImport, formatName: String): Unit =
showInputDialog(s"$formatName Import", rows = 5).foreach { input => showInputDialog(s"$formatName Import", rows = 5).foreach { input =>
importer.importGameContext(input) match importer.importGameContext(input) match
case Right(gameContext) => case Right(gameContext) =>
@@ -362,7 +360,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
case Left(err) => case Left(err) =>
showMessage(s"⚠️ $formatName Error: $err") showMessage(s"⚠️ $formatName Error: $err")
} }
}
private def showCopyDialog(title: String, content: String): Unit = private def showCopyDialog(title: String, content: String): Unit =
val area = new javafx.scene.control.TextArea(content) val area = new javafx.scene.control.TextArea(content)
@@ -386,7 +383,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.getDialogPane.setContent(area) dialog.getDialogPane.setContent(area)
dialog.getDialogPane.getButtonTypes.addAll( dialog.getDialogPane.getButtonTypes.addAll(
javafx.scene.control.ButtonType.OK, javafx.scene.control.ButtonType.OK,
javafx.scene.control.ButtonType.CANCEL javafx.scene.control.ButtonType.CANCEL,
) )
dialog.setResultConverter { bt => dialog.setResultConverter { bt =>
if bt == javafx.scene.control.ButtonType.OK then area.getText else null if bt == javafx.scene.control.ButtonType.OK then area.getText else null
@@ -394,4 +391,3 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
dialog.initOwner(stage.delegate) dialog.initOwner(stage.delegate)
val result = dialog.showAndWait() val result = dialog.showAndWait()
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None