refactor: NCS-22 NCS-23 reworked modules and tests (#17)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-04-06 09:07:39 +02:00
parent 51ffd7aac9
commit 8f56a82104
98 changed files with 3752 additions and 5940 deletions
@@ -14,6 +14,9 @@ object Board:
val captured = b.get(to)
val updatedBoard = b.removed(from).updated(to, b(from))
(updatedBoard, captured)
def applyMove(move: de.nowchess.api.move.Move): Board =
val (updatedBoard, _) = b.withMove(move.from, move.to)
updatedBoard
def pieces: Map[Square, Piece] = b
val initial: Board =
@@ -0,0 +1,70 @@
package de.nowchess.api.board
/**
* Unified castling rights tracker for all four sides.
* Tracks whether castling is still available for each side and direction.
*
* @param whiteKingSide White's king-side castling (0-0) still legally available
* @param whiteQueenSide White's queen-side castling (0-0-0) still legally available
* @param blackKingSide Black's king-side castling (0-0) still legally available
* @param blackQueenSide Black's queen-side castling (0-0-0) still legally available
*/
final case class CastlingRights(
whiteKingSide: Boolean,
whiteQueenSide: Boolean,
blackKingSide: Boolean,
blackQueenSide: Boolean
):
/**
* Check if either side has any castling rights remaining.
*/
def hasAnyRights: Boolean =
whiteKingSide || whiteQueenSide || blackKingSide || blackQueenSide
/**
* Check if a specific color has any castling rights remaining.
*/
def hasRights(color: Color): Boolean = color match
case Color.White => whiteKingSide || whiteQueenSide
case Color.Black => blackKingSide || blackQueenSide
/**
* Revoke all castling rights for a specific color.
*/
def revokeColor(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false, whiteQueenSide = false)
case Color.Black => copy(blackKingSide = false, blackQueenSide = false)
/**
* Revoke a specific castling right.
*/
def revokeKingSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteKingSide = false)
case Color.Black => copy(blackKingSide = false)
/**
* Revoke a specific castling right.
*/
def revokeQueenSide(color: Color): CastlingRights = color match
case Color.White => copy(whiteQueenSide = false)
case Color.Black => copy(blackQueenSide = false)
object CastlingRights:
/** No castling rights for any side. */
val None: CastlingRights = CastlingRights(
whiteKingSide = false,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = false
)
/** All castling rights available. */
val All: CastlingRights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = true,
blackKingSide = true,
blackQueenSide = true
)
/** Standard starting position castling rights (both sides can castle both ways). */
val Initial: CastlingRights = All
@@ -39,3 +39,19 @@ object Square:
if n >= 1 && n <= 8 then Some(Rank.values(n - 1)) else None
)
for f <- fileOpt; r <- rankOpt yield Square(f, r)
val all: IndexedSeq[Square] =
for
r <- Rank.values.toIndexedSeq
f <- File.values.toIndexedSeq
yield Square(f, r)
/** Compute a target square by offsetting file and rank.
* Returns None if the resulting square is outside the board (0-7 range). */
extension (sq: Square)
def offset(fileDelta: Int, rankDelta: Int): Option[Square] =
val newFileOrd = sq.file.ordinal + fileDelta
val newRankOrd = sq.rank.ordinal + rankDelta
if newFileOrd >= 0 && newFileOrd < 8 && newRankOrd >= 0 && newRankOrd < 8 then
Some(Square(File.values(newFileOrd), Rank.values(newRankOrd)))
else None
@@ -0,0 +1,44 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Board, Color, Square, CastlingRights}
import de.nowchess.api.move.Move
/** Immutable bundle of complete game state.
* All state changes produce new GameContext instances.
*/
case class GameContext(
board: Board,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
halfMoveClock: Int,
moves: List[Move]
):
/** Create new context with updated board. */
def withBoard(newBoard: Board): GameContext = copy(board = newBoard)
/** Create new context with updated turn. */
def withTurn(newTurn: Color): GameContext = copy(turn = newTurn)
/** Create new context with updated castling rights. */
def withCastlingRights(newRights: CastlingRights): GameContext = copy(castlingRights = newRights)
/** Create new context with updated en passant square. */
def withEnPassantSquare(newSq: Option[Square]): GameContext = copy(enPassantSquare = newSq)
/** Create new context with updated half-move clock. */
def withHalfMoveClock(newClock: Int): GameContext = copy(halfMoveClock = newClock)
/** Create new context with move appended to history. */
def withMove(move: Move): GameContext = copy(moves = moves :+ move)
object GameContext:
/** Initial position: white to move, all castling rights, no en passant. */
def initial: GameContext = GameContext(
board = Board.initial,
turn = Color.White,
castlingRights = CastlingRights.Initial,
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty
)
@@ -1,67 +0,0 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Color, Square}
/**
* Castling availability flags for one side.
*
* @param kingSide king-side castling still legally available
* @param queenSide queen-side castling still legally available
*/
final case class CastlingRights(kingSide: Boolean, queenSide: Boolean)
object CastlingRights:
val None: CastlingRights = CastlingRights(kingSide = false, queenSide = false)
val Both: CastlingRights = CastlingRights(kingSide = true, queenSide = true)
/** Outcome of a finished game. */
enum GameResult:
case WhiteWins
case BlackWins
case Draw
/** Lifecycle state of a game. */
enum GameStatus:
case NotStarted
case InProgress
case Finished(result: GameResult)
/**
* A FEN-compatible snapshot of board and game state.
*
* The board is represented as a FEN piece-placement string (rank 8 to rank 1,
* separated by '/'). All other fields mirror standard FEN fields.
*
* @param piecePlacement FEN piece-placement field, e.g.
* "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
* @param activeColor side to move
* @param castlingWhite castling rights for White
* @param castlingBlack castling rights for Black
* @param enPassantTarget square behind the double-pushed pawn, if any
* @param halfMoveClock plies since last capture or pawn advance (50-move rule)
* @param fullMoveNumber increments after Black's move, starts at 1
* @param status current lifecycle status of the game
*/
final case class GameState(
piecePlacement: String,
activeColor: Color,
castlingWhite: CastlingRights,
castlingBlack: CastlingRights,
enPassantTarget: Option[Square],
halfMoveClock: Int,
fullMoveNumber: Int,
status: GameStatus
)
object GameState:
/** Standard starting position. */
val initial: GameState = GameState(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = None,
halfMoveClock = 0,
fullMoveNumber = 1,
status = GameStatus.InProgress
)
@@ -9,7 +9,7 @@ enum PromotionPiece:
/** Classifies special move semantics beyond a plain quiet move or capture. */
enum MoveType:
/** A normal move or capture with no special rule. */
case Normal
case Normal(isCapture: Boolean = false)
/** Kingside castling (O-O). */
case CastleKingside
/** Queenside castling (O-O-O). */
@@ -29,5 +29,5 @@ enum MoveType:
final case class Move(
from: Square,
to: Square,
moveType: MoveType = MoveType.Normal
moveType: MoveType = MoveType.Normal()
)
@@ -1,5 +1,6 @@
package de.nowchess.api.board
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -7,13 +8,9 @@ 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") {
test("pieceAt resolves occupied and empty squares") {
Board.initial.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
}
test("pieceAt returns None for empty square") {
Board.initial.pieceAt(e4) shouldBe None
}
@@ -34,38 +31,20 @@ class BoardTest extends AnyFunSuite with Matchers:
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") {
test("Board.apply and pieces expose the wrapped map") {
val map = Map(e2 -> Piece.WhitePawn)
val b = Board(map)
b.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
b.pieces shouldBe map
}
test("initial board has 32 pieces") {
test("initial board has expected material and pawn placement") {
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)
}
}
@@ -101,17 +80,14 @@ class BoardTest extends AnyFunSuite with Matchers:
Board.initial.pieceAt(Square(file, rank)) shouldBe None
}
test("updated adds or replaces piece at square") {
test("updated adds and replaces piece at squares") {
val b = Board(Map(e2 -> Piece.WhitePawn))
val updated = b.updated(e4, Piece.WhiteKnight)
updated.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
updated.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
}
val added = b.updated(e4, Piece.WhiteKnight)
added.pieceAt(e2) shouldBe Some(Piece.WhitePawn)
added.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
test("updated replaces existing piece") {
val b = Board(Map(e2 -> Piece.WhitePawn))
val updated = b.updated(e2, Piece.WhiteKnight)
updated.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
val replaced = b.updated(e2, Piece.WhiteKnight)
replaced.pieceAt(e2) shouldBe Some(Piece.WhiteKnight)
}
test("removed deletes piece from board") {
@@ -120,3 +96,13 @@ class BoardTest extends AnyFunSuite with Matchers:
removed.pieceAt(e2) shouldBe None
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
}
test("applyMove uses move.from and move.to to relocate a piece") {
val b = Board(Map(e2 -> Piece.WhitePawn))
val moved = b.applyMove(Move(e2, e4))
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
moved.pieceAt(e2) shouldBe None
}
@@ -0,0 +1,57 @@
package de.nowchess.api.board
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CastlingRightsTest extends AnyFunSuite with Matchers:
test("hasAnyRights and hasRights reflect current flags"):
val rights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = true
)
rights.hasAnyRights shouldBe true
rights.hasRights(Color.White) shouldBe true
rights.hasRights(Color.Black) shouldBe true
CastlingRights.None.hasAnyRights shouldBe false
CastlingRights.None.hasRights(Color.White) shouldBe false
CastlingRights.None.hasRights(Color.Black) shouldBe false
test("revokeColor clears both castling sides for selected color"):
val all = CastlingRights.All
val whiteRevoked = all.revokeColor(Color.White)
whiteRevoked.whiteKingSide shouldBe false
whiteRevoked.whiteQueenSide shouldBe false
whiteRevoked.blackKingSide shouldBe true
whiteRevoked.blackQueenSide shouldBe true
val blackRevoked = all.revokeColor(Color.Black)
blackRevoked.whiteKingSide shouldBe true
blackRevoked.whiteQueenSide shouldBe true
blackRevoked.blackKingSide shouldBe false
blackRevoked.blackQueenSide shouldBe false
test("revokeKingSide and revokeQueenSide disable only requested side"):
val all = CastlingRights.All
val whiteKingSideRevoked = all.revokeKingSide(Color.White)
whiteKingSideRevoked.whiteKingSide shouldBe false
whiteKingSideRevoked.whiteQueenSide shouldBe true
val whiteQueenSideRevoked = all.revokeQueenSide(Color.White)
whiteQueenSideRevoked.whiteKingSide shouldBe true
whiteQueenSideRevoked.whiteQueenSide shouldBe false
val blackKingSideRevoked = all.revokeKingSide(Color.Black)
blackKingSideRevoked.blackKingSide shouldBe false
blackKingSideRevoked.blackQueenSide shouldBe true
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
blackQueenSideRevoked.blackKingSide shouldBe true
blackQueenSideRevoked.blackQueenSide shouldBe false
@@ -5,18 +5,13 @@ import org.scalatest.matchers.should.Matchers
class ColorTest extends AnyFunSuite with Matchers:
test("White.opposite returns Black") {
Color.White.opposite shouldBe Color.Black
}
test("Color values expose opposite and label consistently"):
val cases = List(
(Color.White, Color.Black, "White"),
(Color.Black, Color.White, "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"
}
cases.foreach { (color, opposite, label) =>
color.opposite shouldBe opposite
color.label shouldBe label
}
@@ -11,50 +11,23 @@ class PieceTest extends AnyFunSuite with Matchers:
p.pieceType shouldBe PieceType.Queen
}
test("WhitePawn convenience constant") {
Piece.WhitePawn shouldBe Piece(Color.White, PieceType.Pawn)
}
test("all convenience constants map to expected color and piece type") {
val expected = List(
Piece.WhitePawn -> Piece(Color.White, PieceType.Pawn),
Piece.WhiteKnight -> Piece(Color.White, PieceType.Knight),
Piece.WhiteBishop -> Piece(Color.White, PieceType.Bishop),
Piece.WhiteRook -> Piece(Color.White, PieceType.Rook),
Piece.WhiteQueen -> Piece(Color.White, PieceType.Queen),
Piece.WhiteKing -> Piece(Color.White, PieceType.King),
Piece.BlackPawn -> Piece(Color.Black, PieceType.Pawn),
Piece.BlackKnight -> Piece(Color.Black, PieceType.Knight),
Piece.BlackBishop -> Piece(Color.Black, PieceType.Bishop),
Piece.BlackRook -> Piece(Color.Black, PieceType.Rook),
Piece.BlackQueen -> Piece(Color.Black, PieceType.Queen),
Piece.BlackKing -> Piece(Color.Black, PieceType.King)
)
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)
expected.foreach { case (actual, wanted) =>
actual shouldBe wanted
}
}
@@ -5,26 +5,16 @@ import org.scalatest.matchers.should.Matchers
class PieceTypeTest extends AnyFunSuite with Matchers:
test("Pawn.label returns 'Pawn'") {
PieceType.Pawn.label shouldBe "Pawn"
}
test("PieceType values expose the expected labels"):
val expectedLabels = List(
PieceType.Pawn -> "Pawn",
PieceType.Knight -> "Knight",
PieceType.Bishop -> "Bishop",
PieceType.Rook -> "Rook",
PieceType.Queen -> "Queen",
PieceType.King -> "King"
)
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"
}
expectedLabels.foreach { (pieceType, expectedLabel) =>
pieceType.label shouldBe expectedLabel
}
@@ -5,58 +5,33 @@ 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") {
test("toString renders algebraic notation for edge and middle squares") {
Square(File.A, Rank.R1).toString shouldBe "a1"
}
test("Square.toString for h8") {
Square(File.E, Rank.R4).toString shouldBe "e4"
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 coordinates including case-insensitive files") {
val expected = List(
"a1" -> Square(File.A, Rank.R1),
"e4" -> Square(File.E, Rank.R4),
"h8" -> Square(File.H, Rank.R8),
"E4" -> Square(File.E, Rank.R4)
)
expected.foreach { case (raw, sq) =>
Square.fromAlgebraic(raw) shouldBe Some(sq)
}
}
test("fromAlgebraic parses valid square a1") {
Square.fromAlgebraic("a1") shouldBe Some(Square(File.A, Rank.R1))
test("fromAlgebraic rejects malformed coordinates") {
List("", "e", "e42", "z4", "ex", "e0", "e9").foreach { raw =>
Square.fromAlgebraic(raw) shouldBe None
}
}
test("fromAlgebraic parses valid square h8") {
Square.fromAlgebraic("h8") shouldBe Some(Square(File.H, Rank.R8))
test("offset returns Some in-bounds and None out-of-bounds") {
Square(File.E, Rank.R4).offset(1, 2) shouldBe Some(Square(File.F, Rank.R6))
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
}
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
}
@@ -0,0 +1,60 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameContextTest extends AnyFunSuite with Matchers:
test("GameContext.initial exposes expected default state"):
val initial = GameContext.initial
initial.board shouldBe Board.initial
initial.turn shouldBe Color.White
initial.castlingRights shouldBe CastlingRights.Initial
initial.enPassantSquare shouldBe None
initial.halfMoveClock shouldBe 0
initial.moves shouldBe List.empty
test("withBoard updates only board"):
val square = Square(File.E, Rank.R4)
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
val updated = GameContext.initial.withBoard(updatedBoard)
updated.board shouldBe updatedBoard
updated.turn shouldBe GameContext.initial.turn
updated.castlingRights shouldBe GameContext.initial.castlingRights
updated.enPassantSquare shouldBe GameContext.initial.enPassantSquare
updated.halfMoveClock shouldBe GameContext.initial.halfMoveClock
updated.moves shouldBe GameContext.initial.moves
test("withers update only targeted fields"):
val initial = GameContext.initial
val rights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = true
)
val square = Some(Square(File.E, Rank.R3))
val updatedTurn = initial.withTurn(Color.Black)
val updatedRights = initial.withCastlingRights(rights)
val updatedEp = initial.withEnPassantSquare(square)
val updatedClock = initial.withHalfMoveClock(17)
updatedTurn.turn shouldBe Color.Black
updatedTurn.board shouldBe initial.board
updatedRights.castlingRights shouldBe rights
updatedRights.turn shouldBe initial.turn
updatedEp.enPassantSquare shouldBe square
updatedEp.castlingRights shouldBe initial.castlingRights
updatedClock.halfMoveClock shouldBe 17
updatedClock.moves shouldBe initial.moves
test("withMove appends move to history"):
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
GameContext.initial.withMove(move).moves shouldBe List(move)
@@ -1,77 +0,0 @@
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
}
@@ -9,48 +9,26 @@ 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") {
test("Move defaults to Normal and keeps from/to squares") {
val m = Move(e2, e4)
m.from shouldBe e2
m.to shouldBe e4
m.to shouldBe e4
m.moveType shouldBe MoveType.Normal()
}
test("Move with CastleKingside moveType") {
val m = Move(e2, e4, MoveType.CastleKingside)
m.moveType shouldBe MoveType.CastleKingside
}
test("Move accepts all supported move types") {
val moveTypes = List(
MoveType.Normal(isCapture = true),
MoveType.CastleKingside,
MoveType.CastleQueenside,
MoveType.EnPassant,
MoveType.Promotion(PromotionPiece.Queen),
MoveType.Promotion(PromotionPiece.Rook),
MoveType.Promotion(PromotionPiece.Bishop),
MoveType.Promotion(PromotionPiece.Knight)
)
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)
moveTypes.foreach { moveType =>
Move(e2, e4, moveType).moveType shouldBe moveType
}
}
@@ -5,19 +5,14 @@ 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 and PlayerInfo preserve constructor values") {
val raw = "player-123"
val id = PlayerId(raw)
test("PlayerId.value unwraps to original string") {
val raw = "abc-456"
PlayerId(raw).value shouldBe raw
}
id.value shouldBe raw
test("PlayerInfo holds id and displayName") {
val id = PlayerId("p1")
val info = PlayerInfo(id, "Magnus")
val playerId = PlayerId("p1")
val info = PlayerInfo(playerId, "Magnus")
info.id.value shouldBe "p1"
info.displayName shouldBe "Magnus"
}
@@ -5,52 +5,26 @@ import org.scalatest.matchers.should.Matchers
class ApiResponseTest extends AnyFunSuite with Matchers:
test("ApiResponse.Success carries data") {
test("ApiResponse factories and payload wrappers keep values") {
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)
}
ApiResponse.Failure(List(err)).errors shouldBe List(err)
ApiResponse.error(err) shouldBe ApiResponse.Failure(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
ApiError("INVALID", "bad value", Some("email")).field shouldBe Some("email")
}
test("ApiError holds optional field") {
val e = ApiError("INVALID", "bad value", Some("email"))
e.field shouldBe Some("email")
}
test("Pagination.totalPages with exact division") {
test("Pagination.totalPages handles normal and guarded inputs") {
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
}