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 new file mode 100644 index 0000000..9d89e29 --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala @@ -0,0 +1,14 @@ +package de.nowchess.rules.config + +import com.fasterxml.jackson.databind.ObjectMapper +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) + mapper.writeValueAsString(None) shouldBe "null" + mapper.writeValueAsString(Some("hello")) shouldBe """"hello"""" 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 index 60ce133..4bdaa43 100644 --- a/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala +++ b/modules/rule/src/test/scala/de/nowchess/rules/dto/DtoMapperTest.scala @@ -138,6 +138,12 @@ class DtoMapperTest extends AnyFunSuite with Matchers: 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"): 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 new file mode 100644 index 0000000..fb7235e --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala @@ -0,0 +1,160 @@ +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.rules.sets.DefaultRules +import jakarta.ws.rs.BadRequestException +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +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)) + + // ── position builders ───────────────────────────────────────────── + + private def checkContext(): GameContext = + val board = Board(Map( + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), + Square(File.E, Rank.R3) -> Piece(Color.Black, PieceType.Rook), + )) + GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def foolsMate(): GameContext = + val moves = List(("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4")) + moves.foldLeft(GameContext.initial) { (c, ft) => + val from = Square.fromAlgebraic(ft._1).get + val to = Square.fromAlgebraic(ft._2).get + rules.legalMoves(c)(from).find(_.to == to).fold(c)(rules.applyMove(c)) + } + + private def stalemateContext(): GameContext = + val board = Board(Map( + Square(File.H, Rank.R8) -> Piece(Color.Black, PieceType.King), + Square(File.F, Rank.R7) -> Piece(Color.White, PieceType.Queen), + Square(File.G, Rank.R6) -> Piece(Color.White, PieceType.King), + )) + GameContext(board, Color.Black, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def kingsOnlyContext(): GameContext = + val board = Board(Map( + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), + )) + GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def threefoldContext(): GameContext = + val g1 = Square(File.G, Rank.R1) + val f3 = Square(File.F, Rank.R3) + val g8 = Square(File.G, Rank.R8) + val f6 = Square(File.F, Rank.R6) + def mv(c: GameContext, from: Square, to: Square): GameContext = + rules.legalMoves(c)(from).find(_.to == to).fold(c)(rules.applyMove(c)) + val c1 = mv(GameContext.initial, g1, f3) + val c2 = mv(c1, g8, f6) + val c3 = mv(c2, f3, g1) + val c4 = mv(c3, f6, g8) + val c5 = mv(c4, g1, f3) + val c6 = mv(c5, g8, f6) + val c7 = mv(c6, f3, g1) + mv(c7, f6, g8) + + // ── allLegalMoves ───────────────────────────────────────────────── + + test("allLegalMoves returns 20 moves for initial position"): + resource.allLegalMoves(ctx(GameContext.initial)).moves should have size 20 + + // ── legalMoves ──────────────────────────────────────────────────── + + test("legalMoves returns 2 moves for e2 pawn"): + resource.legalMoves(ctxSq(GameContext.initial, "e2")).moves should have size 2 + + test("legalMoves throws BadRequestException for invalid square"): + an[BadRequestException] should be thrownBy + resource.legalMoves(ctxSq(GameContext.initial, "z9")) + + // ── candidateMoves ──────────────────────────────────────────────── + + test("candidateMoves returns moves for e2 pawn"): + resource.candidateMoves(ctxSq(GameContext.initial, "e2")).moves should not be empty + + test("candidateMoves throws BadRequestException for invalid square"): + an[BadRequestException] should be thrownBy + resource.candidateMoves(ctxSq(GameContext.initial, "z9")) + + // ── isCheck ─────────────────────────────────────────────────────── + + test("isCheck returns false for initial position"): + resource.isCheck(ctx(GameContext.initial)).result shouldBe false + + 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"): + resource.isCheckmate(ctx(GameContext.initial)).result shouldBe false + + test("isCheckmate returns true for Fool's mate"): + resource.isCheckmate(ctx(foolsMate())).result shouldBe true + + // ── isStalemate ─────────────────────────────────────────────────── + + test("isStalemate returns false for initial position"): + resource.isStalemate(ctx(GameContext.initial)).result shouldBe false + + test("isStalemate returns true for stalemate position"): + resource.isStalemate(ctx(stalemateContext())).result shouldBe true + + // ── isInsufficientMaterial ──────────────────────────────────────── + + test("isInsufficientMaterial returns false for initial position"): + resource.isInsufficientMaterial(ctx(GameContext.initial)).result shouldBe false + + test("isInsufficientMaterial returns true for kings only"): + resource.isInsufficientMaterial(ctx(kingsOnlyContext())).result shouldBe true + + // ── isFiftyMoveRule ─────────────────────────────────────────────── + + test("isFiftyMoveRule returns false for initial position"): + resource.isFiftyMoveRule(ctx(GameContext.initial)).result shouldBe false + + test("isFiftyMoveRule returns true when halfMoveClock is 100"): + resource.isFiftyMoveRule(ctx(GameContext.initial.copy(halfMoveClock = 100))).result shouldBe true + + // ── isThreefoldRepetition ───────────────────────────────────────── + + test("isThreefoldRepetition returns false for initial position"): + resource.isThreefoldRepetition(ctx(GameContext.initial)).result shouldBe false + + test("isThreefoldRepetition returns true after repeated moves"): + resource.isThreefoldRepetition(ctx(threefoldContext())).result shouldBe true + + // ── applyMove ───────────────────────────────────────────────────── + + test("applyMove returns updated context with switched turn"): + val move = rules + .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))