feat(rule): Rules as a microservice
Added rules as a microservice
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
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")
|
||||
|
||||
// ── 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,294 @@
|
||||
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.sets.DefaultRules
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import io.restassured.http.ContentType
|
||||
import org.hamcrest.Matchers.*
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@QuarkusTest
|
||||
class RuleSetResourceTest:
|
||||
|
||||
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
|
||||
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)))
|
||||
|
||||
private def contextSquareBody(ctx: GameContext, square: String): String =
|
||||
toJson(ContextSquareRequest(DtoMapper.fromGameContext(ctx), square))
|
||||
|
||||
private def contextMoveBody(ctx: GameContext, move: Move): String =
|
||||
toJson(ContextMoveRequest(DtoMapper.fromGameContext(ctx), DtoMapper.fromMove(move)))
|
||||
|
||||
// ── all-legal-moves ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def allLegalMoves_initialPositionHas20Moves(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/all-legal-moves")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("moves.size()", is(20))
|
||||
|
||||
// ── legal-moves ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def legalMoves_e2PawnHas2Moves(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextSquareBody(GameContext.initial, "e2"))
|
||||
.when()
|
||||
.post("/api/rules/legal-moves")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("moves.size()", is(2))
|
||||
|
||||
// ── candidate-moves ───────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def candidateMoves_e2PawnHas2Candidates(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextSquareBody(GameContext.initial, "e2"))
|
||||
.when()
|
||||
.post("/api/rules/candidate-moves")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("moves.size()", is(2))
|
||||
|
||||
// ── is-check ──────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isCheck_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-check")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isCheck_trueWhenKingAttacked(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildCheckContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-check")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-checkmate ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isCheckmate_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-checkmate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isCheckmate_trueForFoolsMate(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildFoolsMate()))
|
||||
.when()
|
||||
.post("/api/rules/is-checkmate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-stalemate ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isStalemate_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-stalemate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isStalemate_trueForStalematePosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildStalemateContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-stalemate")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-insufficient-material ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isInsufficientMaterial_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-insufficient-material")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isInsufficientMaterial_trueForKingsOnly(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildKingsOnlyContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-insufficient-material")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-fifty-move-rule ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isFiftyMoveRule_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-fifty-move-rule")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isFiftyMoveRule_trueWhenClockAt100(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial.copy(halfMoveClock = 100)))
|
||||
.when()
|
||||
.post("/api/rules/is-fifty-move-rule")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── is-threefold-repetition ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def isThreefoldRepetition_falseForInitialPosition(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(GameContext.initial))
|
||||
.when()
|
||||
.post("/api/rules/is-threefold-repetition")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(false))
|
||||
|
||||
@Test
|
||||
def isThreefoldRepetition_trueAfterRepeatedMoves(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextBody(buildThreefoldContext()))
|
||||
.when()
|
||||
.post("/api/rules/is-threefold-repetition")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("result", is(true))
|
||||
|
||||
// ── apply-move ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def applyMove_updatesContext(): Unit =
|
||||
val move = rules
|
||||
.legalMoves(GameContext.initial)(Square(File.E, Rank.R2))
|
||||
.find(_.to == Square(File.E, Rank.R4))
|
||||
.get
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(contextMoveBody(GameContext.initial, move))
|
||||
.when()
|
||||
.post("/api/rules/apply-move")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("turn", is("Black"))
|
||||
|
||||
// ── error handling ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
def invalidSquare_returns400(): Unit =
|
||||
request()
|
||||
.contentType(ContentType.JSON)
|
||||
.body(toJson(ContextSquareRequest(DtoMapper.fromGameContext(GameContext.initial), "z9")))
|
||||
.when()
|
||||
.post("/api/rules/legal-moves")
|
||||
.`then`()
|
||||
.statusCode(400)
|
||||
|
||||
// ── position builders ─────────────────────────────────────────────
|
||||
|
||||
private def buildCheckContext(): 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 buildFoolsMate(): GameContext =
|
||||
val moves = List(("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4"))
|
||||
moves.foldLeft(GameContext.initial) { (ctx, fromTo) =>
|
||||
val from = Square.fromAlgebraic(fromTo._1).get
|
||||
val to = Square.fromAlgebraic(fromTo._2).get
|
||||
rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx))
|
||||
}
|
||||
|
||||
private def buildStalemateContext(): 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 buildKingsOnlyContext(): 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 buildThreefoldContext(): 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(ctx: GameContext, from: Square, to: Square): GameContext =
|
||||
rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx))
|
||||
val ctx1 = mv(GameContext.initial, g1, f3)
|
||||
val ctx2 = mv(ctx1, g8, f6)
|
||||
val ctx3 = mv(ctx2, f3, g1)
|
||||
val ctx4 = mv(ctx3, f6, g8)
|
||||
val ctx5 = mv(ctx4, g1, f3)
|
||||
val ctx6 = mv(ctx5, g8, f6)
|
||||
val ctx7 = mv(ctx6, f3, g1)
|
||||
mv(ctx7, f6, g8)
|
||||
Reference in New Issue
Block a user