feat: I/O json export import, tests should be 100%
Build & Test (NowChessSystems) TeamCity build finished

This commit is contained in:
shahdlala66
2026-04-06 21:57:09 +02:00
parent 638139602c
commit 33c0260b75
11 changed files with 1003 additions and 1 deletions
@@ -0,0 +1,39 @@
package de.nowchess.io
import de.nowchess.api.game.GameContext
import java.nio.file.{Files, Path}
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.
*/
trait GameFileService:
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
/** Default implementation using the file system. */
object FileSystemGameService extends GameFileService:
/** Save a game context to a file using the specified exporter. */
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] =
Try {
val json = exporter.exportGameContext(context)
Files.write(path, json.getBytes(StandardCharsets.UTF_8))
()
}.fold(
ex => Left(s"Failed to save file: ${ex.getMessage}"),
_ => Right(())
)
/** Load a game context from a file using the specified importer. */
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] =
Try {
val json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8)
importer.importGameContext(json)
}.fold(
ex => Left(s"Failed to load file: ${ex.getMessage}"),
result => result
)
@@ -0,0 +1,163 @@
package de.nowchess.io.json
import de.nowchess.api.board.*
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.Instant
import scala.collection.mutable
/** Exports a GameContext to a comprehensive JSON format.
*
* 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:
def exportGameContext(context: GameContext): String =
val metadata = buildMetadata()
val gameState = buildGameState(context)
val pgnMoves = PgnExporter.exportGameContext(context)
val moves = exportMoves(context.moves)
val captured = exportCapturedPieces(context.board)
val timestamp = buildTimestamp()
s"""{
"metadata": $metadata,
"gameState": $gameState,
"moveHistory": "${escapePgn(pgnMoves)}",
"moves": $moves,
"capturedPieces": $captured,
"timestamp": "$timestamp"
}"""
/** Build metadata section. */
private def buildMetadata(): String =
s"""{
"event": "Game",
"players": {
"white": "White Player",
"black": "Black Player"
},
"date": "${java.time.LocalDate.now}",
"result": "*"
}"""
/** Build gameState section. */
private def buildGameState(context: GameContext): String =
s"""{
"board": ${exportBoard(context.board)},
"turn": "${context.turn.label}",
"castlingRights": ${exportCastlingRights(context.castlingRights)},
"enPassantSquare": ${exportEnPassant(context.enPassantSquare)},
"halfMoveClock": ${context.halfMoveClock}
}"""
/** Build ISO 8601 timestamp. */
private def buildTimestamp(): String =
java.time.ZonedDateTime.now(java.time.ZoneId.of("UTC")).toString
/** Export board as a list of piece placements. */
private def exportBoard(board: Board): String =
val pieces = board.pieces.toList.map { case (square, piece) =>
s"""{"square": "${square}", "color": "${piece.color.label}", "piece": "${piece.pieceType.label}"}"""
}
if pieces.isEmpty then "[]" else s"[${pieces.mkString(", ")}]"
/** Export castling rights. */
private def exportCastlingRights(rights: CastlingRights): String =
s"""{
"whiteKingSide": ${rights.whiteKingSide},
"whiteQueenSide": ${rights.whiteQueenSide},
"blackKingSide": ${rights.blackKingSide},
"blackQueenSide": ${rights.blackQueenSide}
}"""
/** Export en passant square. */
private def exportEnPassant(epSquare: Option[Square]): String =
epSquare match
case Some(sq) => s""""${sq.toString}""""
case None => "null"
/** Export captured pieces. */
private def exportCapturedPieces(board: Board): String =
val initialBoard = Board.initial
val allSquares = Square.all
// Find captured pieces by comparing with initial position
var whiteCaptured = List.empty[String]
var blackCaptured = List.empty[String]
for square <- allSquares do
initialBoard.pieceAt(square) match
case Some(initialPiece) =>
board.pieceAt(square) match
case None =>
// Piece was captured
val pieceName = initialPiece.pieceType.label
if initialPiece.color == Color.White then
whiteCaptured = whiteCaptured :+ pieceName
else
blackCaptured = blackCaptured :+ pieceName
case Some(_) => // Piece still there or promoted
case None => // Square was empty initially
val whiteList = if whiteCaptured.isEmpty then "[]"
else s"[${whiteCaptured.map(p => s""""$p"""").mkString(", ")}]"
val blackList = if blackCaptured.isEmpty then "[]"
else s"[${blackCaptured.map(p => s""""$p"""").mkString(", ")}]"
s"""{
"byWhite": $blackList,
"byBlack": $whiteList
}"""
/** Export moves as an array of move objects. */
private def exportMoves(moves: List[Move]): String =
val moveObjects = moves.map { move =>
val from = move.from.toString
val to = move.to.toString
val moveTypeJson = exportMoveType(move.moveType)
s""" {
"from": "$from",
"to": "$to",
"type": $moveTypeJson
}"""
}
if moveObjects.isEmpty then "[]"
else s"[\n${moveObjects.mkString(",\n")}\n ]"
/** Export move type as a JSON object. */
private def exportMoveType(moveType: MoveType): String =
moveType match
case MoveType.Normal(isCapture) =>
s"""{"type": "normal", "isCapture": $isCapture}"""
case MoveType.CastleKingside =>
s"""{"type": "castleKingside"}"""
case MoveType.CastleQueenside =>
s"""{"type": "castleQueenside"}"""
case MoveType.EnPassant =>
s"""{"type": "enPassant"}"""
case MoveType.Promotion(piece) =>
val pieceName = piece match
case PromotionPiece.Queen => "queen"
case PromotionPiece.Rook => "rook"
case PromotionPiece.Bishop => "bishop"
case PromotionPiece.Knight => "knight"
s"""{"type": "promotion", "promotionPiece": "$pieceName"}"""
/** Escape PGN text for JSON string. */
private def escapePgn(pgn: String): String =
pgn.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
@@ -0,0 +1,308 @@
package de.nowchess.io.json
import de.nowchess.api.board.*
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.io.GameContextImport
import scala.util.{Try, Success, Failure}
/** Imports a GameContext from JSON format.
*
* 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:
def importGameContext(input: String): Either[String, GameContext] =
for
data <- parseRootObject(input)
gameStateJson <- extractGameState(data)
gameStateData <- parseGameStateObject(gameStateJson)
context <- assembleGameContext(gameStateData, data)
yield context
/** Parse the root JSON object. */
private def parseRootObject(input: String): Either[String, Map[String, String]] =
Try {
parseJsonObject(input.trim)
}.toEither.left.map("JSON parsing error: " + _.getMessage)
/** Extract gameState field (new format) or use old format. */
private def extractGameState(data: Map[String, String]): Either[String, String] =
Right(data.getOrElse("gameState", "{\"board\": [], \"turn\": \"White\", \"castlingRights\": {}, \"enPassantSquare\": \"null\", \"halfMoveClock\": \"0\"}"))
/** Parse the gameState object. */
private def parseGameStateObject(json: String): Either[String, Map[String, String]] =
Try {
parseJsonObject(json)
}.toEither.left.map("Failed to parse gameState: " + _.getMessage)
/** Assemble the complete GameContext from parsed data. */
private def assembleGameContext(gameStateData: Map[String, String], rootData: Map[String, String]): Either[String, GameContext] =
for
board <- parseBoard(gameStateData.getOrElse("board", "[]"))
turn <- parseTurn(gameStateData.getOrElse("turn", "White"))
castling <- parseCastlingRights(gameStateData.getOrElse("castlingRights", "{}"))
enPassant <- parseEnPassant(gameStateData.getOrElse("enPassantSquare", "null"))
halfMoveClock <- Try(gameStateData.getOrElse("halfMoveClock", "0").toInt)
.toEither.left.map("Invalid halfMoveClock: " + _.getMessage)
moves <- parseMoveHistory(rootData.getOrElse("moves", "[]"))
yield GameContext(
board = board,
turn = turn,
castlingRights = castling,
enPassantSquare = enPassant,
halfMoveClock = halfMoveClock,
moves = moves
)
/** Parse a simple JSON object into a Map of field names to JSON values. */
private def parseJsonObject(json: String): Map[String, String] =
val startIdx = json.indexOf('{')
if startIdx == -1 then Map.empty
else
val (obj, _) = extractJsonObject(json.substring(startIdx))
if obj.length < 2 then Map.empty
else
val content = obj.substring(1, obj.length - 1).trim
parseFields(content, Map.empty)
/** Recursively parse fields from JSON object content. */
private def parseFields(remaining: String, acc: Map[String, String]): Map[String, String] =
if remaining.isEmpty then acc
else
val colonIdx = remaining.indexOf(':')
if colonIdx == -1 then acc
else
val beforeColon = remaining.substring(0, colonIdx).trim
val key = beforeColon.replaceAll("""^[,\s"]+|["\s]+$""", "")
val afterColon = remaining.substring(colonIdx + 1).trim
val (value, rest) = extractJsonValue(afterColon)
if key.nonEmpty && value.nonEmpty then
parseFields(rest, acc + (key -> value))
else
parseFields(rest, acc)
/** Extract the next JSON value and return (value, remainder). */
private def extractJsonValue(json: String): (String, String) =
if json.startsWith("{") then extractJsonObject(json)
else if json.startsWith("[") then extractJsonArray(json)
else if json.startsWith("\"") then extractJsonString(json)
else extractJsonLiteral(json)
/** Extract a JSON object {...}. */
private def extractJsonObject(json: String): (String, String) =
var depth = 0
var i = 0
var inString = false
var escape = false
while i < json.length do
val ch = json.charAt(i)
if escape then
escape = false
else if ch == '\\' then
escape = true
else if ch == '"' then
inString = !inString
else if !inString then
if ch == '{' then depth += 1
else if ch == '}' then
depth -= 1
if depth == 0 then
return (json.substring(0, i + 1), json.substring(i + 1).dropWhile(c => c == ',' || c.isWhitespace))
i += 1
(json, "")
/** Extract a JSON array [...]. */
private def extractJsonArray(json: String): (String, String) =
var depth = 0
var i = 0
var inString = false
var escape = false
while i < json.length do
val ch = json.charAt(i)
if escape then
escape = false
else if ch == '\\' then
escape = true
else if ch == '"' then
inString = !inString
else if !inString then
if ch == '[' then depth += 1
else if ch == ']' then
depth -= 1
if depth == 0 then
return (json.substring(0, i + 1), json.substring(i + 1).dropWhile(c => c == ',' || c.isWhitespace))
i += 1
(json, "")
/** Extract a JSON string "...". */
private def extractJsonString(json: String): (String, String) =
var i = 1
var escape = false
while i < json.length do
val ch = json.charAt(i)
if escape then
escape = false
else if ch == '\\' then
escape = true
else if ch == '"' then
return (json.substring(0, i + 1), json.substring(i + 1).dropWhile(c => c == ',' || c.isWhitespace))
i += 1
(json, "")
/** Extract a JSON literal (number, true, false, null). */
private def extractJsonLiteral(json: String): (String, String) =
val endIdx = json.indexWhere(c => c == ',' || c == '}' || c == ']' || c.isWhitespace)
if endIdx == -1 then
(json, "")
else
(json.substring(0, endIdx), json.substring(endIdx).dropWhile(c => c == ',' || c.isWhitespace))
/** Parse board array. */
private def parseBoard(json: String): Either[String, Board] =
Try {
val clean = json.trim.drop(1).dropRight(1) // Remove [ and ]
if clean.isEmpty then Board(Map.empty)
else
val pieces = scala.collection.mutable.Map[Square, Piece]()
var remaining = clean
while remaining.contains("{") do
val startIdx = remaining.indexOf("{")
val (obj, rest) = extractJsonObject(remaining.substring(startIdx))
val fields = parseJsonObject(obj)
for
squareStr <- fields.get("square")
colorStr <- fields.get("color")
pieceStr <- fields.get("piece")
square <- Square.fromAlgebraic(unquote(squareStr))
color <- parseColorValueOption(unquote(colorStr))
pieceType <- parsePieceTypeValueOption(unquote(pieceStr))
do
pieces(square) = Piece(color, pieceType)
remaining = rest
Board(pieces.toMap)
}.toEither.left.map("Failed to parse board: " + _.getMessage)
/** Parse turn field. */
private def parseTurn(json: String): Either[String, Color] =
Try {
val color = unquote(json.trim)
if color == "White" then Color.White
else if color == "Black" then Color.Black
else throw new Exception(s"Invalid turn color: $color")
}.toEither.left.map("Failed to parse turn: " + _.getMessage)
/** Parse castling rights object. */
private def parseCastlingRights(json: String): Either[String, CastlingRights] =
Try {
val fields = parseJsonObject(json)
val wk = fields.get("whiteKingSide").map(_.toLowerCase == "true").getOrElse(false)
val wq = fields.get("whiteQueenSide").map(_.toLowerCase == "true").getOrElse(false)
val bk = fields.get("blackKingSide").map(_.toLowerCase == "true").getOrElse(false)
val bq = fields.get("blackQueenSide").map(_.toLowerCase == "true").getOrElse(false)
CastlingRights(wk, wq, bk, bq)
}.toEither.left.map("Failed to parse castling rights: " + _.getMessage)
/** Parse en passant square (string or null). */
private def parseEnPassant(json: String): Either[String, Option[Square]] =
Try {
val trimmed = json.trim
if trimmed == "null" then None: Option[Square]
else
val squareStr = unquote(trimmed)
Square.fromAlgebraic(squareStr)
}.toEither.left.map("Failed to parse en passant: " + _.getMessage)
/** Parse move history array. */
private def parseMoveHistory(json: String): Either[String, List[Move]] =
Try {
val clean = json.trim.drop(1).dropRight(1) // Remove [ and ]
if clean.isEmpty then List.empty
else
val moves = scala.collection.mutable.ListBuffer[Move]()
var remaining = clean
while remaining.contains("{") do
val startIdx = remaining.indexOf("{")
val (obj, rest) = extractJsonObject(remaining.substring(startIdx))
val fields = parseJsonObject(obj)
for
fromStr <- fields.get("from")
toStr <- fields.get("to")
typeJson <- fields.get("type")
from <- Square.fromAlgebraic(unquote(fromStr))
to <- Square.fromAlgebraic(unquote(toStr))
moveType <- parseMoveTypeUnsafe(typeJson)
do
moves += Move(from, to, moveType)
remaining = rest
moves.toList
}.toEither.left.map("Failed to parse move history: " + _.getMessage)
/** Parse move type object (unsafe version that throws on error for use in Try). */
private def parseMoveTypeUnsafe(json: String): Option[MoveType] =
val fields = parseJsonObject(json)
fields.get("type").map(unquote(_)) match
case Some("normal") =>
val isCapture = fields.get("isCapture").map(_.toLowerCase == "true").getOrElse(false)
Some(MoveType.Normal(isCapture))
case Some("castleKingside") =>
Some(MoveType.CastleKingside)
case Some("castleQueenside") =>
Some(MoveType.CastleQueenside)
case Some("enPassant") =>
Some(MoveType.EnPassant)
case Some("promotion") =>
val piece = fields.get("promotionPiece").map(unquote(_)) match
case Some("queen") => PromotionPiece.Queen
case Some("rook") => PromotionPiece.Rook
case Some("bishop") => PromotionPiece.Bishop
case Some("knight") => PromotionPiece.Knight
case _ => PromotionPiece.Queen
Some(MoveType.Promotion(piece))
case Some(unknown) =>
None
case None =>
None
/** Parse a color value (Option version for use in for-comprehension). */
private def parseColorValueOption(colorStr: String): Option[Color] =
if colorStr == "White" then Some(Color.White)
else if colorStr == "Black" then Some(Color.Black)
else None
/** Parse a piece type value (Option version for use in for-comprehension). */
private def parsePieceTypeValueOption(pieceStr: String): Option[PieceType] =
pieceStr match
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
/** Remove surrounding quotes from a JSON string value. */
private def unquote(s: String): String =
s.trim.dropWhile(_ == '"').dropWhile(_ == '\'').takeWhile(_ != '"').takeWhile(_ != '\'')
@@ -0,0 +1,123 @@
package de.nowchess.io
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.move.Move
import de.nowchess.io.json.{JsonExporter, JsonParser}
import java.nio.file.{Files, Paths}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scala.util.Using
class GameFileServiceSuite extends AnyFunSuite with Matchers:
test("saveGameToFile: writes JSON file successfully") {
val tmpFile = Files.createTempFile("chess_test_", ".json")
try
val context = GameContext.initial
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
assert(result.isRight)
assert(Files.exists(tmpFile))
assert(Files.size(tmpFile) > 0)
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)
}
test("loadGameFromFile: returns error on missing file") {
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json")
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
assert(result.isLeft)
}
test("saveGameToFile: persists game with moves") {
val tmpFile = Files.createTempFile("chess_test_moves_", ".json")
try
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
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)
}
test("saveGameToFile: overwrites existing file") {
val tmpFile = Files.createTempFile("chess_test_overwrite_", ".json")
try
// Write first file
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 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)
}
test("loadGameFromFile: handles invalid JSON in file") {
val tmpFile = Files.createTempFile("chess_test_invalid_", ".json")
try
Files.write(tmpFile, "{ invalid json}".getBytes())
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
assert(result.isLeft)
finally
Files.deleteIfExists(tmpFile)
}
test("round-trip: save and load preserves game state") {
val tmpFile = Files.createTempFile("chess_test_roundtrip_", ".json")
try
val move1 = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R4))
val move2 = Move(Square(File.H, Rank.R7), Square(File.H, Rank.R5))
val original = GameContext.initial
.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)
}
@@ -0,0 +1,115 @@
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.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonExporterSuite extends AnyFunSuite with Matchers:
test("exportGameContext: exports initial position") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
json should include("\"metadata\"")
json should include("\"gameState\"")
json should include("\"moveHistory\"")
json should include("\"capturedPieces\"")
json should include("\"timestamp\"")
}
test("exportGameContext: includes board pieces") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
json should include("\"a1\"")
json should include("\"Rook\"")
json should include("\"White\"")
}
test("exportGameContext: includes turn information") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
json should include("\"turn\": \"White\"")
}
test("exportGameContext: includes castling rights") {
val context = GameContext.initial
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 context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context)
json should include("\"moves\"")
json should include("\"from\"")
json should include("\"to\"")
json should include("\"e2\"")
json should include("\"e4\"")
}
test("exportGameContext: valid JSON structure") {
val context = GameContext.initial
val json = JsonExporter.exportGameContext(context)
json should startWith("{")
json should endWith("}")
json should include("\"metadata\": {")
json should include("\"gameState\": {")
}
test("exportGameContext: empty move history for initial position") {
val context = GameContext.initial
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)
json should include("\"enPassantSquare\": \"e3\"")
}
test("exportGameContext: exports null en passant square") {
val context = GameContext.initial.copy(enPassantSquare = None)
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 context = GameContext.initial.withMove(move)
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)
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)
json should include("\"whiteKingSide\": false")
json should include("\"whiteQueenSide\": false")
json should include("\"blackKingSide\": false")
json should include("\"blackQueenSide\": false")
}
@@ -0,0 +1,154 @@
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.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonParserSuite extends AnyFunSuite with Matchers:
test("importGameContext: parses valid JSON") {
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)
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)
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 context = GameContext.initial.withMove(move)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.moves.length) == Right(1))
}
test("importGameContext: handles empty board") {
val json = """{
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
"gameState": {
"board": [],
"turn": "White",
"castlingRights": {"whiteKingSide": true, "whiteQueenSide": true, "blackKingSide": true, "blackQueenSide": true},
"enPassantSquare": null,
"halfMoveClock": 0
},
"moves": [],
"moveHistory": "",
"capturedPieces": {"byWhite": [], "byBlack": []},
"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 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)
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
}
test("importGameContext: round-trip consistency") {
val move1 = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val move2 = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5))
val context = GameContext.initial
.withMove(move1)
.withMove(move2)
.withTurn(Color.White)
val json = JsonExporter.exportGameContext(context)
val restored = JsonParser.importGameContext(json)
assert(restored.map(_.moves.length) == Right(2))
assert(restored.map(_.turn) == Right(Color.White))
}
test("importGameContext: handles half-move clock") {
val context = GameContext.initial.withHalfMoveClock(5)
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)
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)
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 context = GameContext.initial.withMove(move)
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)
assert(result.map(_.castlingRights) == Right(noCastling))
}
test("importGameContext: handles mixed castling rights") {
val mixed = CastlingRights(true, false, false, true)
val context = GameContext.initial.withCastlingRights(mixed)
val json = JsonExporter.exportGameContext(context)
val result = JsonParser.importGameContext(json)
assert(result.map(_.castlingRights) == Right(mixed))
}
+41
View File
@@ -0,0 +1,41 @@
{
"metadata": {
"event": "Game",
"players": {
"white": "White Player",
"black": "Black Player"
},
"date": "2026-04-06",
"result": "*"
},
"gameState": {
"board": [{"square": "g8", "color": "Black", "piece": "Knight"}, {"square": "d8", "color": "Black", "piece": "Queen"}, {"square": "f8", "color": "Black", "piece": "Bishop"}, {"square": "b2", "color": "White", "piece": "Pawn"}, {"square": "c1", "color": "White", "piece": "Bishop"}, {"square": "c7", "color": "Black", "piece": "Pawn"}, {"square": "f7", "color": "Black", "piece": "Pawn"}, {"square": "b7", "color": "Black", "piece": "Pawn"}, {"square": "b1", "color": "White", "piece": "Knight"}, {"square": "h8", "color": "Black", "piece": "Rook"}, {"square": "a1", "color": "White", "piece": "Rook"}, {"square": "g2", "color": "White", "piece": "Pawn"}, {"square": "e1", "color": "White", "piece": "King"}, {"square": "c2", "color": "White", "piece": "Pawn"}, {"square": "h2", "color": "White", "piece": "Pawn"}, {"square": "a8", "color": "Black", "piece": "Rook"}, {"square": "f1", "color": "White", "piece": "Bishop"}, {"square": "d6", "color": "Black", "piece": "Pawn"}, {"square": "a2", "color": "White", "piece": "Pawn"}, {"square": "d1", "color": "White", "piece": "Queen"}, {"square": "e2", "color": "White", "piece": "Pawn"}, {"square": "c8", "color": "Black", "piece": "Bishop"}, {"square": "a7", "color": "Black", "piece": "Pawn"}, {"square": "b8", "color": "Black", "piece": "Knight"}, {"square": "d2", "color": "White", "piece": "Pawn"}, {"square": "e8", "color": "Black", "piece": "King"}, {"square": "f4", "color": "White", "piece": "Pawn"}, {"square": "g7", "color": "Black", "piece": "Pawn"}, {"square": "h7", "color": "Black", "piece": "Pawn"}, {"square": "e7", "color": "Black", "piece": "Pawn"}, {"square": "h1", "color": "White", "piece": "Rook"}, {"square": "g1", "color": "White", "piece": "Knight"}],
"turn": "White",
"castlingRights": {
"whiteKingSide": true,
"whiteQueenSide": true,
"blackKingSide": true,
"blackQueenSide": true
},
"enPassantSquare": null,
"halfMoveClock": 0
},
"moveHistory": "[Event \"?\"]\n[White \"?\"]\n[Black \"?\"]\n[Result \"*\"]\n\n1. f4 d6 *",
"moves": [
{
"from": "f2",
"to": "f4",
"type": {"type": "normal", "isCapture": false}
},
{
"from": "d7",
"to": "d6",
"type": {"type": "normal", "isCapture": false}
}
],
"capturedPieces": {
"byWhite": ["Pawn"],
"byBlack": ["Pawn"]
},
"timestamp": "2026-04-06T11:39:00.746137480Z[UTC]"
}
@@ -16,7 +16,11 @@ import de.nowchess.chess.command.{MoveCommand, MoveResult}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.{GameContextExport, GameContextImport}
import de.nowchess.io.json.{JsonExporter, JsonParser}
import de.nowchess.io.{GameContextExport, GameContextImport, GameFileService, FileSystemGameService}
import java.nio.file.Paths
import scalafx.stage.FileChooser
import scalafx.stage.FileChooser.ExtensionFilter
/** ScalaFX chess board view that displays the game state.
* Uses chess sprites and color palette.
@@ -124,6 +128,22 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
}
)
},
new HBox {
spacing = 10
alignment = Pos.Center
children = Seq(
new Button("JSON Export") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doJsonExport()
style = "-fx-background-radius: 8; -fx-background-color: #B9C4DA;"
},
new Button("JSON Import") {
font = Font.font(comicSansFontFamily, 12)
onAction = _ => doJsonImport()
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
}
)
}
)
}
@@ -289,6 +309,45 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
private def doPgnImport(): Unit =
doImport(PgnParser, "PGN")
private def doJsonExport(): Unit =
val fileChooser = new FileChooser {
title = "Export Game as JSON"
initialFileName = "chess_game.json"
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
}
val selectedFile = fileChooser.showSaveDialog(stage)
if selectedFile != null then
val result = FileSystemGameService.saveGameToFile(
engine.context,
selectedFile.toPath,
JsonExporter
)
result match
case Right(_) => showMessage(s"✓ Game saved to: ${selectedFile.getName}")
case Left(err) => showMessage(s"⚠️ Error saving file: $err")
private def doJsonImport(): Unit =
val fileChooser = new FileChooser {
title = "Import Game from JSON"
extensionFilters.add(new ExtensionFilter("JSON files (*.json)", "*.json"))
extensionFilters.add(new ExtensionFilter("All files", "*.*"))
}
val selectedFile = fileChooser.showOpenDialog(stage)
if selectedFile != null then
val result = FileSystemGameService.loadGameFromFile(
selectedFile.toPath,
JsonParser
)
result match
case Right(gameContext) =>
engine.loadPosition(gameContext)
showMessage(s"✓ Game loaded from: ${selectedFile.getName}")
case Left(err) =>
showMessage(s"⚠️ Error: $err")
private def doExport(exporter: GameContextExport, formatName: String): Unit = {
val exported = exporter.exportGameContext(engine.context)
showCopyDialog(s"$formatName Export", exported)