From 3cec5b8898d70546388bed0616bebf68902d908b Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 5 Apr 2026 22:03:40 +0200 Subject: [PATCH] refactor(tests): enhance test coverage for move application and piece movement logic --- jacoco-reporter/test_counter.py | 10 + .../de/nowchess/api/board/BoardTest.scala | 11 + .../api/board/CastlingRightsTest.scala | 57 ++++ .../de/nowchess/api/board/ColorTest.scala | 23 +- .../de/nowchess/api/board/PieceTypeTest.scala | 34 +- .../de/nowchess/api/board/SquareTest.scala | 10 + .../scala/de/nowchess/api/move/MoveTest.scala | 61 +--- .../nowchess/api/player/PlayerInfoTest.scala | 17 +- .../de/nowchess/chess/engine/GameEngine.scala | 9 +- .../GameEngineCoverageRegressionTest.scala | 174 ++++++++++ .../io/IoCoverageRegressionTest.scala | 101 ++++++ .../de/nowchess/io/pgn/PgnParserTest.scala | 25 ++ .../DefaultRulesStateTransitionsTest.scala | 300 ++++++++++++++++++ .../ui/utils/RendererAndUnicodeTest.scala | 38 +++ 14 files changed, 773 insertions(+), 97 deletions(-) create mode 100644 jacoco-reporter/test_counter.py create mode 100644 modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala create mode 100644 modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineCoverageRegressionTest.scala create mode 100644 modules/io/src/test/scala/de/nowchess/io/IoCoverageRegressionTest.scala create mode 100644 modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala create mode 100644 modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala diff --git a/jacoco-reporter/test_counter.py b/jacoco-reporter/test_counter.py new file mode 100644 index 0000000..a32fa3f --- /dev/null +++ b/jacoco-reporter/test_counter.py @@ -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})') \ No newline at end of file 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 index 7757078..a42dcd4 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala @@ -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 + } + diff --git a/modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala b/modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala new file mode 100644 index 0000000..5dde137 --- /dev/null +++ b/modules/api/src/test/scala/de/nowchess/api/board/CastlingRightsTest.scala @@ -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 + 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 index 211b448..9e62c17 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/ColorTest.scala @@ -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 + } 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 index 135eee6..a10e2d4 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/PieceTypeTest.scala @@ -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 + } 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 index ed4c848..6806433 100644 --- a/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/board/SquareTest.scala @@ -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 + } + 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 index d0d64a1..f331a82 100644 --- a/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala @@ -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 + } } 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 index a073db1..8bbb54b 100644 --- a/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala +++ b/modules/api/src/test/scala/de/nowchess/api/player/PlayerInfoTest.scala @@ -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" } diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index e479e4f..a8490e8 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -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" diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineCoverageRegressionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineCoverageRegressionTest.scala new file mode 100644 index 0000000..e17451d --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineCoverageRegressionTest.scala @@ -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 + + diff --git a/modules/io/src/test/scala/de/nowchess/io/IoCoverageRegressionTest.scala b/modules/io/src/test/scala/de/nowchess/io/IoCoverageRegressionTest.scala new file mode 100644 index 0000000..a0c12e9 --- /dev/null +++ b/modules/io/src/test/scala/de/nowchess/io/IoCoverageRegressionTest.scala @@ -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) + + + diff --git a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala index 00b4ffc..e524c98 100644 --- a/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala +++ b/modules/io/src/test/scala/de/nowchess/io/pgn/PgnParserTest.scala @@ -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) + diff --git a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala new file mode 100644 index 0000000..0d122c0 --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala @@ -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)) + + + + + + diff --git a/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala b/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala new file mode 100644 index 0000000..431d6e3 --- /dev/null +++ b/modules/ui/src/test/scala/de/nowchess/ui/utils/RendererAndUnicodeTest.scala @@ -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[") + +