diff --git a/modules/api/build.gradle.kts b/modules/api/build.gradle.kts index 0ad7d9d..0f963ee 100644 --- a/modules/api/build.gradle.kts +++ b/modules/api/build.gradle.kts @@ -49,9 +49,10 @@ dependencies { testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.test { diff --git a/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala new file mode 100644 index 0000000..36692e7 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala @@ -0,0 +1,102 @@ +package de.nowchess.api.board + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class BoardTest extends AnyFunSuite with Matchers: + + private val e2 = Square(File.E, Rank.R2) + private val e4 = Square(File.E, Rank.R4) + private val d7 = Square(File.D, Rank.R7) + + test("pieceAt returns Some for occupied square") { + Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn) + } + + test("pieceAt returns None for empty square") { + Board.initial.pieceAt(e4) shouldBe None + } + + test("withMove moves piece and vacates origin") { + val (board, captured) = Board.initial.withMove(e2, e4) + captured shouldBe None + board.pieceAt(e4) shouldBe Some(Piece.WhitePawn) + board.pieceAt(e2) shouldBe None + } + + test("withMove returns captured piece when destination is occupied") { + val from = Square(File.A, Rank.R1) + val to = Square(File.A, Rank.R8) + val b = Board(Map(from -> Piece.WhiteRook, to -> Piece.BlackRook)) + val (board, captured) = b.withMove(from, to) + captured shouldBe Some(Piece.BlackRook) + board.pieceAt(to) shouldBe Some(Piece.WhiteRook) + board.pieceAt(from) shouldBe None + } + + test("pieces returns the underlying map") { + val map = Map(e2 -> Piece.WhitePawn) + val b = Board(map) + b.pieces shouldBe map + } + + test("Board.apply constructs board from map") { + val map = Map(e2 -> Piece.WhitePawn) + val b = Board(map) + b.pieceAt(e2) shouldBe Some(Piece.WhitePawn) + } + + test("initial board has 32 pieces") { + Board.initial.pieces should have size 32 + } + + test("initial board has 16 white pieces") { + Board.initial.pieces.values.count(_.color == Color.White) shouldBe 16 + } + + test("initial board has 16 black pieces") { + Board.initial.pieces.values.count(_.color == Color.Black) shouldBe 16 + } + + test("initial board white pawns on rank 2") { + File.values.foreach { file => + Board.initial.pieceAt(Square(file, Rank.R2)) shouldBe Some(Piece.WhitePawn) + } + } + + test("initial board black pawns on rank 7") { + File.values.foreach { file => + Board.initial.pieceAt(Square(file, Rank.R7)) shouldBe Some(Piece.BlackPawn) + } + } + + test("initial board white back rank") { + val expectedBackRank = Vector( + PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, + PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook + ) + File.values.zipWithIndex.foreach { (file, i) => + Board.initial.pieceAt(Square(file, Rank.R1)) shouldBe + Some(Piece(Color.White, expectedBackRank(i))) + } + } + + test("initial board black back rank") { + val expectedBackRank = Vector( + PieceType.Rook, PieceType.Knight, PieceType.Bishop, PieceType.Queen, + PieceType.King, PieceType.Bishop, PieceType.Knight, PieceType.Rook + ) + File.values.zipWithIndex.foreach { (file, i) => + Board.initial.pieceAt(Square(file, Rank.R8)) shouldBe + Some(Piece(Color.Black, expectedBackRank(i))) + } + } + + test("ranks 3-6 are empty on initial board") { + val emptyRanks = Seq(Rank.R3, Rank.R4, Rank.R5, Rank.R6) + for + rank <- emptyRanks + file <- File.values + do + Board.initial.pieceAt(Square(file, rank)) shouldBe None + } diff --git a/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala new file mode 100644 index 0000000..211b448 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala @@ -0,0 +1,22 @@ +package de.nowchess.api.board + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class ColorTest extends AnyFunSuite with Matchers: + + test("White.opposite returns Black") { + Color.White.opposite shouldBe Color.Black + } + + test("Black.opposite returns White") { + Color.Black.opposite shouldBe Color.White + } + + test("White.label returns 'White'") { + Color.White.label shouldBe "White" + } + + test("Black.label returns 'Black'") { + Color.Black.label shouldBe "Black" + } diff --git a/modules/api/src/test/scala/de/nowchess/api/board/PieceTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/PieceTest.scala new file mode 100644 index 0000000..850bdca --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/board/PieceTest.scala @@ -0,0 +1,60 @@ +package de.nowchess.api.board + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class PieceTest extends AnyFunSuite with Matchers: + + test("Piece holds color and pieceType") { + val p = Piece(Color.White, PieceType.Queen) + p.color shouldBe Color.White + p.pieceType shouldBe PieceType.Queen + } + + test("WhitePawn convenience constant") { + Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn) + } + + test("WhiteKnight convenience constant") { + Piece.WhiteKnight shouldBe Piece(Color.White, PieceType.Knight) + } + + test("WhiteBishop convenience constant") { + Piece.WhiteBishop shouldBe Piece(Color.White, PieceType.Bishop) + } + + test("WhiteRook convenience constant") { + Piece.WhiteRook shouldBe Piece(Color.White, PieceType.Rook) + } + + test("WhiteQueen convenience constant") { + Piece.WhiteQueen shouldBe Piece(Color.White, PieceType.Queen) + } + + test("WhiteKing convenience constant") { + Piece.WhiteKing shouldBe Piece(Color.White, PieceType.King) + } + + test("BlackPawn convenience constant") { + Piece.BlackPawn shouldBe Piece(Color.Black, PieceType.Pawn) + } + + test("BlackKnight convenience constant") { + Piece.BlackKnight shouldBe Piece(Color.Black, PieceType.Knight) + } + + test("BlackBishop convenience constant") { + Piece.BlackBishop shouldBe Piece(Color.Black, PieceType.Bishop) + } + + test("BlackRook convenience constant") { + Piece.BlackRook shouldBe Piece(Color.Black, PieceType.Rook) + } + + test("BlackQueen convenience constant") { + Piece.BlackQueen shouldBe Piece(Color.Black, PieceType.Queen) + } + + test("BlackKing convenience constant") { + Piece.BlackKing shouldBe Piece(Color.Black, PieceType.King) + } diff --git a/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala new file mode 100644 index 0000000..135eee6 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala @@ -0,0 +1,30 @@ +package de.nowchess.api.board + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class PieceTypeTest extends AnyFunSuite with Matchers: + + test("Pawn.label returns 'Pawn'") { + PieceType.Pawn.label shouldBe "Pawn" + } + + test("Knight.label returns 'Knight'") { + PieceType.Knight.label shouldBe "Knight" + } + + test("Bishop.label returns 'Bishop'") { + PieceType.Bishop.label shouldBe "Bishop" + } + + test("Rook.label returns 'Rook'") { + PieceType.Rook.label shouldBe "Rook" + } + + test("Queen.label returns 'Queen'") { + PieceType.Queen.label shouldBe "Queen" + } + + test("King.label returns 'King'") { + PieceType.King.label shouldBe "King" + } diff --git a/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala new file mode 100644 index 0000000..ed4c848 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala @@ -0,0 +1,62 @@ +package de.nowchess.api.board + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class SquareTest extends AnyFunSuite with Matchers: + + test("Square.toString produces lowercase file and rank number") { + Square(File.E, Rank.R4).toString shouldBe "e4" + } + + test("Square.toString for a1") { + Square(File.A, Rank.R1).toString shouldBe "a1" + } + + test("Square.toString for h8") { + Square(File.H, Rank.R8).toString shouldBe "h8" + } + + test("fromAlgebraic parses valid square e4") { + Square.fromAlgebraic("e4") shouldBe Some(Square(File.E, Rank.R4)) + } + + test("fromAlgebraic parses valid square a1") { + Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1)) + } + + test("fromAlgebraic parses valid square h8") { + Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8)) + } + + test("fromAlgebraic is case-insensitive for file") { + Square.fromAlgebraic("E4") shouldBe Some(Square(File.E, Rank.R4)) + } + + test("fromAlgebraic returns None for empty string") { + Square.fromAlgebraic("") shouldBe None + } + + test("fromAlgebraic returns None for string too short") { + Square.fromAlgebraic("e") shouldBe None + } + + test("fromAlgebraic returns None for string too long") { + Square.fromAlgebraic("e42") shouldBe None + } + + test("fromAlgebraic returns None for invalid file character") { + Square.fromAlgebraic("z4") shouldBe None + } + + test("fromAlgebraic returns None for non-digit rank") { + Square.fromAlgebraic("ex") shouldBe None + } + + test("fromAlgebraic returns None for rank 0") { + Square.fromAlgebraic("e0") shouldBe None + } + + test("fromAlgebraic returns None for rank 9") { + Square.fromAlgebraic("e9") shouldBe None + } diff --git a/modules/api/src/test/scala/de/nowchess/api/game/GameStateTest.scala b/modules/api/src/test/scala/de/nowchess/api/game/GameStateTest.scala new file mode 100644 index 0000000..374638c --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/game/GameStateTest.scala @@ -0,0 +1,77 @@ +package de.nowchess.api.game + +import de.nowchess.api.board.Color +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class GameStateTest extends AnyFunSuite with Matchers: + + test("CastlingRights.None has both flags false") { + CastlingRights.None.kingSide shouldBe false + CastlingRights.None.queenSide shouldBe false + } + + test("CastlingRights.Both has both flags true") { + CastlingRights.Both.kingSide shouldBe true + CastlingRights.Both.queenSide shouldBe true + } + + test("CastlingRights constructor sets fields") { + val cr = CastlingRights(kingSide = true, queenSide = false) + cr.kingSide shouldBe true + cr.queenSide shouldBe false + } + + test("GameResult cases exist") { + GameResult.WhiteWins shouldBe GameResult.WhiteWins + GameResult.BlackWins shouldBe GameResult.BlackWins + GameResult.Draw shouldBe GameResult.Draw + } + + test("GameStatus.NotStarted") { + GameStatus.NotStarted shouldBe GameStatus.NotStarted + } + + test("GameStatus.InProgress") { + GameStatus.InProgress shouldBe GameStatus.InProgress + } + + test("GameStatus.Finished carries result") { + val status = GameStatus.Finished(GameResult.Draw) + status shouldBe GameStatus.Finished(GameResult.Draw) + status match + case GameStatus.Finished(r) => r shouldBe GameResult.Draw + case _ => fail("expected Finished") + } + + test("GameState.initial has standard FEN piece placement") { + GameState.initial.piecePlacement shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" + } + + test("GameState.initial active color is White") { + GameState.initial.activeColor shouldBe Color.White + } + + test("GameState.initial white has full castling rights") { + GameState.initial.castlingWhite shouldBe CastlingRights.Both + } + + test("GameState.initial black has full castling rights") { + GameState.initial.castlingBlack shouldBe CastlingRights.Both + } + + test("GameState.initial en-passant target is None") { + GameState.initial.enPassantTarget shouldBe None + } + + test("GameState.initial half-move clock is 0") { + GameState.initial.halfMoveClock shouldBe 0 + } + + test("GameState.initial full-move number is 1") { + GameState.initial.fullMoveNumber shouldBe 1 + } + + test("GameState.initial status is InProgress") { + GameState.initial.status shouldBe GameStatus.InProgress + } diff --git a/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala b/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala new file mode 100644 index 0000000..d788f83 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala @@ -0,0 +1,56 @@ +package de.nowchess.api.move + +import de.nowchess.api.board.{File, Rank, Square} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class MoveTest extends AnyFunSuite with Matchers: + + private val e2 = Square(File.E, Rank.R2) + private val e4 = Square(File.E, Rank.R4) + + test("Move defaults moveType to Normal") { + val m = Move(e2, e4) + m.moveType shouldBe MoveType.Normal + } + + test("Move stores from and to squares") { + val m = Move(e2, e4) + m.from shouldBe e2 + m.to shouldBe e4 + } + + test("Move with CastleKingside moveType") { + val m = Move(e2, e4, MoveType.CastleKingside) + m.moveType shouldBe MoveType.CastleKingside + } + + test("Move with CastleQueenside moveType") { + val m = Move(e2, e4, MoveType.CastleQueenside) + m.moveType shouldBe MoveType.CastleQueenside + } + + test("Move with EnPassant moveType") { + val m = Move(e2, e4, MoveType.EnPassant) + m.moveType shouldBe MoveType.EnPassant + } + + test("Move with Promotion to Queen") { + val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Queen)) + m.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) + } + + test("Move with Promotion to Knight") { + val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Knight)) + m.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight) + } + + test("Move with Promotion to Bishop") { + val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Bishop)) + m.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop) + } + + test("Move with Promotion to Rook") { + val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Rook)) + m.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook) + } diff --git a/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala b/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala new file mode 100644 index 0000000..a073db1 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala @@ -0,0 +1,23 @@ +package de.nowchess.api.player + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class PlayerInfoTest extends AnyFunSuite with Matchers: + + test("PlayerId.apply wraps a string") { + val id = PlayerId("player-123") + id.value shouldBe "player-123" + } + + test("PlayerId.value unwraps to original string") { + val raw = "abc-456" + PlayerId(raw).value shouldBe raw + } + + test("PlayerInfo holds id and displayName") { + val id = PlayerId("p1") + val info = PlayerInfo(id, "Magnus") + info.id.value shouldBe "p1" + info.displayName shouldBe "Magnus" + } diff --git a/modules/api/src/test/scala/de/nowchess/api/response/ApiResponseTest.scala b/modules/api/src/test/scala/de/nowchess/api/response/ApiResponseTest.scala new file mode 100644 index 0000000..44d43ef --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/response/ApiResponseTest.scala @@ -0,0 +1,62 @@ +package de.nowchess.api.response + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class ApiResponseTest extends AnyFunSuite with Matchers: + + test("ApiResponse.Success carries data") { + val r = ApiResponse.Success(42) + r.data shouldBe 42 + } + + test("ApiResponse.Failure carries error list") { + val err = ApiError("CODE", "msg") + val r = ApiResponse.Failure(List(err)) + r.errors shouldBe List(err) + } + + test("ApiResponse.error creates single-error Failure") { + val err = ApiError("NOT_FOUND", "not found") + val f = ApiResponse.error(err) + f shouldBe ApiResponse.Failure(List(err)) + } + + test("ApiError holds code and message") { + val e = ApiError("CODE", "message") + e.code shouldBe "CODE" + e.message shouldBe "message" + e.field shouldBe None + } + + test("ApiError holds optional field") { + val e = ApiError("INVALID", "bad value", Some("email")) + e.field shouldBe Some("email") + } + + test("Pagination.totalPages with exact division") { + Pagination(page = 0, pageSize = 10, totalItems = 30).totalPages shouldBe 3 + } + + test("Pagination.totalPages rounds up") { + Pagination(page = 0, pageSize = 10, totalItems = 25).totalPages shouldBe 3 + } + + test("Pagination.totalPages is 0 when totalItems is 0") { + Pagination(page = 0, pageSize = 10, totalItems = 0).totalPages shouldBe 0 + } + + test("Pagination.totalPages is 0 when pageSize is 0") { + Pagination(page = 0, pageSize = 0, totalItems = 100).totalPages shouldBe 0 + } + + test("Pagination.totalPages is 0 when pageSize is negative") { + Pagination(page = 0, pageSize = -1, totalItems = 100).totalPages shouldBe 0 + } + + test("PagedResponse holds items and pagination") { + val pagination = Pagination(page = 1, pageSize = 5, totalItems = 20) + val pr = PagedResponse(List("a", "b"), pagination) + pr.items shouldBe List("a", "b") + pr.pagination shouldBe pagination + } diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index 299068b..21cde7b 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -52,9 +52,10 @@ dependencies { testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.test {