fix(rules): Serializers
Build & Test (NowChessSystems) TeamCity build failed

Added serializers like in IO
This commit is contained in:
LQ63
2026-04-21 20:42:24 +02:00
parent 4a69198e3d
commit 74f846c95c
15 changed files with 251 additions and 383 deletions
@@ -1,11 +1,29 @@
package de.nowchess.rules.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.Square
import de.nowchess.api.move.MoveType
import de.nowchess.rules.json.*
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@Singleton
class JacksonConfig extends ObjectMapperCustomizer:
def customize(mapper: ObjectMapper): Unit =
mapper.registerModule(DefaultScalaModule)
mapper.registerModule(new DefaultScalaModule() {
override def version(): Version =
// scalafix:off DisableSyntax.null
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
val mod = new SimpleModule()
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
mod.addSerializer(classOf[Square], new SquareSerializer())
mod.addDeserializer(classOf[Square], new SquareDeserializer())
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
mapper.registerModule(mod)
@@ -1,128 +0,0 @@
package de.nowchess.rules.dto
import de.nowchess.api.board.{Board, CastlingRights, Color, Piece, PieceType, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
object DtoMapper:
def toColor(s: String): Either[String, Color] = s match
case "White" => Right(Color.White)
case "Black" => Right(Color.Black)
case other => Left(s"Unknown color: $other")
def toPieceType(s: String): Either[String, PieceType] = s match
case "Pawn" => Right(PieceType.Pawn)
case "Knight" => Right(PieceType.Knight)
case "Bishop" => Right(PieceType.Bishop)
case "Rook" => Right(PieceType.Rook)
case "Queen" => Right(PieceType.Queen)
case "King" => Right(PieceType.King)
case other => Left(s"Unknown piece type: $other")
def toSquare(s: String): Either[String, Square] =
Square.fromAlgebraic(s).toRight(s"Invalid square: $s")
def toMoveType(dto: MoveDto): Either[String, MoveType] = dto.moveType match
case "normal" => Right(MoveType.Normal(isCapture = false))
case "capture" => Right(MoveType.Normal(isCapture = true))
case "castleKingside" => Right(MoveType.CastleKingside)
case "castleQueenside" => Right(MoveType.CastleQueenside)
case "enPassant" => Right(MoveType.EnPassant)
case "promotion" =>
dto.promotionPiece.toRight("Missing promotion piece").flatMap(toPromotionPiece).map(MoveType.Promotion(_))
case other => Left(s"Unknown move type: $other")
def toMove(dto: MoveDto): Either[String, Move] =
for
from <- toSquare(dto.from)
to <- toSquare(dto.to)
moveType <- toMoveType(dto)
yield Move(from, to, moveType)
def toBoard(pieces: List[PieceOnSquareDto]): Either[String, Board] =
sequenceList(pieces.map(toPieceOnSquare)).map(entries => Board(entries.toMap))
def toGameContext(dto: GameContextDto): Either[String, GameContext] =
for
board <- toBoard(dto.board)
turn <- toColor(dto.turn)
epSquare <- sequenceOpt(dto.enPassantSquare.map(toSquare))
moves <- sequenceList(dto.moves.map(toMove))
initialBoard <- toBoard(dto.initialBoard)
yield GameContext(
board = board,
turn = turn,
castlingRights = toCastlingRights(dto.castlingRights),
enPassantSquare = epSquare,
halfMoveClock = dto.halfMoveClock,
moves = moves,
initialBoard = initialBoard,
)
def fromMove(move: Move): MoveDto =
val (moveType, promotionPiece) = fromMoveType(move.moveType)
MoveDto(move.from.toString, move.to.toString, moveType, promotionPiece)
def fromBoard(board: Board): List[PieceOnSquareDto] =
board.pieces.toList.map { case (sq, p) =>
PieceOnSquareDto(sq.toString, p.color.label, p.pieceType.label)
}
def fromGameContext(ctx: GameContext): GameContextDto =
GameContextDto(
board = fromBoard(ctx.board),
turn = ctx.turn.label,
castlingRights = fromCastlingRights(ctx.castlingRights),
enPassantSquare = ctx.enPassantSquare.map(_.toString),
halfMoveClock = ctx.halfMoveClock,
moves = ctx.moves.map(fromMove),
initialBoard = fromBoard(ctx.initialBoard),
)
private def toPromotionPiece(s: String): Either[String, PromotionPiece] = s match
case "Queen" => Right(PromotionPiece.Queen)
case "Rook" => Right(PromotionPiece.Rook)
case "Bishop" => Right(PromotionPiece.Bishop)
case "Knight" => Right(PromotionPiece.Knight)
case other => Left(s"Unknown promotion piece: $other")
private def fromMoveType(mt: MoveType): (String, Option[String]) = mt match
case MoveType.Normal(false) => ("normal", None)
case MoveType.Normal(true) => ("capture", None)
case MoveType.CastleKingside => ("castleKingside", None)
case MoveType.CastleQueenside => ("castleQueenside", None)
case MoveType.EnPassant => ("enPassant", None)
case MoveType.Promotion(pp) => ("promotion", Some(fromPromotionPiece(pp)))
private def fromPromotionPiece(pp: PromotionPiece): String = pp match
case PromotionPiece.Queen => "Queen"
case PromotionPiece.Rook => "Rook"
case PromotionPiece.Bishop => "Bishop"
case PromotionPiece.Knight => "Knight"
private def toCastlingRights(dto: CastlingRightsDto): CastlingRights =
CastlingRights(dto.whiteKingSide, dto.whiteQueenSide, dto.blackKingSide, dto.blackQueenSide)
private def fromCastlingRights(cr: CastlingRights): CastlingRightsDto =
CastlingRightsDto(cr.whiteKingSide, cr.whiteQueenSide, cr.blackKingSide, cr.blackQueenSide)
private def toPieceOnSquare(dto: PieceOnSquareDto): Either[String, (Square, Piece)] =
for
sq <- toSquare(dto.square)
color <- toColor(dto.color)
pieceType <- toPieceType(dto.pieceType)
yield sq -> Piece(color, pieceType)
private def sequenceList[R](list: List[Either[String, R]]): Either[String, List[R]] =
list.foldLeft[Either[String, List[R]]](Right(List.empty)) {
case (Right(acc), Right(v)) => Right(acc :+ v)
case (Left(e), _) => Left(e)
case (_, Left(e)) => Left(e)
}
private def sequenceOpt[R](opt: Option[Either[String, R]]): Either[String, Option[R]] =
opt match
case None => Right(None)
case Some(Right(v)) => Right(Some(v))
case Some(Left(e)) => Left(e)
@@ -1,37 +1,14 @@
package de.nowchess.rules.dto
case class PieceOnSquareDto(square: String, color: String, pieceType: String)
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
case class CastlingRightsDto(
whiteKingSide: Boolean,
whiteQueenSide: Boolean,
blackKingSide: Boolean,
blackQueenSide: Boolean,
)
case class ContextRequest(context: GameContext)
case class MoveDto(
from: String,
to: String,
moveType: String,
promotionPiece: Option[String],
)
case class ContextSquareRequest(context: GameContext, square: String)
case class GameContextDto(
board: List[PieceOnSquareDto],
turn: String,
castlingRights: CastlingRightsDto,
enPassantSquare: Option[String],
halfMoveClock: Int,
moves: List[MoveDto],
initialBoard: List[PieceOnSquareDto],
)
case class ContextMoveRequest(context: GameContext, move: Move)
case class ContextRequest(context: GameContextDto)
case class ContextSquareRequest(context: GameContextDto, square: String)
case class ContextMoveRequest(context: GameContextDto, move: MoveDto)
case class MovesResponse(moves: List[MoveDto])
case class MovesResponse(moves: List[Move])
case class BooleanResponse(result: Boolean)
@@ -0,0 +1,17 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.core.{JsonParseException, JsonParser}
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
import de.nowchess.api.move.{MoveType, PromotionPiece}
class MoveTypeDeserializer extends JsonDeserializer[MoveType]:
override def deserialize(p: JsonParser, ctx: DeserializationContext): MoveType =
val node = p.getCodec.readTree[ObjectNode](p)
node.get("type").asText() match
case "normal" => MoveType.Normal(node.get("isCapture").asBoolean(false))
case "castleKingside" => MoveType.CastleKingside
case "castleQueenside" => MoveType.CastleQueenside
case "enPassant" => MoveType.EnPassant
case "promotion" => MoveType.Promotion(PromotionPiece.valueOf(node.get("piece").asText()))
case t => throw new JsonParseException(p, s"Unknown move type: $t")
@@ -0,0 +1,23 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.move.MoveType
class MoveTypeSerializer extends JsonSerializer[MoveType]:
override def serialize(value: MoveType, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeStartObject()
value match
case MoveType.Normal(isCapture) =>
gen.writeStringField("type", "normal")
gen.writeBooleanField("isCapture", isCapture)
case MoveType.CastleKingside =>
gen.writeStringField("type", "castleKingside")
case MoveType.CastleQueenside =>
gen.writeStringField("type", "castleQueenside")
case MoveType.EnPassant =>
gen.writeStringField("type", "enPassant")
case MoveType.Promotion(piece) =>
gen.writeStringField("type", "promotion")
gen.writeStringField("piece", piece.toString)
gen.writeEndObject()
@@ -0,0 +1,9 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
import de.nowchess.api.board.Square
class SquareDeserializer extends JsonDeserializer[Square]:
override def deserialize(p: JsonParser, ctx: DeserializationContext): Square =
Square.fromAlgebraic(p.getText).orNull
@@ -0,0 +1,8 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
import de.nowchess.api.board.Square
class SquareKeyDeserializer extends KeyDeserializer:
override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef =
Square.fromAlgebraic(key).orNull
@@ -0,0 +1,9 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.board.Square
class SquareKeySerializer extends JsonSerializer[Square]:
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeFieldName(value.toString)
@@ -0,0 +1,9 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.board.Square
class SquareSerializer extends JsonSerializer[Square]:
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeString(value.toString)
@@ -1,5 +1,7 @@
package de.nowchess.rules.resource
import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.rules.dto.*
import de.nowchess.rules.sets.DefaultRules
import jakarta.enterprise.context.ApplicationScoped
@@ -12,9 +14,8 @@ class RuleSetResource:
private val rules = DefaultRules
// scalafix:off DisableSyntax.throw
private def parse[T](e: Either[String, T]): T = e match
case Right(v) => v
case Left(msg) => throw BadRequestException(msg)
private def parseSquare(s: String): Square =
Square.fromAlgebraic(s).getOrElse(throw new BadRequestException(s"Invalid square: $s"))
// scalafix:on DisableSyntax.throw
@POST
@@ -22,73 +23,67 @@ class RuleSetResource:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def candidateMoves(req: ContextSquareRequest): MovesResponse =
val ctx = parse(DtoMapper.toGameContext(req.context))
val sq = parse(DtoMapper.toSquare(req.square))
MovesResponse(rules.candidateMoves(ctx)(sq).map(DtoMapper.fromMove))
MovesResponse(rules.candidateMoves(req.context)(parseSquare(req.square)))
@POST
@Path("/legal-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def legalMoves(req: ContextSquareRequest): MovesResponse =
val ctx = parse(DtoMapper.toGameContext(req.context))
val sq = parse(DtoMapper.toSquare(req.square))
MovesResponse(rules.legalMoves(ctx)(sq).map(DtoMapper.fromMove))
MovesResponse(rules.legalMoves(req.context)(parseSquare(req.square)))
@POST
@Path("/all-legal-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def allLegalMoves(req: ContextRequest): MovesResponse =
MovesResponse(rules.allLegalMoves(parse(DtoMapper.toGameContext(req.context))).map(DtoMapper.fromMove))
MovesResponse(rules.allLegalMoves(req.context))
@POST
@Path("/is-check")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isCheck(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isCheck(parse(DtoMapper.toGameContext(req.context))))
BooleanResponse(rules.isCheck(req.context))
@POST
@Path("/is-checkmate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isCheckmate(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isCheckmate(parse(DtoMapper.toGameContext(req.context))))
BooleanResponse(rules.isCheckmate(req.context))
@POST
@Path("/is-stalemate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isStalemate(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isStalemate(parse(DtoMapper.toGameContext(req.context))))
BooleanResponse(rules.isStalemate(req.context))
@POST
@Path("/is-insufficient-material")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isInsufficientMaterial(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isInsufficientMaterial(parse(DtoMapper.toGameContext(req.context))))
BooleanResponse(rules.isInsufficientMaterial(req.context))
@POST
@Path("/is-fifty-move-rule")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isFiftyMoveRule(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isFiftyMoveRule(parse(DtoMapper.toGameContext(req.context))))
BooleanResponse(rules.isFiftyMoveRule(req.context))
@POST
@Path("/is-threefold-repetition")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isThreefoldRepetition(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isThreefoldRepetition(parse(DtoMapper.toGameContext(req.context))))
BooleanResponse(rules.isThreefoldRepetition(req.context))
@POST
@Path("/apply-move")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: ContextMoveRequest): GameContextDto =
val ctx = parse(DtoMapper.toGameContext(req.context))
val move = parse(DtoMapper.toMove(req.move))
DtoMapper.fromGameContext(rules.applyMove(ctx)(move))
def applyMove(req: ContextMoveRequest): GameContext =
rules.applyMove(req.context)(req.move)
@@ -1,14 +1,30 @@
package de.nowchess.rules.config
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.move.MoveType
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JacksonConfigTest extends AnyFunSuite with Matchers:
test("customize registers DefaultScalaModule enabling Option serialization"):
val config = new JacksonConfig()
val mapper = new ObjectMapper()
config.customize(mapper)
private val mapper: ObjectMapper =
val m = new ObjectMapper()
new JacksonConfig().customize(m)
m
test("customize enables Option serialization via DefaultScalaModule"):
mapper.writeValueAsString(None) shouldBe "null"
mapper.writeValueAsString(Some("hello")) shouldBe """"hello""""
test("customize registers SquareSerializer"):
mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4""""
test("customize registers MoveTypeSerializer"):
mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
test("customize registers SquareDeserializer"):
mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4)
test("customize registers MoveTypeDeserializer"):
mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
@@ -1,174 +0,0 @@
package de.nowchess.rules.dto
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class DtoMapperTest extends AnyFunSuite with Matchers:
// ── toColor ────────────────────────────────────────────────────────
test("toColor converts White"):
DtoMapper.toColor("White") shouldBe Right(Color.White)
test("toColor converts Black"):
DtoMapper.toColor("Black") shouldBe Right(Color.Black)
test("toColor rejects unknown"):
DtoMapper.toColor("Red") shouldBe a[Left[?, ?]]
// ── toPieceType ────────────────────────────────────────────────────
test("toPieceType converts all piece types"):
DtoMapper.toPieceType("Pawn") shouldBe Right(PieceType.Pawn)
DtoMapper.toPieceType("Knight") shouldBe Right(PieceType.Knight)
DtoMapper.toPieceType("Bishop") shouldBe Right(PieceType.Bishop)
DtoMapper.toPieceType("Rook") shouldBe Right(PieceType.Rook)
DtoMapper.toPieceType("Queen") shouldBe Right(PieceType.Queen)
DtoMapper.toPieceType("King") shouldBe Right(PieceType.King)
test("toPieceType rejects unknown"):
DtoMapper.toPieceType("Dragon") shouldBe a[Left[?, ?]]
// ── toSquare ───────────────────────────────────────────────────────
test("toSquare converts valid algebraic"):
DtoMapper.toSquare("e4") shouldBe Right(Square(File.E, Rank.R4))
test("toSquare rejects invalid"):
DtoMapper.toSquare("z9") shouldBe a[Left[?, ?]]
// ── toMoveType ────────────────────────────────────────────────────
test("toMoveType converts normal non-capture"):
DtoMapper.toMoveType(MoveDto("e2", "e4", "normal", None)) shouldBe Right(MoveType.Normal(false))
test("toMoveType converts capture"):
DtoMapper.toMoveType(MoveDto("e2", "d3", "capture", None)) shouldBe Right(MoveType.Normal(true))
test("toMoveType converts castleKingside"):
DtoMapper.toMoveType(MoveDto("e1", "g1", "castleKingside", None)) shouldBe Right(MoveType.CastleKingside)
test("toMoveType converts castleQueenside"):
DtoMapper.toMoveType(MoveDto("e1", "c1", "castleQueenside", None)) shouldBe Right(MoveType.CastleQueenside)
test("toMoveType converts enPassant"):
DtoMapper.toMoveType(MoveDto("e5", "d6", "enPassant", None)) shouldBe Right(MoveType.EnPassant)
test("toMoveType converts all promotion pieces"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Queen"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Queen),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Rook"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Rook),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Bishop"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Bishop),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Knight"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Knight),
)
test("toMoveType rejects promotion without piece"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", None)) shouldBe a[Left[?, ?]]
test("toMoveType rejects promotion with unknown piece"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Pawn"))) shouldBe a[Left[?, ?]]
test("toMoveType rejects unknown type"):
DtoMapper.toMoveType(MoveDto("e2", "e4", "unknown", None)) shouldBe a[Left[?, ?]]
// ── toBoard ───────────────────────────────────────────────────────
test("toBoard builds valid board"):
val pieces = List(
PieceOnSquareDto("e1", "White", "King"),
PieceOnSquareDto("e8", "Black", "King"),
)
val result = DtoMapper.toBoard(pieces)
result.isRight shouldBe true
result.map(_.pieceAt(Square(File.E, Rank.R1))) shouldBe Right(Some(Piece(Color.White, PieceType.King)))
test("toBoard rejects invalid square"):
DtoMapper.toBoard(List(PieceOnSquareDto("z9", "White", "King"))) shouldBe a[Left[?, ?]]
test("toBoard rejects invalid color"):
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "Red", "King"))) shouldBe a[Left[?, ?]]
test("toBoard rejects invalid piece type"):
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "White", "Dragon"))) shouldBe a[Left[?, ?]]
test("toBoard with multiple invalid pieces covers all sequenceList branches"):
val pieces = List(
PieceOnSquareDto("z9", "White", "King"),
PieceOnSquareDto("z8", "White", "Queen"),
)
DtoMapper.toBoard(pieces) shouldBe a[Left[?, ?]]
// ── toGameContext ─────────────────────────────────────────────────
test("toGameContext round-trips initial position"):
val ctx = GameContext.initial
val dto = DtoMapper.fromGameContext(ctx)
DtoMapper.toGameContext(dto) shouldBe Right(ctx)
test("toGameContext rejects invalid turn"):
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red")
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid en passant square"):
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(enPassantSquare = Some("z9"))
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid initial board"):
val badBoard = List(PieceOnSquareDto("z9", "White", "King"))
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(initialBoard = badBoard)
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid move"):
val badMove = MoveDto("z9", "e4", "normal", None)
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(moves = List(badMove))
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
// ── fromGameContext ───────────────────────────────────────────────
test("fromGameContext includes en passant square when present"):
val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3)))
DtoMapper.fromGameContext(ctx).enPassantSquare shouldBe Some("e3")
test("toGameContext round-trips a valid en passant square"):
val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3)))
DtoMapper.toGameContext(DtoMapper.fromGameContext(ctx)).map(_.enPassantSquare) shouldBe Right(
Some(Square(File.E, Rank.R3)),
)
// ── fromMove ──────────────────────────────────────────────────────
test("fromMove converts all move types"):
DtoMapper.fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))).moveType shouldBe "normal"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true)))
.moveType shouldBe "capture"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))
.moveType shouldBe "castleKingside"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))
.moveType shouldBe "castleQueenside"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant))
.moveType shouldBe "enPassant"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)))
.promotionPiece shouldBe Some("Queen")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook)))
.promotionPiece shouldBe Some("Rook")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop)))
.promotionPiece shouldBe Some("Bishop")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)))
.promotionPiece shouldBe Some("Knight")
@@ -0,0 +1,98 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.move.{MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonSerializersTest extends AnyFunSuite with Matchers:
private val mapper: ObjectMapper =
val m = new ObjectMapper()
val mod = new SimpleModule()
m.registerModule(DefaultScalaModule)
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
mod.addSerializer(classOf[Square], new SquareSerializer())
mod.addDeserializer(classOf[Square], new SquareDeserializer())
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
m.registerModule(mod)
m
private val e4 = Square(File.E, Rank.R4)
// ── SquareKeySerializer ───────────────────────────────────────────
test("SquareKeySerializer writes square as map field name"):
mapper.writeValueAsString(Map(e4 -> "piece")) shouldBe """{"e4":"piece"}"""
// ── SquareKeyDeserializer ─────────────────────────────────────────
test("SquareKeyDeserializer returns square for valid key"):
new SquareKeyDeserializer().deserializeKey("e4", null) shouldBe e4
test("SquareKeyDeserializer returns null for invalid key"):
new SquareKeyDeserializer().deserializeKey("z9", null) shouldBe null
// ── SquareSerializer/Deserializer ─────────────────────────────────
test("SquareSerializer writes square as string"):
mapper.writeValueAsString(e4) shouldBe """"e4""""
test("SquareDeserializer reads valid square string"):
mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4
test("SquareDeserializer returns null for invalid square string"):
mapper.readValue(""""z9"""", classOf[Square]) shouldBe null
// ── MoveTypeSerializer ────────────────────────────────────────────
test("MoveTypeSerializer serializes Normal non-capture"):
mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}"""
test("MoveTypeSerializer serializes Normal capture"):
mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}"""
test("MoveTypeSerializer serializes CastleKingside"):
mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
test("MoveTypeSerializer serializes CastleQueenside"):
mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}"""
test("MoveTypeSerializer serializes EnPassant"):
mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}"""
test("MoveTypeSerializer serializes Promotion"):
mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe
"""{"type":"promotion","piece":"Queen"}"""
// ── MoveTypeDeserializer ──────────────────────────────────────────
test("MoveTypeDeserializer deserializes normal non-capture"):
mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe
MoveType.Normal(false)
test("MoveTypeDeserializer deserializes normal capture"):
mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe
MoveType.Normal(true)
test("MoveTypeDeserializer deserializes castleKingside"):
mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside
test("MoveTypeDeserializer deserializes castleQueenside"):
mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside
test("MoveTypeDeserializer deserializes enPassant"):
mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
test("MoveTypeDeserializer deserializes promotion"):
mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe
MoveType.Promotion(PromotionPiece.Rook)
test("MoveTypeDeserializer throws for unknown type"):
an[Exception] should be thrownBy
mapper.readValue("""{"type":"unknown"}""", classOf[MoveType])
@@ -1,11 +1,11 @@
package de.nowchess.rules.resource
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest, DtoMapper}
import de.nowchess.rules.config.JacksonConfig
import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest}
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
@@ -16,21 +16,25 @@ import org.junit.jupiter.api.Test
@QuarkusTest
class RuleSetResourceTest:
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
private val rules = DefaultRules
private val mapper: ObjectMapper =
val m = new ObjectMapper()
new JacksonConfig().customize(m)
m
private val rules = DefaultRules
private def request() = RestAssured.`given`()
private def toJson(value: AnyRef): String = mapper.writeValueAsString(value)
private def contextBody(ctx: GameContext): String =
toJson(ContextRequest(DtoMapper.fromGameContext(ctx)))
toJson(ContextRequest(ctx))
private def contextSquareBody(ctx: GameContext, square: String): String =
toJson(ContextSquareRequest(DtoMapper.fromGameContext(ctx), square))
toJson(ContextSquareRequest(ctx, square))
private def contextMoveBody(ctx: GameContext, move: Move): String =
toJson(ContextMoveRequest(DtoMapper.fromGameContext(ctx), DtoMapper.fromMove(move)))
toJson(ContextMoveRequest(ctx, move))
// ── all-legal-moves ───────────────────────────────────────────────
@@ -238,7 +242,7 @@ class RuleSetResourceTest:
def invalidSquare_returns400(): Unit =
request()
.contentType(ContentType.JSON)
.body(toJson(ContextSquareRequest(DtoMapper.fromGameContext(GameContext.initial), "z9")))
.body(toJson(ContextSquareRequest(GameContext.initial, "z9")))
.when()
.post("/api/rules/legal-moves")
.`then`()
@@ -2,8 +2,8 @@ package de.nowchess.rules.resource
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest, DtoMapper}
import de.nowchess.api.move.Move
import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest}
import de.nowchess.rules.sets.DefaultRules
import jakarta.ws.rs.BadRequestException
import org.scalatest.funsuite.AnyFunSuite
@@ -14,9 +14,9 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers:
private val resource = new RuleSetResource()
private val rules = DefaultRules
private def ctx(g: GameContext) = ContextRequest(DtoMapper.fromGameContext(g))
private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(DtoMapper.fromGameContext(g), sq)
private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(DtoMapper.fromGameContext(g), DtoMapper.fromMove(m))
private def ctx(g: GameContext) = ContextRequest(g)
private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(g, sq)
private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(g, m)
// ── position builders ─────────────────────────────────────────────
@@ -104,12 +104,6 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers:
test("isCheck returns true when king is attacked"):
resource.isCheck(ctx(checkContext())).result shouldBe true
test("isCheck throws BadRequestException for invalid context"):
an[BadRequestException] should be thrownBy
resource.isCheck(
ctx(GameContext.initial).copy(context = DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red")),
)
// ── isCheckmate ───────────────────────────────────────────────────
test("isCheckmate returns false for initial position"):
@@ -157,11 +151,4 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers:
.legalMoves(GameContext.initial)(Square(File.E, Rank.R2))
.find(_.to == Square(File.E, Rank.R4))
.get
resource.applyMove(ctxMv(GameContext.initial, move)).turn shouldBe "Black"
test("applyMove throws BadRequestException for invalid move"):
val badMove = DtoMapper
.fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
.copy(moveType = "unknown")
an[BadRequestException] should be thrownBy
resource.applyMove(ContextMoveRequest(DtoMapper.fromGameContext(GameContext.initial), badMove))
resource.applyMove(ctxMv(GameContext.initial, move)).turn shouldBe Color.Black