refactor(tests): enhance test coverage for move application and piece movement logic

This commit is contained in:
2026-04-05 22:03:40 +02:00
parent 4cf39e3e97
commit 3cec5b8898
14 changed files with 773 additions and 97 deletions
+10
View File
@@ -0,0 +1,10 @@
import glob,re,os
rows=[]
for f in glob.glob('modules/*/build/test-results/test/TEST-*.xml'):
txt=open(f,encoding='utf-8').read(500)
m1=re.search(r'name="([^"]+)"',txt)
m2=re.search(r'tests="(\d+)"',txt)
if m1 and m2:
rows.append((int(m2.group(1)),f,m1.group(1)))
for n,f,name in sorted(rows, reverse=True)[:20]:
print(f'{n:3} {name} ({f})')
@@ -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
@@ -120,3 +121,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
}
@@ -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
}
@@ -60,3 +60,13 @@ class SquareTest extends AnyFunSuite with Matchers:
test("fromAlgebraic returns None for rank 9") {
Square.fromAlgebraic("e9") shouldBe None
}
test("offset returns target square for in-bounds delta") {
Square(File.E, Rank.R4).offset(1, 2) shouldBe Some(Square(File.F, Rank.R6))
}
test("offset returns None for out-of-bounds delta") {
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
}
@@ -9,53 +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") {
test("Move defaults to Normal and keeps from/to squares") {
val m = Move(e2, e4)
m.from shouldBe e2
m.to shouldBe e4
m.moveType shouldBe MoveType.Normal()
}
test("MoveType.Normal supports capture flag") {
val m = Move(e2, e4, MoveType.Normal(isCapture = true))
m.moveType shouldBe MoveType.Normal(true)
}
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 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)
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"
}
@@ -158,7 +158,7 @@ class GameEngine(
else
replayMoves(ctx.moves, savedContext)
private def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
var error: Option[String] = None
moves.foreach: move =>
if error.isEmpty then
@@ -170,7 +170,6 @@ class GameEngine(
else
error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
error match
case Some(err) =>
currentContext = savedContext
@@ -256,7 +255,7 @@ class GameEngine(
case PromotionPiece.Knight => "N"
s"${move.to}=$ppChar"
private def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
private[engine] def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
boardBefore.pieceAt(move.from).map(_.pieceType) match
case Some(PieceType.Pawn) =>
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
@@ -264,11 +263,9 @@ class GameEngine(
case Some(pt) =>
val letter = pieceNotation(pt)
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
// $COVERAGE-OFF$ unreachable: executeMove is only called after a piece existence check
case None => move.to.toString
// $COVERAGE-ON$
private def pieceNotation(pieceType: PieceType): String =
private[engine] def pieceNotation(pieceType: PieceType): String =
pieceType match
case PieceType.Knight => "N"
case PieceType.Bishop => "B"
@@ -0,0 +1,174 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, MoveRedoneEvent, Observer}
import de.nowchess.io.GameContextImport
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineCoverageRegressionTest extends AnyFunSuite with Matchers:
private def sq(alg: String): Square =
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
val events = collection.mutable.ListBuffer[GameEvent]()
engine.subscribe(new Observer { def onGameEvent(event: GameEvent): Unit = events += event })
events
test("accessors expose redo availability and command history"):
val engine = new GameEngine()
engine.canRedo shouldBe false
engine.commandHistory shouldBe empty
engine.processUserInput("e2e4")
engine.commandHistory.nonEmpty shouldBe true
test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("")
engine.processUserInput("oops")
engine.processUserInput("undo")
engine.processUserInput("redo")
events.count(_.isInstanceOf[InvalidMoveEvent]) should be >= 3
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("e2e5")
events.exists {
case InvalidMoveEvent(_, reason) => reason.contains("Illegal move")
case _ => false
} shouldBe true
test("loadGame returns Left when importer fails"):
val engine = new GameEngine()
val failingImporter = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("e2e4")
val target = GameContext.initial.withTurn(Color.Black)
engine.loadPosition(target)
engine.context shouldBe target
engine.commandHistory shouldBe empty
events.lastOption.exists(_.isInstanceOf[de.nowchess.chess.observer.BoardResetEvent]) shouldBe true
test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine()
val events = captureEvents(engine)
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
events.clear()
engine.processUserInput("a1h1")
engine.processUserInput("undo")
engine.processUserInput("redo")
val redo = events.collectFirst { case e: MoveRedoneEvent => e }
redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
test("loadGame replay handles promotion moves when pending promotion exists"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val permissiveRules = new RuleSet:
def candidateMoves(context: GameContext, square: Square): List[Move] = legalMoves(context, square)
def legalMoves(context: GameContext, square: Square): List[Move] =
if square == sq("e2") then List(promotionMove) else List.empty
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext, move: Move): GameContext = DefaultRules.applyMove(context, move)
val engine = new GameEngine(ruleSet = permissiveRules)
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
engine.loadGame(importer, "ignored") shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(promotionMove)
test("loadGame replay restores previous context when promotion cannot be completed"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val noLegalMoves = new RuleSet:
def candidateMoves(context: GameContext, square: Square): List[Move] = List.empty
def legalMoves(context: GameContext, square: Square): List[Move] = List.empty
def allLegalMoves(context: GameContext): List[Move] = List.empty
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext, move: Move): GameContext = context
val engine = new GameEngine(ruleSet = noLegalMoves)
engine.processUserInput("e2e4")
val saved = engine.context
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
val result = engine.loadGame(importer, "ignored")
result.isLeft shouldBe true
result.left.toOption.get should include("Promotion required")
engine.context shouldBe saved
test("loadGame replay executes non-promotion moves through default replay branch"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
val engine = new GameEngine()
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove)
test("loadGame replay will stop on errors"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
val engine = new GameEngine()
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove)
test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine()
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
result shouldBe "e4"
test("pieceNotation default branch returns empty string"):
val engine = new GameEngine()
val result = engine.pieceNotation(PieceType.Pawn)
result shouldBe ""
test("observerCount reflects subscribe and unsubscribe operations"):
val engine = new GameEngine()
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = ()
engine.observerCount shouldBe 0
engine.subscribe(observer)
engine.observerCount shouldBe 1
engine.unsubscribe(observer)
engine.observerCount shouldBe 0
@@ -0,0 +1,101 @@
package de.nowchess.io
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class IoCoverageRegressionTest extends AnyFunSuite with Matchers:
private def sq(alg: String): Square =
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
test("FenParser rejects malformed board shapes and invalid piece symbols"):
FenParser.parseBoard("8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
test("FenExporter exportGameContext forwards to gameContextToFen"):
val context = GameContext.initial
FenExporter.exportGameContext(context) shouldBe FenExporter.gameContextToFen(context)
test("PgnParser rejects too-short notation and invalid piece letters"):
val initial = GameContext.initial
PgnParser.parseAlgebraicMove("e", initial, Color.White) shouldBe None
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
test("PgnParser rejects notation with invalid promotion piece"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
val context = GameContext.initial.withBoard(board)
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
test("PgnExporter emits notation for all normal piece types and captures"):
val moves = List(
Move(sq("e2"), sq("e4")),
Move(sq("a7"), sq("a6")),
Move(sq("g1"), sq("f3")),
Move(sq("b7"), sq("b6")),
Move(sq("f1"), sq("b5"), MoveType.Normal(true)),
Move(sq("g8"), sq("f6")),
Move(sq("a1"), sq("a8"), MoveType.Normal(true)),
Move(sq("c7"), sq("c6")),
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
)
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
pgn should include("e4")
pgn should include("Nf3")
pgn should include("Bxb5")
pgn should include("Rxa8")
pgn should include("Qxd7")
pgn should include("Kxe2")
test("PgnExporter emits en-passant and promotion capture notation"):
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
pgn should include("exd3")
pgn should include("exf8=Q")
pawnCapturePgn should include("exd3")
quietPromotionPgn should include("e8=Q")
test("PgnExporter emits all promotion suffixes"):
val promotions = List(
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)),
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Rook)),
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Bishop)),
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Knight))
)
val pgn = PgnExporter.exportGame(Map.empty, promotions)
pgn should include("=Q")
pgn should include("=R")
pgn should include("=B")
pgn should include("=N")
test("PgnParser parsePgn silently skips unknown tokens"):
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
parsed.map(_.moves.size) shouldBe Some(2)
@@ -167,3 +167,28 @@ class PgnParserTest extends AnyFunSuite with Matchers:
val result = PgnParser.importGameContext(invalidPgn)
// Empty PGN is still valid (no moves), so check for reasonable parsing
result.isRight shouldBe true
test("parseAlgebraicMove: uppercase file token still fails when destination is unreachable"):
val result = PgnParser.parseAlgebraicMove("E5", GameContext.initial, Color.White)
result shouldBe None
test("parseAlgebraicMove: non-file/rank hint characters are ignored"):
val result = PgnParser.parseAlgebraicMove("N?f3", GameContext.initial, Color.White)
result.isDefined shouldBe true
result.get.to shouldBe Square(File.F, Rank.R3)
test("extractPromotion returns None for unsupported promotion letter"):
PgnParser.extractPromotion("e7e8=X") shouldBe None
test("parseAlgebraicMove rejects promotion target without promotion suffix"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e8", GameContext.initial.withBoard(board), Color.White)
result shouldBe None
test("parseAlgebraicMove: king notation resolves a legal king move"):
val board = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
val result = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.from shouldBe Square(File.E, Rank.R1)
result.get.to shouldBe Square(File.E, Rank.R2)
@@ -0,0 +1,300 @@
package de.nowchess.rule
import de.nowchess.api.board.{CastlingRights, Color, Piece, PieceType, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.io.fen.FenParser
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
private def contextFromFen(fen: String): GameContext =
FenParser.parseFen(fen).fold(err => fail(err), identity)
private def sq(alg: String): Square =
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
test("isCheckmate returns true for a known mate pattern"):
val context = contextFromFen("rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3")
DefaultRules.isCheck(context) shouldBe true
DefaultRules.isCheckmate(context) shouldBe true
DefaultRules.allLegalMoves(context) shouldBe empty
test("isStalemate returns true for a known stalemate pattern"):
val context = contextFromFen("7k/5K2/6Q1/8/8/8/8/8 b - - 0 1")
DefaultRules.isCheck(context) shouldBe false
DefaultRules.isStalemate(context) shouldBe true
DefaultRules.allLegalMoves(context) shouldBe empty
test("isInsufficientMaterial returns true for king versus king"):
val context = contextFromFen("8/8/8/8/8/8/4k3/4K3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe true
test("isInsufficientMaterial returns true for king and bishop versus king"):
val context = contextFromFen("8/8/8/8/8/8/4k3/3BK3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe true
test("isInsufficientMaterial returns false for king and rook versus king"):
val context = contextFromFen("8/8/8/8/8/8/4k3/3RK3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe false
test("isFiftyMoveRule returns true when halfMoveClock is 100"):
val context = contextFromFen("8/8/8/8/8/8/4k3/4K3 w - - 100 1")
DefaultRules.isFiftyMoveRule(context) shouldBe true
test("applyMove toggles turn and records move"):
val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(GameContext.initial, move)
next.turn shouldBe Color.Black
next.moves.lastOption shouldBe Some(move)
test("applyMove sets en passant square after double pawn push"):
val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(GameContext.initial, move)
next.enPassantSquare shouldBe Some(sq("e3"))
test("applyMove clears en passant square for non double pawn push"):
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
val move = Move(sq("e2"), sq("e3"))
val next = DefaultRules.applyMove(context, move)
next.enPassantSquare shouldBe None
test("applyMove resets halfMoveClock on pawn move"):
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
val move = Move(sq("e2"), sq("e4"))
val next = DefaultRules.applyMove(context, move)
next.halfMoveClock shouldBe 0
test("applyMove increments halfMoveClock on quiet non pawn move"):
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
val move = Move(sq("g1"), sq("f3"))
val next = DefaultRules.applyMove(context, move)
next.halfMoveClock shouldBe 8
test("applyMove resets halfMoveClock on capture"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context, move)
next.halfMoveClock shouldBe 0
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
test("applyMove updates castling rights after king move"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("e2"))
val next = DefaultRules.applyMove(context, move)
next.castlingRights.whiteKingSide shouldBe false
next.castlingRights.whiteQueenSide shouldBe false
next.castlingRights.blackKingSide shouldBe true
next.castlingRights.blackQueenSide shouldBe true
test("applyMove updates castling rights after rook move from h1"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
val move = Move(sq("h1"), sq("h2"))
val next = DefaultRules.applyMove(context, move)
next.castlingRights.whiteKingSide shouldBe false
next.castlingRights.whiteQueenSide shouldBe true
test("applyMove revokes opponent castling right when rook on starting square is captured"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackQueenSide shouldBe false
test("applyMove executes kingside castling and repositions king and rook"):
val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("g1")) shouldBe Some(Piece(Color.White, PieceType.King))
next.board.pieceAt(sq("f1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
next.board.pieceAt(sq("e1")) shouldBe None
next.board.pieceAt(sq("h1")) shouldBe None
test("applyMove executes queenside castling and repositions king and rook"):
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("c1")) shouldBe Some(Piece(Color.White, PieceType.King))
next.board.pieceAt(sq("d1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
next.board.pieceAt(sq("e1")) shouldBe None
next.board.pieceAt(sq("a1")) shouldBe None
test("applyMove executes en passant and removes captured pawn"):
val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("d6")) shouldBe Some(Piece(Color.White, PieceType.Pawn))
next.board.pieceAt(sq("d5")) shouldBe None
next.board.pieceAt(sq("e5")) shouldBe None
test("applyMove executes promotion with selected piece type"):
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Knight))
next.board.pieceAt(sq("a7")) shouldBe None
test("candidateMoves returns empty for opponent piece on selected square"):
val context = GameContext.initial.withTurn(Color.Black)
DefaultRules.candidateMoves(context, sq("e2")) shouldBe empty
test("legalMoves keeps king safe by filtering pinned bishop moves"):
val context = contextFromFen("8/8/8/8/8/8/r1B1K3/8 w - - 0 1")
val bishopMoves = DefaultRules.legalMoves(context, sq("c2"))
bishopMoves shouldBe empty
test("applyMove preserves black castling rights after white kingside castling"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context, move)
next.castlingRights.whiteKingSide shouldBe false
next.castlingRights.whiteQueenSide shouldBe false
next.castlingRights.blackKingSide shouldBe true
next.castlingRights.blackQueenSide shouldBe true
test("applyMove can revoke both white castling rights when both rooks are captured"):
val context = GameContext(
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
turn = Color.Black,
castlingRights = CastlingRights(true, true, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty
)
val afterA1Capture = DefaultRules.applyMove(context, Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
val afterH1Capture = DefaultRules.applyMove(afterA1Capture, Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
afterH1Capture.castlingRights.whiteKingSide shouldBe false
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
test("isInsufficientMaterial returns true for opposite color bishops only"):
val context = contextFromFen("8/8/8/8/8/8/4k1b1/3BK3 w - - 0 1")
DefaultRules.isInsufficientMaterial(context) shouldBe true
test("candidateMoves for rook includes enemy capture move"):
val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
val rookMoves = DefaultRules.candidateMoves(context, sq("a1"))
rookMoves.exists(m => m.to == sq("h1") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
test("candidateMoves for knight includes enemy capture move"):
val context = contextFromFen("4k3/8/8/8/8/3p4/5N2/4K3 w - - 0 1")
val knightMoves = DefaultRules.candidateMoves(context, sq("f2"))
knightMoves.exists(m => m.to == sq("d3") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
test("candidateMoves includes black kingside and queenside castling options"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val kingMoves = DefaultRules.candidateMoves(context, sq("e8"))
kingMoves.exists(_.moveType == MoveType.CastleKingside) shouldBe true
kingMoves.exists(_.moveType == MoveType.CastleQueenside) shouldBe true
test("applyMove executes black kingside castling and repositions pieces on rank 8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
val next = DefaultRules.applyMove(context, move)
next.board.pieceAt(sq("g8")) shouldBe Some(Piece(Color.Black, PieceType.King))
next.board.pieceAt(sq("f8")) shouldBe Some(Piece(Color.Black, PieceType.Rook))
next.board.pieceAt(sq("e8")) shouldBe None
next.board.pieceAt(sq("h8")) shouldBe None
test("applyMove revokes black castling rights when black rook moves from h8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("h8"), sq("h7"))
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackKingSide shouldBe false
next.castlingRights.blackQueenSide shouldBe true
test("applyMove revokes black queenside castling right when black rook moves from a8"):
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
val move = Move(sq("a8"), sq("a7"))
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackKingSide shouldBe true
next.castlingRights.blackQueenSide shouldBe false
test("applyMove revokes black kingside castling right when rook on h8 is captured"):
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
val next = DefaultRules.applyMove(context, move)
next.castlingRights.blackKingSide shouldBe false
test("candidateMoves creates all promotion move variants for black pawn"):
val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
val to = sq("a1")
val pawnMoves = DefaultRules.candidateMoves(context, sq("a2"))
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
promotions.toSet shouldBe Set(
PromotionPiece.Queen,
PromotionPiece.Rook,
PromotionPiece.Bishop,
PromotionPiece.Knight
)
test("applyMove promotion supports queen rook and bishop targets"):
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val queen = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
val rook = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
val bishop = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
@@ -0,0 +1,38 @@
package de.nowchess.ui.utils
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
private val whiteKing = Piece(Color.White, PieceType.King)
private val blackPawn = Piece(Color.Black, PieceType.Pawn)
test("unicode returns the correct symbol for white king"):
whiteKing.unicode shouldBe "\u2654"
test("unicode returns the correct symbol for black pawn"):
blackPawn.unicode shouldBe "\u265F"
test("render includes board coordinates on top and bottom"):
val rendered = Renderer.render(Board(Map.empty))
val lines = rendered.trim.split("\\n").toList.map(_.trim)
lines.head shouldBe "a b c d e f g h"
lines.last shouldBe "a b c d e f g h"
test("render includes rank labels from 8 down to 1"):
val rendered = Renderer.render(Board(Map.empty))
rendered should include("8")
rendered should include("1")
test("render places a piece unicode glyph on occupied square"):
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
val rendered = Renderer.render(board)
rendered should include("\u2655")
rendered should include("\u001b[")