refactor: clean up code formatting and improve readability in ChessBoardView and related files
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -6,10 +6,9 @@ import java.nio.charset.StandardCharsets
|
||||
import scala.util.Try
|
||||
|
||||
/** Service for persisting and loading game states to/from disk.
|
||||
*
|
||||
* Abstracts file I/O operations away from the UI layer.
|
||||
* Handles both reading and writing game files.
|
||||
*/
|
||||
*
|
||||
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
|
||||
*/
|
||||
trait GameFileService:
|
||||
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
|
||||
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
|
||||
@@ -25,7 +24,7 @@ object FileSystemGameService extends GameFileService:
|
||||
()
|
||||
}.fold(
|
||||
ex => Left(s"Failed to save file: ${ex.getMessage}"),
|
||||
_ => Right(())
|
||||
_ => Right(()),
|
||||
)
|
||||
|
||||
/** Load a game context from a file using the specified importer. */
|
||||
@@ -35,5 +34,5 @@ object FileSystemGameService extends GameFileService:
|
||||
importer.importGameContext(json)
|
||||
}.fold(
|
||||
ex => Left(s"Failed to load file: ${ex.getMessage}"),
|
||||
result => result
|
||||
result => result,
|
||||
)
|
||||
|
||||
@@ -8,31 +8,31 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextExport
|
||||
import de.nowchess.io.pgn.PgnExporter
|
||||
import java.time.{LocalDate, ZonedDateTime, ZoneId}
|
||||
import java.time.{LocalDate, ZoneId, ZonedDateTime}
|
||||
|
||||
/** Exports a GameContext to a comprehensive JSON format using Jackson.
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
*
|
||||
* The JSON includes:
|
||||
* - Game metadata (players, event, date, result)
|
||||
* - Board state (all pieces and their positions)
|
||||
* - Current game state (turn, castling rights, en passant, half-move clock)
|
||||
* - Move history in both algebraic notation (PGN) and detailed move objects
|
||||
* - Captured pieces tracking (which pieces have been removed)
|
||||
* - Timestamp for record-keeping
|
||||
*/
|
||||
object JsonExporter extends GameContextExport:
|
||||
private val mapper = createMapper()
|
||||
|
||||
|
||||
private def createMapper(): ObjectMapper =
|
||||
val mapper = new ObjectMapper()
|
||||
.registerModule(DefaultScalaModule)
|
||||
|
||||
|
||||
// Configure pretty printer with custom spacing to match test expectations
|
||||
val indenter = new DefaultIndenter(" ", "\n")
|
||||
val printer = new DefaultPrettyPrinter()
|
||||
val printer = new DefaultPrettyPrinter()
|
||||
printer.indentArraysWith(indenter)
|
||||
printer.indentObjectsWith(indenter)
|
||||
|
||||
|
||||
mapper.setDefaultPrettyPrinter(printer)
|
||||
mapper.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
mapper
|
||||
@@ -42,18 +42,19 @@ object JsonExporter extends GameContextExport:
|
||||
formatJson(mapper.writeValueAsString(record))
|
||||
|
||||
private def buildGameRecord(context: GameContext): JsonGameRecord =
|
||||
val pgn = try {
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
} catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
val pgn =
|
||||
try
|
||||
Some(PgnExporter.exportGameContext(context))
|
||||
catch {
|
||||
case _: Exception => None
|
||||
}
|
||||
JsonGameRecord(
|
||||
metadata = Some(buildMetadata()),
|
||||
gameState = Some(buildGameState(context)),
|
||||
moveHistory = pgn,
|
||||
moves = Some(buildMoves(context.moves)),
|
||||
capturedPieces = Some(buildCapturedPieces(context.board)),
|
||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString)
|
||||
timestamp = Some(ZonedDateTime.now(ZoneId.of("UTC")).toString),
|
||||
)
|
||||
|
||||
private def buildMetadata(): JsonMetadata =
|
||||
@@ -61,7 +62,7 @@ object JsonExporter extends GameContextExport:
|
||||
event = Some("Game"),
|
||||
players = Some(Map("white" -> "White Player", "black" -> "Black Player")),
|
||||
date = Some(LocalDate.now().toString),
|
||||
result = Some("*")
|
||||
result = Some("*"),
|
||||
)
|
||||
|
||||
private def buildGameState(context: GameContext): JsonGameState =
|
||||
@@ -70,7 +71,7 @@ object JsonExporter extends GameContextExport:
|
||||
turn = Some(context.turn.label),
|
||||
castlingRights = Some(buildCastlingRights(context.castlingRights)),
|
||||
enPassantSquare = context.enPassantSquare.map(_.toString),
|
||||
halfMoveClock = Some(context.halfMoveClock)
|
||||
halfMoveClock = Some(context.halfMoveClock),
|
||||
)
|
||||
|
||||
private def buildBoardPieces(board: Board): List[JsonPiece] =
|
||||
@@ -83,7 +84,7 @@ object JsonExporter extends GameContextExport:
|
||||
Some(rights.whiteKingSide),
|
||||
Some(rights.whiteQueenSide),
|
||||
Some(rights.blackKingSide),
|
||||
Some(rights.blackQueenSide)
|
||||
Some(rights.blackQueenSide),
|
||||
)
|
||||
|
||||
private def buildMoves(moves: List[Move]): List[JsonMove] =
|
||||
@@ -128,12 +129,11 @@ object JsonExporter extends GameContextExport:
|
||||
val captured = Square.all.flatMap { square =>
|
||||
initialBoard.pieceAt(square).flatMap { initialPiece =>
|
||||
board.pieceAt(square) match
|
||||
case None => Some(initialPiece)
|
||||
case None => Some(initialPiece)
|
||||
case Some(_) => None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val whiteCaptured = captured.filter(_.color == Color.White).map(_.pieceType.label).toList
|
||||
val blackCaptured = captured.filter(_.color == Color.Black).map(_.pieceType.label).toList
|
||||
(blackCaptured, whiteCaptured)
|
||||
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
case class JsonMetadata(
|
||||
event: Option[String] = None,
|
||||
players: Option[Map[String, String]] = None,
|
||||
date: Option[String] = None,
|
||||
result: Option[String] = None
|
||||
event: Option[String] = None,
|
||||
players: Option[Map[String, String]] = None,
|
||||
date: Option[String] = None,
|
||||
result: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonPiece(
|
||||
square: Option[String] = None,
|
||||
color: Option[String] = None,
|
||||
piece: Option[String] = None
|
||||
square: Option[String] = None,
|
||||
color: Option[String] = None,
|
||||
piece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonCastlingRights(
|
||||
whiteKingSide: Option[Boolean] = None,
|
||||
whiteQueenSide: Option[Boolean] = None,
|
||||
blackKingSide: Option[Boolean] = None,
|
||||
blackQueenSide: Option[Boolean] = None
|
||||
whiteKingSide: Option[Boolean] = None,
|
||||
whiteQueenSide: Option[Boolean] = None,
|
||||
blackKingSide: Option[Boolean] = None,
|
||||
blackQueenSide: Option[Boolean] = None,
|
||||
)
|
||||
|
||||
case class JsonGameState(
|
||||
board: Option[List[JsonPiece]] = None,
|
||||
turn: Option[String] = None,
|
||||
castlingRights: Option[JsonCastlingRights] = None,
|
||||
enPassantSquare: Option[String] = None,
|
||||
halfMoveClock: Option[Int] = None
|
||||
board: Option[List[JsonPiece]] = None,
|
||||
turn: Option[String] = None,
|
||||
castlingRights: Option[JsonCastlingRights] = None,
|
||||
enPassantSquare: Option[String] = None,
|
||||
halfMoveClock: Option[Int] = None,
|
||||
)
|
||||
|
||||
case class JsonCapturedPieces(
|
||||
byWhite: Option[List[String]] = None,
|
||||
byBlack: Option[List[String]] = None
|
||||
byWhite: Option[List[String]] = None,
|
||||
byBlack: Option[List[String]] = None,
|
||||
)
|
||||
|
||||
case class JsonMoveType(
|
||||
`type`: Option[String] = None,
|
||||
isCapture: Option[Boolean] = None,
|
||||
promotionPiece: Option[String] = None
|
||||
`type`: Option[String] = None,
|
||||
isCapture: Option[Boolean] = None,
|
||||
promotionPiece: Option[String] = None,
|
||||
)
|
||||
|
||||
case class JsonMove(
|
||||
from: Option[String] = None,
|
||||
to: Option[String] = None,
|
||||
`type`: Option[JsonMoveType] = None
|
||||
from: Option[String] = None,
|
||||
to: Option[String] = None,
|
||||
`type`: Option[JsonMoveType] = None,
|
||||
)
|
||||
|
||||
case class JsonGameRecord(
|
||||
metadata: Option[JsonMetadata] = None,
|
||||
gameState: Option[JsonGameState] = None,
|
||||
moveHistory: Option[String] = None,
|
||||
moves: Option[List[JsonMove]] = None,
|
||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||
timestamp: Option[String] = None
|
||||
metadata: Option[JsonMetadata] = None,
|
||||
gameState: Option[JsonGameState] = None,
|
||||
moveHistory: Option[String] = None,
|
||||
moves: Option[List[JsonMove]] = None,
|
||||
capturedPieces: Option[JsonCapturedPieces] = None,
|
||||
timestamp: Option[String] = None,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import com.fasterxml.jackson.databind.{ObjectMapper, DeserializationFeature}
|
||||
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
@@ -9,17 +9,17 @@ import de.nowchess.io.GameContextImport
|
||||
import scala.util.Try
|
||||
|
||||
/** Imports a GameContext from JSON format using Jackson.
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
*
|
||||
* Parses JSON exported by JsonExporter and reconstructs the GameContext including:
|
||||
* - Board state
|
||||
* - Current turn
|
||||
* - Castling rights
|
||||
* - En passant square
|
||||
* - Half-move clock
|
||||
* - Move history
|
||||
*
|
||||
* Returns Left(error message) if the JSON is malformed or invalid.
|
||||
*/
|
||||
object JsonParser extends GameContextImport:
|
||||
|
||||
private val mapper = new ObjectMapper()
|
||||
@@ -27,20 +27,20 @@ object JsonParser extends GameContextImport:
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither
|
||||
.left.map(e => "JSON parsing error: " + e.getMessage)
|
||||
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
|
||||
.map(e => "JSON parsing error: " + e.getMessage)
|
||||
.flatMap { data =>
|
||||
val gs = data.gameState.getOrElse(JsonGameState())
|
||||
val gs = data.gameState.getOrElse(JsonGameState())
|
||||
val rawBoard = gs.board.getOrElse(Nil)
|
||||
val rawTurn = gs.turn.getOrElse("White")
|
||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||
val rawTurn = gs.turn.getOrElse("White")
|
||||
val rawCr = gs.castlingRights.getOrElse(JsonCastlingRights())
|
||||
val rawHmc = gs.halfMoveClock.getOrElse(0)
|
||||
val rawMoves = data.moves.getOrElse(Nil)
|
||||
|
||||
for
|
||||
board <- parseBoard(rawBoard)
|
||||
turn <- parseTurn(rawTurn)
|
||||
castlingRights = parseCastlingRights(rawCr)
|
||||
turn <- parseTurn(rawTurn)
|
||||
castlingRights = parseCastlingRights(rawCr)
|
||||
enPassantSquare = gs.enPassantSquare.flatMap(s => Square.fromAlgebraic(s))
|
||||
moves <- parseMoves(rawMoves)
|
||||
yield GameContext(
|
||||
@@ -49,16 +49,16 @@ object JsonParser extends GameContextImport:
|
||||
castlingRights = castlingRights,
|
||||
enPassantSquare = enPassantSquare,
|
||||
halfMoveClock = rawHmc,
|
||||
moves = moves
|
||||
moves = moves,
|
||||
)
|
||||
}
|
||||
|
||||
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
|
||||
val parsedPieces = pieces.flatMap { p =>
|
||||
for
|
||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||
sq <- p.square.flatMap(Square.fromAlgebraic)
|
||||
color <- p.color.flatMap(parseColor)
|
||||
pt <- p.piece.flatMap(parsePieceType)
|
||||
pt <- p.piece.flatMap(parsePieceType)
|
||||
yield (sq, Piece(color, pt))
|
||||
}
|
||||
Right(Board(parsedPieces.toMap))
|
||||
@@ -73,27 +73,27 @@ object JsonParser extends GameContextImport:
|
||||
|
||||
private def parsePieceType(pt: String): Option[PieceType] =
|
||||
pt match
|
||||
case "Pawn" => Some(PieceType.Pawn)
|
||||
case "Pawn" => Some(PieceType.Pawn)
|
||||
case "Knight" => Some(PieceType.Knight)
|
||||
case "Bishop" => Some(PieceType.Bishop)
|
||||
case "Rook" => Some(PieceType.Rook)
|
||||
case "Queen" => Some(PieceType.Queen)
|
||||
case "King" => Some(PieceType.King)
|
||||
case _ => None
|
||||
case "Rook" => Some(PieceType.Rook)
|
||||
case "Queen" => Some(PieceType.Queen)
|
||||
case "King" => Some(PieceType.King)
|
||||
case _ => None
|
||||
|
||||
private def parseCastlingRights(cr: JsonCastlingRights): CastlingRights =
|
||||
CastlingRights(
|
||||
cr.whiteKingSide.getOrElse(false),
|
||||
cr.whiteQueenSide.getOrElse(false),
|
||||
cr.blackKingSide.getOrElse(false),
|
||||
cr.blackQueenSide.getOrElse(false)
|
||||
cr.blackQueenSide.getOrElse(false),
|
||||
)
|
||||
|
||||
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
|
||||
Right(moves.flatMap { m =>
|
||||
for
|
||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||
from <- m.from.flatMap(Square.fromAlgebraic)
|
||||
to <- m.to.flatMap(Square.fromAlgebraic)
|
||||
moveType <- m.`type`.flatMap(parseMoveType)
|
||||
yield Move(from, to, moveType)
|
||||
})
|
||||
@@ -110,10 +110,10 @@ object JsonParser extends GameContextImport:
|
||||
Some(MoveType.EnPassant)
|
||||
case Some("promotion") =>
|
||||
val piece = mt.promotionPiece match
|
||||
case Some("queen") => PromotionPiece.Queen
|
||||
case Some("rook") => PromotionPiece.Rook
|
||||
case Some("queen") => PromotionPiece.Queen
|
||||
case Some("rook") => PromotionPiece.Rook
|
||||
case Some("bishop") => PromotionPiece.Bishop
|
||||
case Some("knight") => PromotionPiece.Knight
|
||||
case _ => PromotionPiece.Queen // default
|
||||
case _ => PromotionPiece.Queen // default
|
||||
Some(MoveType.Promotion(piece))
|
||||
case _ => None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Square, File, Rank}
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.io.json.{JsonExporter, JsonParser}
|
||||
import java.nio.file.{Files, Paths}
|
||||
@@ -15,37 +15,35 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||
try
|
||||
val context = GameContext.initial
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(Files.exists(tmpFile))
|
||||
assert(Files.size(tmpFile) > 0)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: reads JSON file successfully") {
|
||||
val tmpFile = Files.createTempFile("chess_test_", ".json")
|
||||
try
|
||||
val originalContext = GameContext.initial
|
||||
|
||||
|
||||
// Save
|
||||
FileSystemGameService.saveGameToFile(originalContext, tmpFile, JsonExporter)
|
||||
|
||||
|
||||
// Load
|
||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
|
||||
|
||||
assert(result.isRight)
|
||||
val loaded = result.getOrElse(GameContext.initial)
|
||||
assert(loaded == originalContext)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: returns error on missing file") {
|
||||
val nonExistentFile = Paths.get("/tmp/nonexistent_chess_game_file_12345.json")
|
||||
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
|
||||
|
||||
val result = FileSystemGameService.loadGameFromFile(nonExistentFile, JsonParser)
|
||||
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
@@ -57,16 +55,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val context = GameContext.initial
|
||||
.withMove(move1)
|
||||
.withMove(move2)
|
||||
|
||||
|
||||
val saveResult = FileSystemGameService.saveGameToFile(context, tmpFile, JsonExporter)
|
||||
assert(saveResult.isRight)
|
||||
|
||||
|
||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 2)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("saveGameToFile: overwrites existing file") {
|
||||
@@ -76,18 +73,17 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
val context1 = GameContext.initial
|
||||
FileSystemGameService.saveGameToFile(context1, tmpFile, JsonExporter)
|
||||
val size1 = Files.size(tmpFile)
|
||||
|
||||
|
||||
// Write second file (should overwrite)
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context2 = GameContext.initial.withMove(move)
|
||||
FileSystemGameService.saveGameToFile(context2, tmpFile, JsonExporter)
|
||||
|
||||
|
||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 1)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("loadGameFromFile: handles invalid JSON in file") {
|
||||
@@ -95,10 +91,9 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
try
|
||||
Files.write(tmpFile, "{ invalid json}".getBytes())
|
||||
val result = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
|
||||
|
||||
assert(result.isLeft)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("round-trip: save and load preserves game state") {
|
||||
@@ -110,16 +105,15 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
.withMove(move1)
|
||||
.withMove(move2)
|
||||
.withHalfMoveClock(3)
|
||||
|
||||
|
||||
FileSystemGameService.saveGameToFile(original, tmpFile, JsonExporter)
|
||||
val loadResult = FileSystemGameService.loadGameFromFile(tmpFile, JsonParser)
|
||||
|
||||
|
||||
assert(loadResult.isRight)
|
||||
val loaded = loadResult.getOrElse(GameContext.initial)
|
||||
assert(loaded.moves.length == 2)
|
||||
assert(loaded.halfMoveClock == 3)
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
test("saveGameToFile: handles exporter that throws exception") {
|
||||
@@ -127,13 +121,12 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
|
||||
try
|
||||
val context = GameContext.initial
|
||||
val faultyExporter = new GameContextExport {
|
||||
def exportGameContext(c: GameContext): String =
|
||||
def exportGameContext(c: GameContext): String =
|
||||
throw new RuntimeException("Export failed")
|
||||
}
|
||||
|
||||
|
||||
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("Failed to save file"))
|
||||
finally
|
||||
Files.deleteIfExists(tmpFile)
|
||||
finally Files.deleteIfExists(tmpFile)
|
||||
}
|
||||
|
||||
+22
-22
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color, CastlingRights, Piece, PieceType}
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -13,71 +13,71 @@ class JsonExporterBranchCoverageSuite extends AnyFunSuite with Matchers:
|
||||
(PromotionPiece.Queen, "queen"),
|
||||
(PromotionPiece.Rook, "rook"),
|
||||
(PromotionPiece.Bishop, "bishop"),
|
||||
(PromotionPiece.Knight, "knight")
|
||||
(PromotionPiece.Knight, "knight"),
|
||||
)
|
||||
|
||||
for ((piece, expectedName) <- promotions) do
|
||||
for (piece, expectedName) <- promotions do
|
||||
val move = Move(Square(File.A, Rank.R7), Square(File.A, Rank.R8), MoveType.Promotion(piece))
|
||||
// Empty boards can cause issues in PgnExporter, using initial
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
// try-catch to ignore PgnExporter errors but cover convertMoveType
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include (s""""$expectedName"""")
|
||||
json should include(s""""$expectedName"""")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export normal non-capture move") {
|
||||
val quietMove = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))
|
||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"normal\"")
|
||||
val ctx = GameContext.initial.copy(moves = List(quietMove))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include("\"normal\"")
|
||||
}
|
||||
|
||||
test("export normal capture move manually") {
|
||||
val move = Move(Square(File.E, Rank.R4), Square(File.D, Rank.R5), MoveType.Normal(true))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"normal\"")
|
||||
json should include ("\"isCapture\": true")
|
||||
json should include("\"normal\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export all move type categories") {
|
||||
val move = Move(Square(File.D, Rank.R2), Square(File.D, Rank.R4))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
|
||||
json should include ("\"moves\"")
|
||||
json should include ("\"from\"")
|
||||
json should include ("\"to\"")
|
||||
|
||||
json should include("\"moves\"")
|
||||
json should include("\"from\"")
|
||||
json should include("\"to\"")
|
||||
}
|
||||
|
||||
test("export castle queenside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"castleQueenside\"")
|
||||
json should include("\"castleQueenside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export castle kingside move") {
|
||||
val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"castleKingside\"")
|
||||
json should include("\"castleKingside\"")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
test("export en passant move manually") {
|
||||
val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant)
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
val ctx = GameContext.initial.copy(moves = List(move))
|
||||
try {
|
||||
val json = JsonExporter.exportGameContext(ctx)
|
||||
json should include ("\"enPassant\"")
|
||||
json should include ("\"isCapture\": true")
|
||||
json should include("\"enPassant\"")
|
||||
json should include("\"isCapture\": true")
|
||||
} catch { case _: Exception => }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Board, Square, Piece, Color, PieceType, File, Rank, CastlingRights}
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -10,8 +10,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: exports initial position") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"metadata\"")
|
||||
json should include("\"gameState\"")
|
||||
json should include("\"moveHistory\"")
|
||||
@@ -21,8 +21,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: includes board pieces") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"a1\"")
|
||||
json should include("\"Rook\"")
|
||||
json should include("\"White\"")
|
||||
@@ -30,24 +30,24 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: includes turn information") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"turn\": \"White\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: includes castling rights") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"whiteKingSide\": true")
|
||||
json should include("\"whiteQueenSide\": true")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports with moves") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\"")
|
||||
json should include("\"from\"")
|
||||
json should include("\"to\"")
|
||||
@@ -57,8 +57,8 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: valid JSON structure") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should startWith("{")
|
||||
json should endWith("}")
|
||||
json should include("\"metadata\": {")
|
||||
@@ -67,47 +67,47 @@ class JsonExporterSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("exportGameContext: empty move history for initial position") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\": []")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports en passant square") {
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"enPassantSquare\": \"e3\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports null en passant square") {
|
||||
val context = GameContext.initial.copy(enPassantSquare = None)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"enPassantSquare\": null")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports different move destinations") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"moves\"")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports empty board") {
|
||||
val emptyBoard = Board(Map.empty)
|
||||
val context = GameContext.initial.copy(board = emptyBoard)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val context = GameContext.initial.copy(board = emptyBoard)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"board\": []")
|
||||
}
|
||||
|
||||
test("exportGameContext: exports all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
|
||||
json should include("\"whiteKingSide\": false")
|
||||
json should include("\"whiteQueenSide\": false")
|
||||
json should include("\"blackKingSide\": false")
|
||||
|
||||
@@ -40,7 +40,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
||||
Some("White"),
|
||||
Some(JsonCastlingRights()),
|
||||
Some("e3"),
|
||||
Some(5)
|
||||
Some(5),
|
||||
)
|
||||
assert(gs.board.contains(Nil))
|
||||
assert(gs.halfMoveClock.contains(5))
|
||||
@@ -88,7 +88,7 @@ class JsonModelExtraTestSuite extends AnyFunSuite with Matchers:
|
||||
Some(""),
|
||||
Some(Nil),
|
||||
Some(JsonCapturedPieces()),
|
||||
Some("2026-04-08T00:00:00Z")
|
||||
Some("2026-04-08T00:00:00Z"),
|
||||
)
|
||||
assert(record.metadata.nonEmpty)
|
||||
assert(record.timestamp.nonEmpty)
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse invalid turn color returns error") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "Invalid", "board": []},
|
||||
"moves": []
|
||||
@@ -19,7 +19,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid piece type filters it out") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -36,7 +36,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid color in board filters piece") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -53,7 +53,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse with missing turn uses default") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"board": []},
|
||||
"moves": []
|
||||
@@ -65,7 +65,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse with missing board uses empty") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White"},
|
||||
"moves": []
|
||||
@@ -77,7 +77,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse with missing moves uses empty list") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []}
|
||||
}"""
|
||||
@@ -88,7 +88,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid square in board filters it") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -105,7 +105,7 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse all valid piece types") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
@@ -124,11 +124,16 @@ class JsonParserEdgeCasesSuite extends AnyFunSuite with Matchers:
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
assert(ctx.board.pieces.size == 6)
|
||||
assert(ctx.board.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1)).get.pieceType == PieceType.Pawn)
|
||||
assert(
|
||||
ctx.board
|
||||
.pieceAt(de.nowchess.api.board.Square(de.nowchess.api.board.File.A, de.nowchess.api.board.Rank.R1))
|
||||
.get
|
||||
.pieceType == PieceType.Pawn,
|
||||
)
|
||||
}
|
||||
|
||||
test("parse with all castling rights false") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
|
||||
@@ -8,7 +8,7 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse completely invalid JSON returns error") {
|
||||
val invalidJson = "{ this is not valid json at all }"
|
||||
val result = JsonParser.importGameContext(invalidJson)
|
||||
val result = JsonParser.importGameContext(invalidJson)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
@@ -26,26 +26,26 @@ class JsonParserErrorHandlingSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse malformed JSON object returns error") {
|
||||
val malformed = """{"metadata": {"unclosed": """
|
||||
val result = JsonParser.importGameContext(malformed)
|
||||
val result = JsonParser.importGameContext(malformed)
|
||||
assert(result.isLeft)
|
||||
assert(result.left.toOption.get.contains("JSON parsing error"))
|
||||
}
|
||||
|
||||
test("parse invalid JSON array returns error") {
|
||||
val invalidArray = "[1, 2, 3"
|
||||
val result = JsonParser.importGameContext(invalidArray)
|
||||
val result = JsonParser.importGameContext(invalidArray)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse JSON with missing required fields") {
|
||||
val json = """{"metadata": {}}"""
|
||||
val json = """{"metadata": {}}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
// Should still succeed because all fields have defaults
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("parse valid JSON with invalid turn falls back to default") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Color, PieceType, Piece, Square, File, Rank}
|
||||
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -9,7 +9,7 @@ import org.scalatest.matchers.should.Matchers
|
||||
class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parse all move type variations") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "result": "*"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [
|
||||
@@ -34,7 +34,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse invalid move type defaults to None") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {"event": "Game"},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "e4", "type": {"type": "unknown"}}]
|
||||
@@ -45,7 +45,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse promotion with default piece") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "a7", "to": "a8", "type": {"type": "promotion", "promotionPiece": "invalid"}}]
|
||||
@@ -56,7 +56,7 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse move with missing from/to skips it") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e2", "to": "invalid", "type": {"type": "normal"}}]
|
||||
@@ -69,26 +69,26 @@ class JsonParserMoveTypeSuite extends AnyFunSuite with Matchers:
|
||||
}
|
||||
|
||||
test("parse with invalid JSON returns error") {
|
||||
val json = """{"invalid json"""
|
||||
val json = """{"invalid json"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("parse normal move with isCapture true") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {"turn": "White", "board": []},
|
||||
"moves": [{"from": "e4", "to": "d5", "type": {"type": "normal", "isCapture": true}}]
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
assert(result.isRight)
|
||||
val ctx = result.toOption.get
|
||||
val ctx = result.toOption.get
|
||||
val move = ctx.moves.head
|
||||
assert(move.moveType == MoveType.Normal(true))
|
||||
}
|
||||
|
||||
test("parse board with invalid pieces filters them") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {},
|
||||
"gameState": {
|
||||
"turn": "White",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package de.nowchess.io.json
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.board.{Color, File, Rank, Square, CastlingRights}
|
||||
import de.nowchess.api.board.{CastlingRights, Color, File, Rank, Square}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -9,39 +9,39 @@ import org.scalatest.matchers.should.Matchers
|
||||
class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: parses valid JSON") {
|
||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||
val json = JsonExporter.exportGameContext(GameContext.initial)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("importGameContext: restores board state") {
|
||||
val context = GameContext.initial
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result == Right(context))
|
||||
}
|
||||
|
||||
test("importGameContext: restores turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: restores moves") {
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles empty board") {
|
||||
val json = """{
|
||||
val json = """{
|
||||
"metadata": {"event": "Game", "players": {"white": "A", "black": "B"}, "date": "2026-04-06", "result": "*"},
|
||||
"gameState": {
|
||||
"board": [],
|
||||
@@ -56,30 +56,31 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
"timestamp": "2026-04-06T00:00:00Z"
|
||||
}"""
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.board.pieces.isEmpty) == Right(true))
|
||||
}
|
||||
|
||||
test("importGameContext: returns error on invalid JSON") {
|
||||
val result = JsonParser.importGameContext("not valid json {{{")
|
||||
|
||||
|
||||
assert(result.isLeft)
|
||||
}
|
||||
|
||||
test("importGameContext: handles missing fields with defaults") {
|
||||
val json = "{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||
val json =
|
||||
"{\"metadata\": {}, \"gameState\": {\"board\": [], \"turn\": \"White\", \"castlingRights\": {\"whiteKingSide\": true, \"whiteQueenSide\": true, \"blackKingSide\": true, \"blackQueenSide\": true}, \"enPassantSquare\": null, \"halfMoveClock\": 0}, \"moves\": [], \"moveHistory\": \"\", \"capturedPieces\": {\"byWhite\": [], \"byBlack\": []}, \"timestamp\": \"2026-01-01T00:00:00Z\"}"
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
|
||||
assert(result.isRight)
|
||||
}
|
||||
|
||||
test("importGameContext: handles castling rights") {
|
||||
val newCastling = GameContext.initial.castlingRights.copy(whiteKingSide = false)
|
||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val context = GameContext.initial.withCastlingRights(newCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights.whiteKingSide) == Right(false))
|
||||
}
|
||||
|
||||
@@ -91,7 +92,7 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
.withMove(move2)
|
||||
.withTurn(Color.White)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val restored = JsonParser.importGameContext(json)
|
||||
|
||||
assert(restored.map(_.moves.length) == Right(2))
|
||||
@@ -100,55 +101,55 @@ class JsonParserSuite extends AnyFunSuite with Matchers:
|
||||
|
||||
test("importGameContext: handles half-move clock") {
|
||||
val context = GameContext.initial.withHalfMoveClock(5)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.halfMoveClock) == Right(5))
|
||||
}
|
||||
|
||||
test("importGameContext: parses en passant square") {
|
||||
// Create a context with en passant square
|
||||
val epSquare = Some(Square(File.E, Rank.R3))
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val context = GameContext.initial.copy(enPassantSquare = epSquare)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.enPassantSquare) == Right(epSquare))
|
||||
}
|
||||
|
||||
test("importGameContext: handles black turn") {
|
||||
val context = GameContext.initial.withTurn(Color.Black)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.turn) == Right(Color.Black))
|
||||
}
|
||||
|
||||
test("importGameContext: preserves basic moves in JSON round-trip") {
|
||||
// Use simple move without explicit moveType to let system handle it
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
|
||||
val context = GameContext.initial.withMove(move)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.isRight)
|
||||
assert(result.map(_.moves.length) == Right(1))
|
||||
}
|
||||
|
||||
test("importGameContext: handles all castling rights disabled") {
|
||||
val noCastling = CastlingRights(false, false, false, false)
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val context = GameContext.initial.withCastlingRights(noCastling)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(noCastling))
|
||||
}
|
||||
|
||||
test("importGameContext: handles mixed castling rights") {
|
||||
val mixed = CastlingRights(true, false, false, true)
|
||||
val mixed = CastlingRights(true, false, false, true)
|
||||
val context = GameContext.initial.withCastlingRights(mixed)
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
val json = JsonExporter.exportGameContext(context)
|
||||
val result = JsonParser.importGameContext(json)
|
||||
|
||||
assert(result.map(_.castlingRights) == Right(mixed))
|
||||
}
|
||||
|
||||
@@ -17,37 +17,36 @@ import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||
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 scalafx.stage.FileChooser
|
||||
import scalafx.stage.FileChooser.ExtensionFilter
|
||||
|
||||
/** ScalaFX chess board view that displays the game state.
|
||||
* Uses chess sprites and color palette.
|
||||
* Handles user interactions (clicks) and sends moves to GameEngine.
|
||||
*/
|
||||
/** ScalaFX chess board view that displays the game state. Uses chess sprites and color palette. Handles user
|
||||
* interactions (clicks) and sends moves to GameEngine.
|
||||
*/
|
||||
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 boardGrid = new GridPane()
|
||||
private val boardGrid = new GridPane()
|
||||
private val messageLabel = new Label {
|
||||
text = "Welcome!"
|
||||
font = Font.font(comicSansFontFamily, 16)
|
||||
padding = Insets(10)
|
||||
}
|
||||
|
||||
private var currentBoard: Board = engine.board
|
||||
private var currentTurn: Color = engine.turn
|
||||
|
||||
private var currentBoard: Board = engine.board
|
||||
private var currentTurn: Color = engine.turn
|
||||
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 redoButton: Button = uninitialized
|
||||
|
||||
// Initialize UI
|
||||
initializeBoard()
|
||||
|
||||
|
||||
top = new VBox {
|
||||
padding = Insets(10)
|
||||
spacing = 5
|
||||
@@ -58,17 +57,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 24)
|
||||
style = "-fx-font-weight: bold;"
|
||||
},
|
||||
messageLabel
|
||||
messageLabel,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
center = new VBox {
|
||||
padding = Insets(20)
|
||||
alignment = Pos.Center
|
||||
style = s"-fx-background-color: ${PieceSprites.SquareColors.Border};"
|
||||
children = boardGrid
|
||||
}
|
||||
|
||||
|
||||
bottom = new VBox {
|
||||
padding = Insets(10)
|
||||
spacing = 8
|
||||
@@ -86,8 +85,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
disable = !engine.canUndo
|
||||
}
|
||||
undoButton
|
||||
},
|
||||
{
|
||||
}, {
|
||||
redoButton = new Button("Redo") {
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
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)
|
||||
onAction = _ => engine.reset()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #E1EAA9;"
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
new HBox {
|
||||
@@ -126,7 +124,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doPgnImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #B9DAC4;"
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
new HBox {
|
||||
@@ -142,17 +140,17 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
font = Font.font(comicSansFontFamily, 12)
|
||||
onAction = _ => doJsonImport()
|
||||
style = "-fx-background-radius: 8; -fx-background-color: #C4B9DA;"
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private def initializeBoard(): Unit =
|
||||
boardGrid.padding = Insets(5)
|
||||
boardGrid.hgap = 0
|
||||
boardGrid.vgap = 0
|
||||
|
||||
|
||||
// Create 8x8 board with rank/file labels
|
||||
for
|
||||
rank <- 0 until 8
|
||||
@@ -161,13 +159,13 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
val square = createSquare(rank, file)
|
||||
squareViews((rank, file)) = square
|
||||
boardGrid.add(square, file, 7 - rank) // Flip rank for proper display
|
||||
|
||||
|
||||
updateBoard(currentBoard, currentTurn)
|
||||
|
||||
|
||||
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 bgRect = new Rectangle {
|
||||
width = squareSize
|
||||
height = squareSize
|
||||
@@ -175,21 +173,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
arcWidth = 8
|
||||
arcHeight = 8
|
||||
}
|
||||
|
||||
|
||||
val square = new StackPane {
|
||||
children = Seq(bgRect)
|
||||
onMouseClicked = _ => handleSquareClick(rank, file)
|
||||
style = "-fx-cursor: hand;"
|
||||
}
|
||||
|
||||
|
||||
square
|
||||
|
||||
|
||||
private def handleSquareClick(rank: Int, file: Int): Unit =
|
||||
if engine.isPendingPromotion then
|
||||
return // Don't allow moves during promotion
|
||||
|
||||
if engine.isPendingPromotion then return // Don't allow moves during promotion
|
||||
|
||||
val clickedSquare = Square(File.values(file), Rank.values(rank))
|
||||
|
||||
|
||||
selectedSquare match
|
||||
case None =>
|
||||
// First click - select piece if it belongs to current player
|
||||
@@ -198,13 +195,14 @@ 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)
|
||||
.collect { case move if move.from == clickedSquare => move.to }
|
||||
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)
|
||||
highlightSquare(sq.rank.ordinal, sq.file.ordinal, PieceSprites.SquareColors.ValidMove)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
case Some(fromSquare) =>
|
||||
// Second click - attempt move
|
||||
if clickedSquare == fromSquare then
|
||||
@@ -216,21 +214,21 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
val moveStr = s"${fromSquare}$clickedSquare"
|
||||
engine.processUserInput(moveStr)
|
||||
selectedSquare = None
|
||||
|
||||
|
||||
def updateBoard(board: Board, turn: Color): Unit =
|
||||
currentBoard = board
|
||||
currentTurn = turn
|
||||
selectedSquare = None
|
||||
|
||||
|
||||
// Update all squares
|
||||
for
|
||||
rank <- 0 until 8
|
||||
file <- 0 until 8
|
||||
do
|
||||
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 bgRect = new Rectangle {
|
||||
width = squareSize
|
||||
height = squareSize
|
||||
@@ -238,16 +236,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
arcWidth = 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 children = pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
|
||||
|
||||
stackPane.children = children
|
||||
}
|
||||
|
||||
@@ -266,20 +264,20 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
arcWidth = 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)
|
||||
|
||||
|
||||
stackPane.children = pieceOption match
|
||||
case Some(piece) =>
|
||||
Seq(bgRect, PieceSprites.loadPieceImage(piece, squareSize * 0.8))
|
||||
case None =>
|
||||
Seq(bgRect)
|
||||
}
|
||||
|
||||
|
||||
def showMessage(msg: String): Unit =
|
||||
messageLabel.text = msg
|
||||
|
||||
|
||||
def showPromotionDialog(from: Square, to: Square): Unit =
|
||||
val choices = Seq("Queen", "Rook", "Bishop", "Knight")
|
||||
val dialog = new ChoiceDialog(defaultChoice = "Queen", choices = choices) {
|
||||
@@ -288,14 +286,14 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
headerText = "Choose promotion piece"
|
||||
contentText = "Promote to:"
|
||||
}
|
||||
|
||||
|
||||
val result = dialog.showAndWait()
|
||||
result match
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
case Some("Queen") => engine.completePromotion(PromotionPiece.Queen)
|
||||
case Some("Rook") => engine.completePromotion(PromotionPiece.Rook)
|
||||
case Some("Bishop") => engine.completePromotion(PromotionPiece.Bishop)
|
||||
case Some("Knight") => engine.completePromotion(PromotionPiece.Knight)
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
case _ => engine.completePromotion(PromotionPiece.Queen) // Default
|
||||
|
||||
private def doFenExport(): Unit =
|
||||
doExport(FenExporter, "FEN")
|
||||
@@ -316,16 +314,16 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
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
|
||||
JsonExporter,
|
||||
)
|
||||
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")
|
||||
|
||||
private def doJsonImport(): Unit =
|
||||
@@ -334,12 +332,12 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
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
|
||||
JsonParser,
|
||||
)
|
||||
result match
|
||||
case Right(gameContext) =>
|
||||
@@ -353,7 +351,7 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
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 =>
|
||||
importer.importGameContext(input) match
|
||||
case Right(gameContext) =>
|
||||
@@ -362,7 +360,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B
|
||||
case Left(err) =>
|
||||
showMessage(s"⚠️ $formatName Error: $err")
|
||||
}
|
||||
}
|
||||
|
||||
private def showCopyDialog(title: String, content: String): Unit =
|
||||
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.getButtonTypes.addAll(
|
||||
javafx.scene.control.ButtonType.OK,
|
||||
javafx.scene.control.ButtonType.CANCEL
|
||||
javafx.scene.control.ButtonType.CANCEL,
|
||||
)
|
||||
dialog.setResultConverter { bt =>
|
||||
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)
|
||||
val result = dialog.showAndWait()
|
||||
if result.isPresent && result.get != null && result.get.nonEmpty then Some(result.get) else None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user