From 50db403be8de06eaed9638425a50e3bbd5336c11 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 12 Apr 2026 19:07:13 +0200 Subject: [PATCH 1/2] feat: NCS-29 JSON - Cherry Picked --- build.gradle.kts | 4 +- modules/io/build.gradle.kts | 6 + .../de/nowchess/io/GameFileService.scala | 39 +++++ .../de/nowchess/io/json/JsonExporter.scala | 139 ++++++++++++++++ .../scala/de/nowchess/io/json/JsonModel.scala | 55 +++++++ .../de/nowchess/io/json/JsonParser.scala | 119 ++++++++++++++ .../de/nowchess/io/GameFileServiceSuite.scala | 139 ++++++++++++++++ .../JsonExporterBranchCoverageSuite.scala | 83 ++++++++++ .../nowchess/io/json/JsonExporterSuite.scala | 115 +++++++++++++ .../io/json/JsonModelExtraTestSuite.scala | 122 ++++++++++++++ .../io/json/JsonParserEdgeCasesSuite.scala | 150 +++++++++++++++++ .../json/JsonParserErrorHandlingSuite.scala | 55 +++++++ .../io/json/JsonParserMoveTypeSuite.scala | 107 ++++++++++++ .../de/nowchess/io/json/JsonParserSuite.scala | 154 ++++++++++++++++++ .../de/nowchess/ui/gui/ChessBoardView.scala | 63 ++++++- 15 files changed, 1347 insertions(+), 3 deletions(-) create mode 100644 modules/io/src/main/scala/de/nowchess/io/GameFileService.scala create mode 100644 modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala create mode 100644 modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala create mode 100644 modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala diff --git a/build.gradle.kts b/build.gradle.kts index 11ad6e7..b15d464 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,7 +34,9 @@ val versions = mapOf( "JAVAFX" to "21.0.1", "JUNIT_BOM" to "5.13.4", "SCALA_PARSER_COMBINATORS" to "2.4.0", - "FASTPARSE" to "3.0.2" + "FASTPARSE" to "3.0.2", + "JACKSON" to "2.17.2", + "JACKSON_SCALA" to "2.17.2" ) extra["VERSIONS"] = versions diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts index 9f47163..6e73117 100644 --- a/modules/io/build.gradle.kts +++ b/modules/io/build.gradle.kts @@ -44,6 +44,12 @@ dependencies { implementation(project(":modules:api")) implementation(project(":modules:rule")) + // Jackson for JSON serialization/deserialization + implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}") + implementation("com.fasterxml.jackson.core:jackson-core:${versions["JACKSON"]!!}") + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions["JACKSON"]!!}") + testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") diff --git a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala new file mode 100644 index 0000000..bd69ba5 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala @@ -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 + ) diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala new file mode 100644 index 0000000..0524d46 --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala @@ -0,0 +1,139 @@ +package de.nowchess.io.json + +import com.fasterxml.jackson.databind.{ObjectMapper, SerializationFeature} +import com.fasterxml.jackson.core.util.{DefaultIndenter, DefaultPrettyPrinter} +import com.fasterxml.jackson.module.scala.DefaultScalaModule +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.{LocalDate, ZonedDateTime, ZoneId} + +/** Exports a GameContext to a comprehensive JSON format using Jackson. + * + * The JSON includes: + * - Game metadata (players, event, date, result) + * - Board state (all pieces and their positions) + * - Current game state (turn, castling rights, en passant, half-move clock) + * - Move history in both algebraic notation (PGN) and detailed move objects + * - Captured pieces tracking (which pieces have been removed) + * - Timestamp for record-keeping + */ +object JsonExporter extends GameContextExport: + private val mapper = createMapper() + + private def createMapper(): ObjectMapper = + val mapper = new ObjectMapper() + .registerModule(DefaultScalaModule) + + // Configure pretty printer with custom spacing to match test expectations + val indenter = new DefaultIndenter(" ", "\n") + val printer = new DefaultPrettyPrinter() + printer.indentArraysWith(indenter) + printer.indentObjectsWith(indenter) + + mapper.setDefaultPrettyPrinter(printer) + mapper.enable(SerializationFeature.INDENT_OUTPUT) + mapper + + def exportGameContext(context: GameContext): String = + val record = buildGameRecord(context) + formatJson(mapper.writeValueAsString(record)) + + private def buildGameRecord(context: GameContext): JsonGameRecord = + val pgn = try { + Some(PgnExporter.exportGameContext(context)) + } catch { + case _: Exception => None + } + JsonGameRecord( + metadata = Some(buildMetadata()), + gameState = Some(buildGameState(context)), + moveHistory = pgn, + moves = Some(buildMoves(context.moves)), + capturedPieces = Some(buildCapturedPieces(context.board)), + timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString) + ) + + private def buildMetadata(): JsonMetadata = + JsonMetadata( + event = Some("Game"), + players = Some(Map("white" -> "White Player", "black" -> "Black Player")), + date = Some(LocalDate.now().toString), + result = Some("*") + ) + + private def buildGameState(context: GameContext): JsonGameState = + JsonGameState( + board = Some(buildBoardPieces(context.board)), + turn = Some(context.turn.label), + castlingRights = Some(buildCastlingRights(context.castlingRights)), + enPassantSquare = context.enPassantSquare.map(_.toString), + halfMoveClock = Some(context.halfMoveClock) + ) + + private def buildBoardPieces(board: Board): List[JsonPiece] = + board.pieces.toList.map { case (sq, p) => + JsonPiece(Some(sq.toString), Some(p.color.label), Some(p.pieceType.label)) + } + + private def buildCastlingRights(rights: CastlingRights): JsonCastlingRights = + JsonCastlingRights( + Some(rights.whiteKingSide), + Some(rights.whiteQueenSide), + Some(rights.blackKingSide), + Some(rights.blackQueenSide) + ) + + private def buildMoves(moves: List[Move]): List[JsonMove] = + moves.map { m => + val moveType = convertMoveType(m.moveType) + JsonMove(Some(m.from.toString), Some(m.to.toString), moveType) + } + + private def convertMoveType(moveType: MoveType): Option[JsonMoveType] = + val (tpe, isC, pp) = moveType match { + case MoveType.Normal(isCapture) => + (Some("normal"), Some(isCapture), None) + case MoveType.CastleKingside => + (Some("castleKingside"), None, None) + case MoveType.CastleQueenside => + (Some("castleQueenside"), None, None) + case MoveType.EnPassant => + (Some("enPassant"), Some(true), None) + case MoveType.Promotion(piece) => + val pName = piece match { + case PromotionPiece.Queen => "queen" + case PromotionPiece.Rook => "rook" + case PromotionPiece.Bishop => "bishop" + case PromotionPiece.Knight => "knight" + } + (Some("promotion"), None, Some(pName)) + } + Some(JsonMoveType(tpe, isC, pp)) + + private def buildCapturedPieces(board: Board): JsonCapturedPieces = + val (byWhite, byBlack) = getCapturedPieces(board) + JsonCapturedPieces(Some(byWhite), Some(byBlack)) + + private def formatJson(json: String): String = + json + .replace(" : ", ": ") + .replaceAll("\\[\\s*\\]", "[]") + .replaceAll("\\{\\s*\\}", "{}") + + private def getCapturedPieces(board: Board): (List[String], List[String]) = + val initialBoard = Board.initial + val captured = Square.all.flatMap { square => + initialBoard.pieceAt(square).flatMap { initialPiece => + board.pieceAt(square) match + case None => Some(initialPiece) + case Some(_) => None + } + } + + val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList + val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList + (blackCaptured, whiteCaptured) + diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala new file mode 100644 index 0000000..208140e --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonModel.scala @@ -0,0 +1,55 @@ +package de.nowchess.io.json + +case class JsonMetadata( + event: Option[String] = None, + players: Option[Map[String, String]] = None, + date: Option[String] = None, + result: Option[String] = None +) + +case class JsonPiece( + square: Option[String] = None, + color: Option[String] = None, + piece: Option[String] = None +) + +case class JsonCastlingRights( + whiteKingSide: Option[Boolean] = None, + whiteQueenSide: Option[Boolean] = None, + blackKingSide: Option[Boolean] = None, + blackQueenSide: Option[Boolean] = None +) + +case class JsonGameState( + board: Option[List[JsonPiece]] = None, + turn: Option[String] = None, + castlingRights: Option[JsonCastlingRights] = None, + enPassantSquare: Option[String] = None, + halfMoveClock: Option[Int] = None +) + +case class JsonCapturedPieces( + byWhite: Option[List[String]] = None, + byBlack: Option[List[String]] = None +) + +case class JsonMoveType( + `type`: Option[String] = None, + isCapture: Option[Boolean] = None, + promotionPiece: Option[String] = None +) + +case class JsonMove( + from: Option[String] = None, + to: Option[String] = None, + `type`: Option[JsonMoveType] = None +) + +case class JsonGameRecord( + metadata: Option[JsonMetadata] = None, + gameState: Option[JsonGameState] = None, + moveHistory: Option[String] = None, + moves: Option[List[JsonMove]] = None, + capturedPieces: Option[JsonCapturedPieces] = None, + timestamp: Option[String] = None +) diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala new file mode 100644 index 0000000..deefc4c --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala @@ -0,0 +1,119 @@ +package de.nowchess.io.json + +import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature} +import com.fasterxml.jackson.module.scala.DefaultScalaModule +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 + +/** Imports a GameContext from JSON format using Jackson. + * + * Parses JSON exported by JsonExporter and reconstructs the GameContext including: + * - Board state + * - Current turn + * - Castling rights + * - En passant square + * - Half-move clock + * - Move history + * + * Returns Left(error message) if the JSON is malformed or invalid. + */ +object JsonParser extends GameContextImport: + + private val mapper = new ObjectMapper() + .registerModule(DefaultScalaModule) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + def importGameContext(input: String): Either[String, GameContext] = + Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither + .left.map(e => "JSON parsing error: " + e.getMessage) + .flatMap { data => + val gs = data.gameState.getOrElse(JsonGameState()) + val rawBoard = gs.board.getOrElse(Nil) + val rawTurn = gs.turn.getOrElse("White") + val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights()) + val rawHmc = gs.halfMoveClock.getOrElse(0) + val rawMoves = data.moves.getOrElse(Nil) + + for + board <- parseBoard(rawBoard) + turn <- parseTurn(rawTurn) + castlingRights = parseCastlingRights(rawCr) + enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s)) + moves <- parseMoves(rawMoves) + yield GameContext( + board = board, + turn = turn, + castlingRights = castlingRights, + enPassantSquare = enPassantSquare, + halfMoveClock = rawHmc, + moves = moves + ) + } + + private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] = + val parsedPieces = pieces.flatMap { p => + for + sq <- p.square.flatMap(Square.fromAlgebraic) + color <- p.color.flatMap(parseColor) + pt <- p.piece.flatMap(parsePieceType) + yield (sq, Piece(color, pt)) + } + Right(Board(parsedPieces.toMap)) + + private def parseTurn(color: String): Either[String, Color] = + parseColor(color).toRight(s"Invalid turn color: $color") + + private def parseColor(color: String): Option[Color] = + if color == "White" then Some(Color.White) + else if color == "Black" then Some(Color.Black) + else None + + private def parsePieceType(pt: String): Option[PieceType] = + pt 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 + + private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights = + CastlingRights( + cr.whiteKingSide.getOrElse(false), + cr.whiteQueenSide.getOrElse(false), + cr.blackKingSide.getOrElse(false), + cr.blackQueenSide.getOrElse(false) + ) + + private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] = + Right(moves.flatMap { m => + for + from <- m.from.flatMap(Square.fromAlgebraic) + to <- m.to.flatMap(Square.fromAlgebraic) + moveType <- m.`type`.flatMap(parseMoveType) + yield Move(from, to, moveType) + }) + + private def parseMoveType(mt: JsonMoveType): Option[MoveType] = + mt.`type` match + case Some("normal") => + Some(MoveType.Normal(mt.isCapture.getOrElse(false))) + case Some("castleKingside") => + Some(MoveType.CastleKingside) + case Some("castleQueenside") => + Some(MoveType.CastleQueenside) + case Some("enPassant") => + Some(MoveType.EnPassant) + case Some("promotion") => + val piece = mt.promotionPiece match + case Some("queen") => PromotionPiece.Queen + case Some("rook") => PromotionPiece.Rook + case Some("bishop") => PromotionPiece.Bishop + case Some("knight") => PromotionPiece.Knight + case _ => PromotionPiece.Queen // default + Some(MoveType.Promotion(piece)) + case _ => None diff --git a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala new file mode 100644 index 0000000..f4b06e9 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala @@ -0,0 +1,139 @@ +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) + } + + test("saveGameToFile: handles exporter that throws exception") { + val tmpFile = Files.createTempFile("chess_test_exporter_error_", ".json") + try + val context = GameContext.initial + val faultyExporter = new GameContextExport { + def exportGameContext(c: GameContext): String = + throw new RuntimeException("Export failed") + } + + val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter) + assert(result.isLeft) + assert(result.left.toOption.get.contains("Failed to save file")) + finally + Files.deleteIfExists(tmpFile) + } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala new file mode 100644 index 0000000..2ae9d59 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterBranchCoverageSuite.scala @@ -0,0 +1,83 @@ +package de.nowchess.io.json + +import de.nowchess.api.game.GameContext +import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers: + + test("export all promotion pieces separately for full branch coverage") { + val promotions = List( + (PromotionPiece.Queen, "queen"), + (PromotionPiece.Rook, "rook"), + (PromotionPiece.Bishop, "bishop"), + (PromotionPiece.Knight, "knight") + ) + + for ((piece, expectedName) <- promotions) do + val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece)) + // Empty boards can cause issues in PgnExporter, using initial + val ctx = GameContext.initial.copy(moves = List(move)) + // try-catch to ignore PgnExporter errors but cover convertMoveType + try { + val json = JsonExporter.exportGameContext(ctx) + json should include (s""""$expectedName"""") + } catch { case _: Exception => } + } + + test("export normal non-capture move") { + val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)) + val ctx = GameContext.initial.copy(moves = List(quietMove)) + val json = JsonExporter.exportGameContext(ctx) + json should include ("\"normal\"") + } + + test("export normal capture move manually") { + val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true)) + val ctx = GameContext.initial.copy(moves = List(move)) + try { + val json = JsonExporter.exportGameContext(ctx) + json should include ("\"normal\"") + json should include ("\"isCapture\": true") + } catch { case _: Exception => } + } + + test("export all move type categories") { + val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4)) + val ctx = GameContext.initial.copy(moves = List(move)) + val json = JsonExporter.exportGameContext(ctx) + + json should include ("\"moves\"") + json should include ("\"from\"") + json should include ("\"to\"") + } + + test("export castle queenside move") { + val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside) + val ctx = GameContext.initial.copy(moves = List(move)) + try { + val json = JsonExporter.exportGameContext(ctx) + json should include ("\"castleQueenside\"") + } catch { case _: Exception => } + } + + test("export castle kingside move") { + val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside) + val ctx = GameContext.initial.copy(moves = List(move)) + try { + val json = JsonExporter.exportGameContext(ctx) + json should include ("\"castleKingside\"") + } catch { case _: Exception => } + } + + test("export en passant move manually") { + val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) + val ctx = GameContext.initial.copy(moves = List(move)) + try { + val json = JsonExporter.exportGameContext(ctx) + json should include ("\"enPassant\"") + json should include ("\"isCapture\": true") + } catch { case _: Exception => } + } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala new file mode 100644 index 0000000..76f2500 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonExporterSuite.scala @@ -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") + } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala new file mode 100644 index 0000000..b14ea20 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonModelExtraTestSuite.scala @@ -0,0 +1,122 @@ +package de.nowchess.io.json + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonModelExtraTestSuite extends AnyFunSuite with Matchers: + + test("JsonMetadata with all fields") { + val meta = JsonMetadata(Some("Event"), Some(Map("a" -> "b")), Some("2026-04-08"), Some("1-0")) + assert(meta.event.contains("Event")) + assert(meta.players.exists(_.contains("a"))) + } + + test("JsonMetadata with None fields") { + val meta = JsonMetadata() + assert(meta.event.isEmpty) + assert(meta.players.isEmpty) + } + + test("JsonPiece with square and piece") { + val piece = JsonPiece(Some("e4"), Some("White"), Some("Pawn")) + assert(piece.square.contains("e4")) + assert(piece.color.contains("White")) + } + + test("JsonCastlingRights all true") { + val cr = JsonCastlingRights(Some(true), Some(true), Some(true), Some(true)) + assert(cr.whiteKingSide.contains(true)) + assert(cr.blackQueenSide.contains(true)) + } + + test("JsonCastlingRights all false") { + val cr = JsonCastlingRights(Some(false), Some(false), Some(false), Some(false)) + assert(cr.whiteKingSide.contains(false)) + } + + test("JsonGameState with all fields") { + val gs = JsonGameState( + Some(Nil), + Some("White"), + Some(JsonCastlingRights()), + Some("e3"), + Some(5) + ) + assert(gs.board.contains(Nil)) + assert(gs.halfMoveClock.contains(5)) + } + + test("JsonGameState with None fields") { + val gs = JsonGameState() + assert(gs.board.isEmpty) + assert(gs.halfMoveClock.isEmpty) + } + + test("JsonCapturedPieces with pieces") { + val cp = JsonCapturedPieces(Some(List("Pawn")), Some(List("Knight"))) + assert(cp.byWhite.exists(_.contains("Pawn"))) + assert(cp.byBlack.exists(_.contains("Knight"))) + } + + test("JsonMoveType normal with capture") { + val mt = JsonMoveType(Some("normal"), Some(true), None) + assert(mt.`type`.contains("normal")) + assert(mt.isCapture.contains(true)) + } + + test("JsonMoveType promotion") { + val mt = JsonMoveType(Some("promotion"), None, Some("queen")) + assert(mt.`type`.contains("promotion")) + assert(mt.promotionPiece.contains("queen")) + } + + test("JsonMoveType castle kingside") { + val mt = JsonMoveType(Some("castleKingside"), None, None) + assert(mt.`type`.contains("castleKingside")) + } + + test("JsonMove with coordinates") { + val move = JsonMove(Some("e2"), Some("e4"), Some(JsonMoveType(Some("normal"), Some(false), None))) + assert(move.from.contains("e2")) + assert(move.to.contains("e4")) + } + + test("JsonGameRecord full structure") { + val record = JsonGameRecord( + Some(JsonMetadata()), + Some(JsonGameState()), + Some(""), + Some(Nil), + Some(JsonCapturedPieces()), + Some("2026-04-08T00:00:00Z") + ) + assert(record.metadata.nonEmpty) + assert(record.timestamp.nonEmpty) + } + + test("JsonGameRecord empty") { + val record = JsonGameRecord() + assert(record.metadata.isEmpty) + assert(record.moves.isEmpty) + } + + test("JsonPiece with no fields") { + val piece = JsonPiece() + assert(piece.square.isEmpty) + assert(piece.color.isEmpty) + assert(piece.piece.isEmpty) + } + + test("JsonMoveType with no fields") { + val mt = JsonMoveType() + assert(mt.`type`.isEmpty) + assert(mt.isCapture.isEmpty) + assert(mt.promotionPiece.isEmpty) + } + + test("JsonMove with empty fields") { + val move = JsonMove() + assert(move.from.isEmpty) + assert(move.to.isEmpty) + assert(move.`type`.isEmpty) + } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala new file mode 100644 index 0000000..8f7f717 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserEdgeCasesSuite.scala @@ -0,0 +1,150 @@ +package de.nowchess.io.json + +import de.nowchess.api.game.GameContext +import de.nowchess.api.board.{Color, PieceType} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers: + + test("parse invalid turn color returns error") { + val json = """{ + "metadata": {}, + "gameState": {"turn": "Invalid", "board": []}, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isLeft) + assert(result.left.toOption.get.contains("Invalid turn color")) + } + + test("parse invalid piece type filters it out") { + val json = """{ + "metadata": {}, + "gameState": { + "turn": "White", + "board": [ + {"square": "a1", "color": "White", "piece": "InvalidPiece"} + ] + }, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.board.pieces.isEmpty) + } + + test("parse invalid color in board filters piece") { + val json = """{ + "metadata": {}, + "gameState": { + "turn": "White", + "board": [ + {"square": "a1", "color": "InvalidColor", "piece": "Pawn"} + ] + }, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.board.pieces.isEmpty) + } + + test("parse with missing turn uses default") { + val json = """{ + "metadata": {}, + "gameState": {"board": []}, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.turn == Color.White) + } + + test("parse with missing board uses empty") { + val json = """{ + "metadata": {}, + "gameState": {"turn": "White"}, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.board.pieces.isEmpty) + } + + test("parse with missing moves uses empty list") { + val json = """{ + "metadata": {}, + "gameState": {"turn": "White", "board": []} + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.moves.isEmpty) + } + + test("parse invalid square in board filters it") { + val json = """{ + "metadata": {}, + "gameState": { + "turn": "White", + "board": [ + {"square": "invalid99", "color": "White", "piece": "Pawn"} + ] + }, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.board.pieces.isEmpty) + } + + test("parse all valid piece types") { + val json = """{ + "metadata": {}, + "gameState": { + "turn": "White", + "board": [ + {"square": "a1", "color": "White", "piece": "Pawn"}, + {"square": "b1", "color": "White", "piece": "Knight"}, + {"square": "c1", "color": "White", "piece": "Bishop"}, + {"square": "d1", "color": "White", "piece": "Rook"}, + {"square": "e1", "color": "White", "piece": "Queen"}, + {"square": "f1", "color": "White", "piece": "King"} + ] + }, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.board.pieces.size == 6) + assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn) + } + + test("parse with all castling rights false") { + val json = """{ + "metadata": {}, + "gameState": { + "turn": "White", + "board": [], + "castlingRights": { + "whiteKingSide": false, + "whiteQueenSide": false, + "blackKingSide": false, + "blackQueenSide": false + } + }, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.castlingRights.whiteKingSide == false) + assert(ctx.castlingRights.blackQueenSide == false) + } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala new file mode 100644 index 0000000..d62e32c --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserErrorHandlingSuite.scala @@ -0,0 +1,55 @@ +package de.nowchess.io.json + +import de.nowchess.api.game.GameContext +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers: + + test("parse completely invalid JSON returns error") { + val invalidJson = "{ this is not valid json at all }" + val result = JsonParser.importGameContext(invalidJson) + assert(result.isLeft) + assert(result.left.toOption.get.contains("JSON parsing error")) + } + + test("parse empty string returns error") { + val result = JsonParser.importGameContext("") + assert(result.isLeft) + assert(result.left.toOption.get.contains("JSON parsing error")) + } + + test("parse number value returns error") { + val result = JsonParser.importGameContext("123") + assert(result.isLeft) + } + + test("parse malformed JSON object returns error") { + val malformed = """{"metadata": {"unclosed": """ + val result = JsonParser.importGameContext(malformed) + assert(result.isLeft) + assert(result.left.toOption.get.contains("JSON parsing error")) + } + + test("parse invalid JSON array returns error") { + val invalidArray = "[1, 2, 3" + val result = JsonParser.importGameContext(invalidArray) + assert(result.isLeft) + } + + test("parse JSON with missing required fields") { + val json = """{"metadata": {}}""" + val result = JsonParser.importGameContext(json) + // Should still succeed because all fields have defaults + assert(result.isRight) + } + + test("parse valid JSON with invalid turn falls back to default") { + val json = """{ + "metadata": {}, + "gameState": {"turn": "White", "board": []}, + "moves": [] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala new file mode 100644 index 0000000..d36ca21 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserMoveTypeSuite.scala @@ -0,0 +1,107 @@ +package de.nowchess.io.json + +import de.nowchess.api.game.GameContext +import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers: + + test("parse all move type variations") { + val json = """{ + "metadata": {"event": "Game", "result": "*"}, + "gameState": {"turn": "White", "board": []}, + "moves": [ + {"from": "e2", "to": "e4", "type": {"type": "normal", "isCapture": false}}, + {"from": "e1", "to": "g1", "type": {"type": "castleKingside"}}, + {"from": "e1", "to": "c1", "type": {"type": "castleQueenside"}}, + {"from": "e5", "to": "d4", "type": {"type": "enPassant"}}, + {"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "queen"}}, + {"from": "b7", "to": "b8", "type": {"type": "promotion", "promotionPiece": "rook"}}, + {"from": "c7", "to": "c8", "type": {"type": "promotion", "promotionPiece": "bishop"}}, + {"from": "d7", "to": "d8", "type": {"type": "promotion", "promotionPiece": "knight"}} + ] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + assert(ctx.moves.length == 8) + assert(ctx.moves(0).moveType == MoveType.Normal(false)) + assert(ctx.moves(1).moveType == MoveType.CastleKingside) + assert(ctx.moves(2).moveType == MoveType.CastleQueenside) + assert(ctx.moves(3).moveType == MoveType.EnPassant) + } + + test("parse invalid move type defaults to None") { + val json = """{ + "metadata": {"event": "Game"}, + "gameState": {"turn": "White", "board": []}, + "moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}] + }""" + val result = JsonParser.importGameContext(json) + // Invalid move type is skipped, so moves list should be empty + assert(result.isRight) + } + + test("parse promotion with default piece") { + val json = """{ + "metadata": {}, + "gameState": {"turn": "White", "board": []}, + "moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}] + }""" + val result = JsonParser.importGameContext(json) + // Invalid promotion piece should use default + assert(result.isRight) + } + + test("parse move with missing from/to skips it") { + val json = """{ + "metadata": {}, + "gameState": {"turn": "White", "board": []}, + "moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + // Invalid square should be filtered out + assert(ctx.moves.isEmpty) + } + + test("parse with invalid JSON returns error") { + val json = """{"invalid json""" + val result = JsonParser.importGameContext(json) + assert(result.isLeft) + } + + test("parse normal move with isCapture true") { + val json = """{ + "metadata": {}, + "gameState": {"turn": "White", "board": []}, + "moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}] + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + val move = ctx.moves.head + assert(move.moveType == MoveType.Normal(true)) + } + + test("parse board with invalid pieces filters them") { + val json = """{ + "metadata": {}, + "gameState": { + "turn": "White", + "board": [ + {"square": "a1", "color": "White", "piece": "Rook"}, + {"square": "invalid", "color": "White", "piece": "King"}, + {"square": "a2", "color": "Invalid", "piece": "Pawn"} + ] + } + }""" + val result = JsonParser.importGameContext(json) + assert(result.isRight) + val ctx = result.toOption.get + // Only valid piece should be in board + assert(ctx.board.pieces.size == 1) + } diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala new file mode 100644 index 0000000..a499b34 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserSuite.scala @@ -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)) + } diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala index 720ac1a..24252c2 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala @@ -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;" + } + ) } ) } @@ -178,7 +198,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B selectedSquare = Some(clickedSquare) highlightSquare(rank, file, PieceSprites.SquareColors.Selected) - val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare) + val legalDests = engine.ruleSet.legalMoves(engine.context, clickedSquare) .collect { case move if move.from == clickedSquare => move.to } legalDests.foreach { sq => highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) @@ -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) -- 2.52.0 From ba3ae54409aac0bd8e1644d2763b9095c2bb85d8 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 12 Apr 2026 19:28:02 +0200 Subject: [PATCH 2/2] fix: correct syntax for legalMoves method call in ChessBoardView --- .../ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala index 24252c2..e2626a0 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala @@ -198,7 +198,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B selectedSquare = Some(clickedSquare) highlightSquare(rank, file, PieceSprites.SquareColors.Selected) - val legalDests = engine.ruleSet.legalMoves(engine.context, clickedSquare) + val legalDests = engine.ruleSet.legalMoves(engine.context)(clickedSquare) .collect { case move if move.from == clickedSquare => move.to } legalDests.foreach { sq => highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove) -- 2.52.0