diff --git a/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala index 57c0347..8dd29b8 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala b/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala deleted file mode 100644 index 2c00fc2..0000000 --- a/modules/rule/src/main/scala/de/nowchess/rules/dto/DtoMapper.scala +++ /dev/null @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala index 648275d..d06609c 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala new file mode 100644 index 0000000..d0f0c8f --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala @@ -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") diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala new file mode 100644 index 0000000..1817586 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala @@ -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() diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala new file mode 100644 index 0000000..0be5f92 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala @@ -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 diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala new file mode 100644 index 0000000..4d52c10 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala @@ -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 diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala new file mode 100644 index 0000000..3ef02a5 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala new file mode 100644 index 0000000..93aaca9 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala @@ -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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala index 0b7f6c3..de25463 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala @@ -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) diff --git a/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala index 9d89e29..27c738d 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala @@ -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 diff --git a/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala deleted file mode 100644 index 4bdaa43..0000000 --- a/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala +++ /dev/null @@ -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") diff --git a/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala new file mode 100644 index 0000000..a14769b --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala @@ -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]) diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala index bb7209a..4879b37 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala @@ -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`() diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala index b32f55d..277e869 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala @@ -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