From 3d9a108bdcf3251124617693dfc8dd6f477d4139 Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 4 Apr 2026 17:11:11 +0200 Subject: [PATCH] refactor(core): NCS-22 update GameEngine to use GameContext and integrate Rule module --- compile | 0 coverage | 0 .../de/nowchess/chess/command/Command.scala | 19 +- .../de/nowchess/chess/engine/GameEngine.scala | 383 ++++++------- .../nowchess/chess/notation/PgnParser.scala | 211 +++---- .../command/CommandInvokerBranchTest.scala | 78 +-- .../chess/command/CommandInvokerTest.scala | 17 +- .../CommandInvokerThreadSafetyTest.scala | 29 +- .../nowchess/chess/command/CommandTest.scala | 18 +- .../command/MoveCommandDefaultsTest.scala | 54 ++ .../command/MoveCommandImmutabilityTest.scala | 36 +- .../chess/controller/GameControllerTest.scala | 526 ------------------ .../engine/GameEngineEdgeCasesTest.scala | 2 - .../engine/GameEngineGameEndingTest.scala | 12 +- .../GameEngineHandleFailedMoveTest.scala | 1 - .../engine/GameEngineInvalidMovesTest.scala | 1 - .../chess/engine/GameEngineLoadPgnTest.scala | 32 +- .../engine/GameEnginePromotionTest.scala | 51 +- .../chess/engine/GameEngineTest.scala | 47 +- .../engine/MoveCommandDefaultsTest.scala | 110 ---- .../logic/CastlingRightsCalculatorTest.scala | 70 --- .../chess/logic/EnPassantCalculatorTest.scala | 101 ---- .../chess/logic/GameHistoryTest.scala | 9 +- .../nowchess/chess/logic/GameRulesTest.scala | 161 ------ .../chess/logic/MoveValidatorTest.scala | 280 ---------- .../chess/notation/FenExporterTest.scala | 5 +- .../chess/notation/PgnExporterTest.scala | 6 +- .../chess/notation/PgnParserTest.scala | 112 ++-- .../chess/notation/PgnValidatorTest.scala | 6 +- .../observer/ObservableThreadSafetyTest.scala | 8 +- .../scala/de/nowchess/rules/RuleSet.scala | 6 + .../de/nowchess/rules/StandardRules.scala | 91 ++- modules/ui/build.gradle.kts | 1 + .../de/nowchess/ui/gui/ChessBoardView.scala | 1 - .../test/scala/de/nowchess/ui/MainTest.scala | 22 - .../nowchess/ui/terminal/TerminalUITest.scala | 327 ----------- 36 files changed, 527 insertions(+), 2306 deletions(-) mode change 100644 => 100755 compile mode change 100644 => 100755 coverage create mode 100644 modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandDefaultsTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/engine/MoveCommandDefaultsTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/logic/CastlingRightsCalculatorTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala delete mode 100644 modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala delete mode 100644 modules/ui/src/test/scala/de/nowchess/ui/MainTest.scala delete mode 100644 modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala diff --git a/compile b/compile old mode 100644 new mode 100755 diff --git a/coverage b/coverage old mode 100644 new mode 100755 diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala index 582da08..a9aaf94 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala @@ -1,7 +1,7 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, Board, Color, Piece} -import de.nowchess.api.game.GameHistory +import de.nowchess.api.board.{Square, Piece} +import de.nowchess.api.game.GameContext /** Marker trait for all commands that can be executed and undone. * Commands encapsulate user actions and game state transitions. @@ -23,23 +23,22 @@ case class MoveCommand( from: Square, to: Square, moveResult: Option[MoveResult] = None, - previousBoard: Option[Board] = None, - previousHistory: Option[GameHistory] = None, - previousTurn: Option[Color] = None + previousContext: Option[GameContext] = None, + notation: String = "" ) extends Command: override def execute(): Boolean = moveResult.isDefined override def undo(): Boolean = - previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined + previousContext.isDefined override def description: String = s"Move from $from to $to" // Sealed hierarchy of move outcomes (for tracking state changes) sealed trait MoveResult object MoveResult: - case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult + case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult case object InvalidFormat extends MoveResult case object InvalidMove extends MoveResult @@ -51,14 +50,12 @@ case class QuitCommand() extends Command: /** Command to reset the board to initial position. */ case class ResetCommand( - previousBoard: Option[Board] = None, - previousHistory: Option[GameHistory] = None, - previousTurn: Option[Color] = None + previousContext: Option[GameContext] = None ) extends Command: override def execute(): Boolean = true override def undo(): Boolean = - previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined + previousContext.isDefined override def description: String = "Reset board" 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 d1753c0..3ce8657 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 @@ -1,57 +1,36 @@ package de.nowchess.chess.engine -import de.nowchess.api.board.{Board, Color, Piece, Square, CastlingRights} -import de.nowchess.api.move.{PromotionPiece, Move} -import de.nowchess.api.game.{GameHistory, GameContext} -import de.nowchess.chess.controller.{GameController, Parser, MoveResult} +import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.api.game.GameContext +import de.nowchess.chess.controller.Parser import de.nowchess.chess.observer.* -import de.nowchess.chess.command.{CommandInvoker, MoveCommand} +import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} import de.nowchess.chess.notation.{PgnExporter, PgnParser} +import de.nowchess.rules.{RuleSet, StandardRules} /** Pure game engine that manages game state and notifies observers of state changes. - * This class is the single source of truth for the game state. - * All user interactions must go through this engine via Commands, and all state changes - * are communicated to observers via GameEvent notifications. + * All rule queries delegate to the injected RuleSet. + * All user interactions go through Commands; state changes are broadcast via GameEvents. */ class GameEngine( - initialBoard: Board = Board.initial, - initialHistory: GameHistory = GameHistory.empty, - initialTurn: Color = Color.White, - completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult = - GameController.completePromotion + initialContext: GameContext = GameContext.initial, + ruleSet: RuleSet = StandardRules ) extends Observable: - private var currentBoard: Board = initialBoard - private var currentHistory: GameHistory = initialHistory - private var currentTurn: Color = initialTurn + private var currentContext: GameContext = initialContext private val invoker = new CommandInvoker() - /** Inner class for tracking pending promotion state */ - private case class PendingPromotion( - from: Square, to: Square, - boardBefore: Board, historyBefore: GameHistory, - turn: Color - ) - - /** Current pending promotion, if any */ + /** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */ + private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext) private var pendingPromotion: Option[PendingPromotion] = None /** True if a pawn promotion move is pending and needs a piece choice. */ def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined } // Synchronized accessors for current state - def board: Board = synchronized { currentBoard } - def history: GameHistory = synchronized { currentHistory } - def turn: Color = synchronized { currentTurn } - - /** Create a GameContext from current state (for event creation). */ - private def currentContext: GameContext = GameContext( - board = currentBoard, - turn = currentTurn, - castlingRights = CastlingRights.Initial, // TODO: derive from history - enPassantSquare = None, // TODO: derive from history - halfMoveClock = currentHistory.halfMoveClock, - moves = List.empty // TODO: convert history moves to api.move.Move - ) + def board: Board = synchronized { currentContext.board } + def turn: Color = synchronized { currentContext.turn } + def context: GameContext = synchronized { currentContext } /** Check if undo is available. */ def canUndo: Boolean = synchronized { invoker.canUndo } @@ -69,7 +48,6 @@ class GameEngine( val trimmed = rawInput.trim.toLowerCase trimmed match case "quit" | "q" => - // Client should handle quit logic; we just return () case "undo" => @@ -79,10 +57,8 @@ class GameEngine( performRedo() case "draw" => - if currentHistory.halfMoveClock >= 100 then - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White + if currentContext.halfMoveClock >= 100 then + currentContext = GameContext.initial invoker.clear() notifyObservers(DrawClaimedEvent(currentContext)) else @@ -92,11 +68,7 @@ class GameEngine( )) case "" => - val event = InvalidMoveEvent( - currentContext, - "Please enter a valid move or command." - ) - notifyObservers(event) + notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command.")) case moveInput => Parser.parseMove(moveInput) match @@ -106,67 +78,35 @@ class GameEngine( s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4." )) case Some((from, to)) => - handleParsedMove(from, to, moveInput) + handleParsedMove(from, to) } - private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit = - val cmd = MoveCommand( - from = from, - to = to, - previousBoard = Some(currentBoard), - previousHistory = Some(currentHistory), - previousTurn = Some(currentTurn) - ) - GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match - case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit => - handleFailedMove(moveInput) + private def handleParsedMove(from: Square, to: Square): Unit = + currentContext.board.pieceAt(from) match + case None => + notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square.")) + case Some(piece) if piece.color != currentContext.turn => + notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece.")) + case Some(piece) => + val legal = ruleSet.legalMoves(currentContext, from) + // Find all legal moves going to `to` + val candidates = legal.filter(_.to == to) + candidates match + case Nil => + notifyObservers(InvalidMoveEvent(currentContext, "Illegal move.")) + case moves if isPromotionMove(piece, to) => + // Multiple moves (one per promotion piece) — ask user to choose + val contextBefore = currentContext + pendingPromotion = Some(PendingPromotion(from, to, contextBefore)) + notifyObservers(PromotionRequiredEvent(currentContext, from, to)) + case move :: _ => + executeMove(move) - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) - invoker.execute(updatedCmd) - updateGameState(newBoard, newHistory, newTurn) - emitMoveEvent(from.toString, to.toString, captured, newTurn) - if currentHistory.halfMoveClock >= 100 then - notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) - - case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) - invoker.execute(updatedCmd) - updateGameState(newBoard, newHistory, newTurn) - emitMoveEvent(from.toString, to.toString, captured, newTurn) - notifyObservers(CheckDetectedEvent(currentContext)) - if currentHistory.halfMoveClock >= 100 then - notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) - - case MoveResult.Checkmate(winner) => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(CheckmateEvent(currentContext, winner)) - - case MoveResult.Stalemate => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(StalemateEvent(currentContext)) - - case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) => - pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn)) - notifyObservers(PromotionRequiredEvent(currentContext, promFrom, promTo)) - - /** Undo the last move. */ - def undo(): Unit = synchronized { - performUndo() - } - - /** Redo the last undone move. */ - def redo(): Unit = synchronized { - performRedo() - } + private def isPromotionMove(piece: Piece, to: Square): Boolean = + piece.pieceType == PieceType.Pawn && { + val promoRank = if piece.color == Color.White then 7 else 0 + to.rank.ordinal == promoRank + } /** Apply a player's promotion piece choice. * Must only be called when isPendingPromotion is true. @@ -177,88 +117,48 @@ class GameEngine( notifyObservers(InvalidMoveEvent(currentContext, "No promotion pending.")) case Some(pending) => pendingPromotion = None - val cmd = MoveCommand( - from = pending.from, - to = pending.to, - previousBoard = Some(pending.boardBefore), - previousHistory = Some(pending.historyBefore), - previousTurn = Some(pending.turn) - ) - completePromotionFn( - pending.boardBefore, pending.historyBefore, - pending.from, pending.to, piece, pending.turn - ) match - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) - invoker.execute(updatedCmd) - updateGameState(newBoard, newHistory, newTurn) - emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn) - - case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))) - invoker.execute(updatedCmd) - updateGameState(newBoard, newHistory, newTurn) - emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn) - notifyObservers(CheckDetectedEvent(currentContext)) - - case MoveResult.Checkmate(winner) => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(CheckmateEvent(currentContext, winner)) - - case MoveResult.Stalemate => - val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))) - invoker.execute(updatedCmd) - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White - notifyObservers(StalemateEvent(currentContext)) - - case _ => - notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion.")) + val move = Move(pending.from, pending.to, MoveType.Promotion(piece)) + // Verify it's actually legal + val legal = ruleSet.legalMoves(currentContext, pending.from) + if legal.contains(move) then + executeMove(move) + else + notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion.")) } + /** Undo the last move. */ + def undo(): Unit = synchronized { performUndo() } + + /** Redo the last undone move. */ + def redo(): Unit = synchronized { performRedo() } + /** Validate and load a PGN string. * Each move is replayed through the command system so undo/redo is available after loading. - * Returns Right(()) on success; Left(error) if any move is illegal or the position impossible. */ + */ def loadPgn(pgn: String): Either[String, Unit] = synchronized { PgnParser.validatePgn(pgn) match - case Left(err) => - Left(err) + case Left(err) => Left(err) case Right(game) => - val initialBoardBeforeLoad = currentBoard - val initialHistoryBeforeLoad = currentHistory - val initialTurnBeforeLoad = currentTurn - - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White + val savedContext = currentContext + currentContext = GameContext.initial pendingPromotion = None invoker.clear() var error: Option[String] = None import scala.util.control.Breaks._ breakable { - game.moves.foreach { move => - handleParsedMove(move.from, move.to, s"${move.from}${move.to}") - move.promotionPiece.foreach(completePromotion) - - // If the move failed to execute properly, stop and report - // (validatePgn should have caught this, but we're being safe) - if pendingPromotion.isDefined && move.promotionPiece.isEmpty then - error = Some(s"Promotion required for move ${move.from}${move.to}") + game.moves.foreach { histMove => + handleParsedMove(histMove.from, histMove.to) + histMove.promotionPiece.foreach(completePromotion) + if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then + error = Some(s"Promotion required for move ${histMove.from}${histMove.to}") break() } } - + error match case Some(err) => - currentBoard = initialBoardBeforeLoad - currentHistory = initialHistoryBeforeLoad - currentTurn = initialTurnBeforeLoad + currentContext = savedContext Left(err) case None => notifyObservers(PgnLoadedEvent(currentContext)) @@ -266,10 +166,8 @@ class GameEngine( } /** Load an arbitrary board position, clearing all history and undo/redo state. */ - def loadPosition(board: Board, history: GameHistory, turn: Color): Unit = synchronized { - currentBoard = board - currentHistory = history - currentTurn = turn + def loadPosition(newContext: GameContext): Unit = synchronized { + currentContext = newContext pendingPromotion = None invoker.clear() notifyObservers(BoardResetEvent(currentContext)) @@ -277,28 +175,97 @@ class GameEngine( /** Reset the board to initial position. */ def reset(): Unit = synchronized { - currentBoard = Board.initial - currentHistory = GameHistory.empty - currentTurn = Color.White + currentContext = GameContext.initial invoker.clear() - notifyObservers(BoardResetEvent( - currentContext - )) + notifyObservers(BoardResetEvent(currentContext)) } - // ──── Private Helpers ──── + // ──── Private helpers ──── + + private def executeMove(move: Move): Unit = + val contextBefore = currentContext + val nextContext = ruleSet.applyMove(currentContext, move) + val captured = computeCaptured(currentContext, move) + + val cmd = MoveCommand( + from = move.from, + to = move.to, + moveResult = Some(MoveResult.Successful(nextContext, captured)), + previousContext = Some(contextBefore), + notation = moveToPgn(move, contextBefore.board) + ) + invoker.execute(cmd) + currentContext = nextContext + + notifyObservers(MoveExecutedEvent( + currentContext, + move.from.toString, + move.to.toString, + captured.map(c => s"${c.color.label} ${c.pieceType.label}") + )) + + if ruleSet.isCheckmate(currentContext) then + val winner = currentContext.turn.opposite + currentContext = GameContext.initial + notifyObservers(CheckmateEvent(currentContext, winner)) + else if ruleSet.isStalemate(currentContext) then + currentContext = GameContext.initial + notifyObservers(StalemateEvent(currentContext)) + else if ruleSet.isCheck(currentContext) then + notifyObservers(CheckDetectedEvent(currentContext)) + + if currentContext.halfMoveClock >= 100 then + notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) + + private def moveToPgn(move: Move, boardBefore: Board): String = + move.moveType match + case MoveType.CastleKingside => "O-O" + case MoveType.CastleQueenside => "O-O-O" + case MoveType.EnPassant => + s"${move.from.file.toString.toLowerCase}x${move.to}" + case MoveType.Promotion(pp) => + val ppChar = pp match + case PromotionPiece.Queen => "Q" + case PromotionPiece.Rook => "R" + case PromotionPiece.Bishop => "B" + case PromotionPiece.Knight => "N" + s"${move.to}=$ppChar" + case MoveType.Normal => + val isCapture = boardBefore.pieceAt(move.to).isDefined + boardBefore.pieceAt(move.from).map(_.pieceType) match + case Some(PieceType.Pawn) => + if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}" + else move.to.toString + case Some(pt) => + val letter = pt match + case PieceType.Knight => "N" + case PieceType.Bishop => "B" + case PieceType.Rook => "R" + case PieceType.Queen => "Q" + case PieceType.King => "K" + case _ => "" + if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}" + case None => move.to.toString + + private def computeCaptured(context: GameContext, move: Move): Option[Piece] = + move.moveType match + case MoveType.EnPassant => + // Captured pawn is on the same rank as the moving pawn, same file as destination + val capturedSquare = Square(move.to.file, move.from.rank) + context.board.pieceAt(capturedSquare) + case MoveType.CastleKingside | MoveType.CastleQueenside => + None + case _ => + context.board.pieceAt(move.to) private def performUndo(): Unit = if invoker.canUndo then val cmd = invoker.history(invoker.getCurrentIndex) (cmd: @unchecked) match case moveCmd: MoveCommand => - val notation = currentHistory.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("") - moveCmd.previousBoard.foreach(currentBoard = _) - moveCmd.previousHistory.foreach(currentHistory = _) - moveCmd.previousTurn.foreach(currentTurn = _) + moveCmd.previousContext.foreach(currentContext = _) invoker.undo() - notifyObservers(MoveUndoneEvent(currentContext, notation)) + notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation)) else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo.")) @@ -307,44 +274,16 @@ class GameEngine( val cmd = invoker.history(invoker.getCurrentIndex + 1) (cmd: @unchecked) match case moveCmd: MoveCommand => - for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do - updateGameState(nb, nh, nt) + for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do + currentContext = nextCtx invoker.redo() - val notation = nh.moves.lastOption.map(PgnExporter.moveToAlgebraic).getOrElse("") val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}") - notifyObservers(MoveRedoneEvent(currentContext, notation, moveCmd.from.toString, moveCmd.to.toString, capturedDesc)) + notifyObservers(MoveRedoneEvent( + currentContext, + moveCmd.notation, + moveCmd.from.toString, + moveCmd.to.toString, + capturedDesc + )) else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo.")) - - private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit = - currentBoard = newBoard - currentHistory = newHistory - currentTurn = newTurn - - private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit = - val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}") - notifyObservers(MoveExecutedEvent( - currentContext, - fromSq, - toSq, - capturedDesc - )) - - private def handleFailedMove(moveInput: String): Unit = - (GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match - case MoveResult.NoPiece => - notifyObservers(InvalidMoveEvent( - currentContext, - "No piece on that square." - )) - case MoveResult.WrongColor => - notifyObservers(InvalidMoveEvent( - currentContext, - "That is not your piece." - )) - case MoveResult.IllegalMove => - notifyObservers(InvalidMoveEvent( - currentContext, - "Illegal move." - )) - diff --git a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala index 2ee0304..a22c29e 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/notation/PgnParser.scala @@ -1,8 +1,9 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* -import de.nowchess.api.move.PromotionPiece -import de.nowchess.api.game.{GameHistory, HistoryMove} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.api.game.{GameContext, HistoryMove} +import de.nowchess.rules.StandardRules /** A parsed PGN game containing headers and the resolved move list. */ case class PgnGame( @@ -27,11 +28,9 @@ object PgnParser: def parsePgn(pgn: String): Option[PgnGame] = val lines = pgn.split("\n").map(_.trim) val (headerLines, rest) = lines.span(_.startsWith("[")) - val headers = parseHeaders(headerLines) val moveText = rest.mkString(" ") val moves = parseMovesText(moveText) - Some(PgnGame(headers, moves)) /** Parse PGN header lines of the form [Key "Value"]. */ @@ -42,40 +41,32 @@ object PgnParser: /** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */ private def parseMovesText(moveText: String): List[HistoryMove] = val tokens = moveText.split("\\s+").filter(_.nonEmpty) - - // Fold over tokens, threading (board, history, currentColor, accumulator) - val (_, _, _, moves) = tokens.foldLeft( - (Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove]) + val (_, _, moves) = tokens.foldLeft( + (GameContext.initial, Color.White, List.empty[HistoryMove]) ): - case (state @ (board, history, color, acc), token) => - // Skip move-number markers (e.g. "1.", "2.") and result tokens + case (state @ (ctx, color, acc), token) => if isMoveNumberOrResult(token) then state else - parseAlgebraicMove(token, board, history, color) match - case None => state // unrecognised token — skip silently + parseAlgebraicMove(token, ctx, color) match + case None => state case Some(move) => - val newBoard = applyMoveToBoard(board, move, color) - val newHistory = history.addMove(move) - (newBoard, newHistory, color.opposite, acc :+ move) - + val histMove = toHistoryMove(move, ctx, color) + val nextCtx = StandardRules.applyMove(ctx, move) + (nextCtx, color.opposite, acc :+ histMove) moves - /** Apply a single HistoryMove to a Board, handling castling and promotion. */ - private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board = - move.castleSide match - case Some("Kingside") => board.withCastle(color, CastleSide.Kingside) - case Some("Queenside") => board.withCastle(color, CastleSide.Queenside) - case _ => - val (boardAfterMove, _) = board.withMove(move.from, move.to) - move.promotionPiece match - case Some(pp) => - val pieceType = pp match - case PromotionPiece.Queen => PieceType.Queen - case PromotionPiece.Rook => PieceType.Rook - case PromotionPiece.Bishop => PieceType.Bishop - case PromotionPiece.Knight => PieceType.Knight - boardAfterMove.updated(move.to, Piece(color, pieceType)) - case None => boardAfterMove + /** Convert an api.move.Move to a HistoryMove given the board context before the move. */ + private def toHistoryMove(move: Move, ctx: GameContext, color: Color): HistoryMove = + val castleSide = move.moveType match + case MoveType.CastleKingside => Some("Kingside") + case MoveType.CastleQueenside => Some("Queenside") + case _ => None + val promotionPiece = move.moveType match + case MoveType.Promotion(pp) => Some(pp) + case _ => None + val pieceType = ctx.board.pieceAt(move.from).map(_.pieceType).getOrElse(PieceType.Pawn) + val isCapture = ctx.board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant + HistoryMove(move.from, move.to, castleSide, promotionPiece, pieceType, isCapture) /** True for move-number tokens ("1.", "12.") and PGN result tokens. */ private def isMoveNumberOrResult(token: String): Boolean = @@ -85,85 +76,74 @@ object PgnParser: token == "0-1" || token == "1/2-1/2" - /** Parse a single algebraic notation token into a HistoryMove, given the current board state. */ - def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] = + /** Parse a single algebraic notation token into a Move, given the current game context. */ + def parseAlgebraicMove(notation: String, ctx: GameContext, color: Color): Option[Move] = notation match case "O-O" | "O-O+" | "O-O#" => val rank = if color == Color.White then Rank.R1 else Rank.R8 - Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some("Kingside"), pieceType = PieceType.King)) + val move = Move(Square(File.E, rank), Square(File.G, rank), MoveType.CastleKingside) + Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) case "O-O-O" | "O-O-O+" | "O-O-O#" => val rank = if color == Color.White then Rank.R1 else Rank.R8 - Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some("Queenside"), pieceType = PieceType.King)) + val move = Move(Square(File.E, rank), Square(File.C, rank), MoveType.CastleQueenside) + Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) case _ => - parseRegularMove(notation, board, history, color) + parseRegularMove(notation, ctx, color) /** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */ - private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] = - // Strip check/mate/capture indicators and promotion suffix (e.g. =Q) + private def parseRegularMove(notation: String, ctx: GameContext, color: Color): Option[Move] = val clean = notation .replace("+", "") .replace("#", "") .replace("x", "") .replaceAll("=[NBRQ]$", "") - // The destination square is always the last two characters if clean.length < 2 then None else val destStr = clean.takeRight(2) Square.fromAlgebraic(destStr).flatMap: toSquare => - val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank + val disambig = clean.dropRight(2) - // Determine required piece type: upper-case first char = piece letter; else pawn val requiredPieceType: Option[PieceType] = if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head) else if clean.head.isUpper then charToPieceType(clean.head) else Some(PieceType.Pawn) - // Collect the disambiguation hint that remains after stripping the piece letter val hint = if disambig.nonEmpty && disambig.head.isUpper then disambig.tail - else disambig // hint is file/rank info or empty + else disambig - // Candidate source squares: pieces of `color` that can geometrically reach `toSquare`. - // We prefer pieces that can actually reach the target; if none can (positionally illegal - // PGN input), fall back to any piece of the matching type belonging to `color`. - val reachable: Set[Square] = - board.pieces.collect { - case (from, piece) if piece.color == color && - MoveValidator.legalTargets(board, from).contains(toSquare) => from - }.toSet + val promotion = extractPromotion(notation) - val candidates: Set[Square] = - if reachable.nonEmpty then reachable - else - // Fallback for positionally-illegal but syntactically valid PGN notation: - // find any piece of `color` with the correct piece type on the board. - board.pieces.collect { - case (from, piece) if piece.color == color => from - }.toSet + // Get all legal moves for this color that reach toSquare + val allLegal = StandardRules.allLegalMoves(ctx) + val candidates = allLegal.filter { move => + move.to == toSquare && + ctx.board.pieceAt(move.from).exists(p => + p.color == color && + requiredPieceType.forall(_ == p.pieceType) + ) && + (hint.isEmpty || matchesHint(move.from, hint)) && + promotionMatches(move, promotion) + } - // Filter by required piece type - val byPiece = candidates.filter(from => - requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt)) - ) - - // Apply disambiguation hint (file letter or rank digit) - val disambiguated = - if hint.isEmpty then byPiece - else byPiece.filter(from => matchesHint(from, hint)) - - val promotion = extractPromotion(notation) - val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn) - val moveIsCapture = notation.contains('x') - disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture)) + candidates.headOption /** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */ private def matchesHint(sq: Square, hint: String): Boolean = - hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) - else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1') - else true) + hint.forall(c => + if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) + else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1') + else true + ) + + private def promotionMatches(move: Move, promotion: Option[PromotionPiece]): Boolean = + promotion match + case None => move.moveType == MoveType.Normal || move.moveType == MoveType.EnPassant || + move.moveType == MoveType.CastleKingside || move.moveType == MoveType.CastleQueenside + case Some(pp) => move.moveType == MoveType.Promotion(pp) /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */ private[notation] def extractPromotion(notation: String): Option[PromotionPiece] = @@ -192,77 +172,16 @@ object PgnParser: /** Walk all move tokens, failing immediately on any unresolvable or illegal move. */ private def validateMovesText(moveText: String): Either[String, List[HistoryMove]] = val tokens = moveText.split("\\s+").filter(_.nonEmpty) - tokens.foldLeft(Right((Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])): Either[String, (Board, GameHistory, Color, List[HistoryMove])]) { + tokens.foldLeft(Right((GameContext.initial, Color.White, List.empty[HistoryMove])): Either[String, (GameContext, Color, List[HistoryMove])]) { case (acc, token) => - acc.flatMap { case (board, history, color, moves) => - if isMoveNumberOrResult(token) then Right((board, history, color, moves)) + acc.flatMap { case (ctx, color, moves) => + if isMoveNumberOrResult(token) then Right((ctx, color, moves)) else - strictParseAlgebraicMove(token, board, history, color) match + parseAlgebraicMove(token, ctx, color) match case None => Left(s"Illegal or impossible move: '$token'") case Some(move) => - val newBoard = applyMoveToBoard(board, move, color) - val newHistory = history.addMove(move) - Right((newBoard, newHistory, color.opposite, moves :+ move)) + val histMove = toHistoryMove(move, ctx, color) + val nextCtx = StandardRules.applyMove(ctx, move) + Right((nextCtx, color.opposite, moves :+ histMove)) } - }.map(_._4) - - /** Strict algebraic move parse — no fallback to positionally-illegal moves. */ - private def strictParseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] = - val rank = if color == Color.White then Rank.R1 else Rank.R8 - notation match - case "O-O" | "O-O+" | "O-O#" => - val dest = Square(File.G, rank) - Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))( - HistoryMove(Square(File.E, rank), dest, Some("Kingside"), pieceType = PieceType.King) - ) - case "O-O-O" | "O-O-O+" | "O-O-O#" => - val dest = Square(File.C, rank) - Option.when(MoveValidator.castlingTargets(board, history, color).contains(dest))( - HistoryMove(Square(File.E, rank), dest, Some("Queenside"), pieceType = PieceType.King) - ) - case _ => - strictParseRegularMove(notation, board, history, color) - - /** Strict regular move parse — uses only legally reachable squares, no fallback. */ - private def strictParseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] = - val clean = notation - .replace("+", "") - .replace("#", "") - .replace("x", "") - .replaceAll("=[NBRQ]$", "") - - if clean.length < 2 then None - else - val destStr = clean.takeRight(2) - Square.fromAlgebraic(destStr).flatMap { toSquare => - val disambig = clean.dropRight(2) - - val requiredPieceType: Option[PieceType] = - if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head) - else if clean.head.isUpper then charToPieceType(clean.head) - else Some(PieceType.Pawn) - - val hint = - if disambig.nonEmpty && disambig.head.isUpper then disambig.tail - else disambig - - // Strict: only squares from which a legal move (including en passant/castling awareness) exists. - val reachable: Set[Square] = - board.pieces.collect { - case (from, piece) if piece.color == color && - MoveValidator.legalTargets(board, history, from).contains(toSquare) => from - }.toSet - - val byPiece = reachable.filter(from => - requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt)) - ) - - val disambiguated = - if hint.isEmpty then byPiece - else byPiece.filter(from => matchesHint(from, hint)) - - val promotion = extractPromotion(notation) - val movePieceType = requiredPieceType.getOrElse(PieceType.Pawn) - val moveIsCapture = notation.contains('x') - disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion, movePieceType, moveIsCapture)) - } + }.map(_._3) diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala index b8ab9a4..5685c87 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala @@ -1,38 +1,32 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, File, Rank, Board, Color} -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers class CommandInvokerBranchTest extends AnyFunSuite with Matchers: - + private def sq(f: File, r: Rank): Square = Square(f, r) - // ──── Helper: Command that always fails ──── private case class FailingCommand() extends Command: override def execute(): Boolean = false override def undo(): Boolean = false override def description: String = "Failing command" - // ──── Helper: Command that conditionally fails on undo or execute ──── private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command: override def execute(): Boolean = !shouldFailOnExecute override def undo(): Boolean = !shouldFailOnUndo override def description: String = "Conditional fail" private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand = - val cmd = MoveCommand( + MoveCommand( from = from, to = to, - moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None, - previousBoard = Some(Board.initial), - previousHistory = Some(GameHistory.empty), - previousTurn = Some(Color.White) + moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None, + previousContext = Some(GameContext.initial) ) - cmd - // ──── BRANCH: execute() returns false ──── test("CommandInvoker.execute() with failing command returns false"): val invoker = new CommandInvoker() val cmd = FailingCommand() @@ -44,18 +38,14 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: val invoker = new CommandInvoker() val failingCmd = FailingCommand() val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - invoker.execute(failingCmd) shouldBe false invoker.history.size shouldBe 0 - invoker.execute(successCmd) shouldBe true invoker.history.size shouldBe 1 invoker.history(0) shouldBe successCmd - // ──── BRANCH: undo() with invalid index (currentIndex < 0) ──── test("CommandInvoker.undo() returns false when currentIndex < 0"): val invoker = new CommandInvoker() - // currentIndex starts at -1 invoker.undo() shouldBe false test("CommandInvoker.undo() returns false when empty history"): @@ -63,41 +53,31 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: invoker.canUndo shouldBe false invoker.undo() shouldBe false - // ──── BRANCH: undo() with invalid index (currentIndex >= size) ──── test("CommandInvoker.undo() returns false when currentIndex >= history size"): val invoker = new CommandInvoker() val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) - invoker.execute(cmd1) invoker.execute(cmd2) - // currentIndex now = 1, history.size = 2 - - invoker.undo() // currentIndex becomes 0 - invoker.undo() // currentIndex becomes -1 - invoker.undo() // currentIndex still -1, should fail + invoker.undo() + invoker.undo() + invoker.undo() - // ──── BRANCH: undo() command returns false ──── test("CommandInvoker.undo() returns false when command.undo() fails"): val invoker = new CommandInvoker() val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true) - invoker.execute(failingCmd) shouldBe true invoker.canUndo shouldBe true - invoker.undo() shouldBe false - // Index should not change when undo fails invoker.getCurrentIndex shouldBe 0 test("CommandInvoker.undo() returns true when command.undo() succeeds"): val invoker = new CommandInvoker() val successCmd = ConditionalFailCommand(shouldFailOnUndo = false) - invoker.execute(successCmd) shouldBe true invoker.undo() shouldBe true invoker.getCurrentIndex shouldBe -1 - // ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ──── test("CommandInvoker.redo() returns false when nothing to redo"): val invoker = new CommandInvoker() invoker.redo() shouldBe false @@ -105,9 +85,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: test("CommandInvoker.redo() returns false when at end of history"): val invoker = new CommandInvoker() val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - invoker.execute(cmd) - // currentIndex = 0, history.size = 1 invoker.canRedo shouldBe false invoker.redo() shouldBe false @@ -115,59 +93,41 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: val invoker = new CommandInvoker() val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) - invoker.execute(cmd1) invoker.execute(cmd2) - // currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false invoker.canRedo shouldBe false invoker.redo() shouldBe false - // ──── BRANCH: redo() command returns false ──── test("CommandInvoker.redo() returns false when command.execute() fails"): val invoker = new CommandInvoker() val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute - + val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) invoker.execute(cmd1) - invoker.execute(redoFailCmd) // Succeeds and added to history - + invoker.execute(redoFailCmd) invoker.undo() - // currentIndex = 0, redoFailCmd is at index 1 invoker.canRedo shouldBe true - - // Now modify to fail on next execute (redo) redoFailCmd.shouldFailOnExecute = true invoker.redo() shouldBe false - // currentIndex should not change invoker.getCurrentIndex shouldBe 0 test("CommandInvoker.redo() returns true when command.execute() succeeds"): val invoker = new CommandInvoker() val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - invoker.execute(cmd) shouldBe true invoker.undo() shouldBe true invoker.redo() shouldBe true invoker.getCurrentIndex shouldBe 0 - // ──── BRANCH: execute() with redo history discarding (while loop) ──── test("CommandInvoker.execute() discards redo history via while loop"): val invoker = new CommandInvoker() val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) - invoker.execute(cmd1) invoker.execute(cmd2) - // currentIndex = 1, size = 2 - invoker.undo() - // currentIndex = 0, size = 2 - // Redo history exists: cmd2 is at index 1 invoker.canRedo shouldBe true - invoker.execute(cmd3) - // while loop should discard cmd2 invoker.canRedo shouldBe false invoker.history.size shouldBe 2 invoker.history(1) shouldBe cmd3 @@ -178,39 +138,25 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers: val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) - invoker.execute(cmd1) invoker.execute(cmd2) invoker.execute(cmd3) invoker.execute(cmd4) - // currentIndex = 3, size = 4 - invoker.undo() invoker.undo() - // currentIndex = 1, size = 4 - // Redo history: cmd3 (idx 2), cmd4 (idx 3) invoker.canRedo shouldBe true - val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4)) invoker.execute(newCmd) - // While loop should discard indices 2 and 3 (cmd3 and cmd4) invoker.history.size shouldBe 3 invoker.canRedo shouldBe false - // ──── BRANCH: execute() with no redo history to discard ──── test("CommandInvoker.execute() with no redo history (while condition false)"): val invoker = new CommandInvoker() val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) - invoker.execute(cmd1) invoker.execute(cmd2) - // currentIndex = 1, size = 2 - // currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run - invoker.canRedo shouldBe false - val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) - invoker.execute(cmd3) // While loop condition should be false, no iterations + invoker.execute(cmd3) invoker.history.size shouldBe 3 - diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala index 2e06aac..7beca2d 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala @@ -1,22 +1,20 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, File, Rank, Board, Color} -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers class CommandInvokerTest extends AnyFunSuite with Matchers: private def sq(f: File, r: Rank): Square = Square(f, r) - + private def createMoveCommand(from: Square, to: Square): MoveCommand = MoveCommand( from = from, to = to, - moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), - previousBoard = Some(Board.initial), - previousHistory = Some(GameHistory.empty), - previousTurn = Some(Color.White) + moveResult = Some(MoveResult.Successful(GameContext.initial, None)), + previousContext = Some(GameContext.initial) ) test("CommandInvoker executes a command and adds it to history"): @@ -92,10 +90,8 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: invoker.execute(cmd2) invoker.undo() invoker.undo() - // After undoing twice, we're at the beginning (before any commands) invoker.getCurrentIndex shouldBe -1 invoker.canRedo shouldBe true - // Executing a new command from the beginning discards all redo history invoker.execute(cmd3) invoker.canRedo shouldBe false invoker.history.size shouldBe 1 @@ -110,14 +106,11 @@ class CommandInvokerTest extends AnyFunSuite with Matchers: invoker.execute(cmd1) invoker.execute(cmd2) invoker.undo() - // After one undo, we're at the end of cmd1 invoker.getCurrentIndex shouldBe 0 invoker.canRedo shouldBe true - // Executing a new command discards cmd2 (the redo history) invoker.execute(cmd3) invoker.canRedo shouldBe false invoker.history.size shouldBe 2 invoker.history(0) shouldBe cmd1 invoker.history(1) shouldBe cmd3 invoker.getCurrentIndex shouldBe 1 - diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerThreadSafetyTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerThreadSafetyTest.scala index 8b6215d..a3970d3 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerThreadSafetyTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerThreadSafetyTest.scala @@ -1,7 +1,7 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, File, Rank, Board, Color} -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import scala.collection.mutable @@ -14,10 +14,8 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers: MoveCommand( from = from, to = to, - moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), - previousBoard = Some(Board.initial), - previousHistory = Some(GameHistory.empty), - previousTurn = Some(Color.White) + moveResult = Some(MoveResult.Successful(GameContext.initial, None)), + previousContext = Some(GameContext.initial) ) test("CommandInvoker is thread-safe for concurrent execute and history reads"): @@ -25,15 +23,11 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers: @volatile var raceDetected = false val exceptions = mutable.ListBuffer[Exception]() - // Thread 1: executes commands val executorThread = new Thread(new Runnable { def run(): Unit = { try { for i <- 1 to 1000 do - val cmd = createMoveCommand( - sq(File.E, Rank.R2), - sq(File.E, Rank.R4) - ) + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.execute(cmd) } catch { case e: Exception => @@ -43,14 +37,13 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers: } }) - // Thread 2: reads history during execution val readerThread = new Thread(new Runnable { def run(): Unit = { try { for _ <- 1 to 1000 do val _ = invoker.history val _ = invoker.getCurrentIndex - Thread.sleep(0) // Yield to increase contention + Thread.sleep(0) } catch { case e: Exception => exceptions += e @@ -72,11 +65,9 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers: @volatile var raceDetected = false val exceptions = mutable.ListBuffer[Exception]() - // Pre-populate with some commands for _ <- 1 to 5 do invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))) - // Thread 1: executes new commands val executorThread = new Thread(new Runnable { def run(): Unit = { try { @@ -90,13 +81,11 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers: } }) - // Thread 2: undoes commands val undoThread = new Thread(new Runnable { def run(): Unit = { try { for _ <- 1 to 500 do - if invoker.canUndo then - invoker.undo() + if invoker.canUndo then invoker.undo() } catch { case e: Exception => exceptions += e @@ -105,13 +94,11 @@ class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers: } }) - // Thread 3: redoes commands val redoThread = new Thread(new Runnable { def run(): Unit = { try { for _ <- 1 to 500 do - if invoker.canRedo then - invoker.redo() + if invoker.canRedo then invoker.redo() } catch { case e: Exception => exceptions += e diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala index ca71cdc..5e23234 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala @@ -1,7 +1,6 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -29,24 +28,15 @@ class CommandTest extends AnyFunSuite with Matchers: cmd.undo() shouldBe false test("ResetCommand with prior state can undo"): - val cmd = ResetCommand( - previousBoard = Some(Board.initial), - previousHistory = Some(GameHistory.empty), - previousTurn = Some(Color.White) - ) + val cmd = ResetCommand(previousContext = Some(GameContext.initial)) cmd.execute() shouldBe true cmd.undo() shouldBe true - test("ResetCommand with partial state cannot undo"): - val cmd = ResetCommand( - previousBoard = Some(Board.initial), - previousHistory = None, // missing - previousTurn = Some(Color.White) - ) + test("ResetCommand with no context cannot undo"): + val cmd = ResetCommand(previousContext = None) cmd.execute() shouldBe true cmd.undo() shouldBe false test("ResetCommand description"): val cmd = ResetCommand() cmd.description shouldBe "Reset board" - diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandDefaultsTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandDefaultsTest.scala new file mode 100644 index 0000000..cd33940 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandDefaultsTest.scala @@ -0,0 +1,54 @@ +package de.nowchess.chess.command + +import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.game.GameContext +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class MoveCommandDefaultsTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + + test("MoveCommand with no moveResult defaults to None"): + val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4)) + cmd.moveResult shouldBe None + cmd.execute() shouldBe false + + test("MoveCommand with no previousContext defaults to None"): + val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4)) + cmd.previousContext shouldBe None + cmd.undo() shouldBe false + + test("MoveCommand description is always returned"): + val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4)) + cmd.description shouldBe "Move from e2 to e4" + + test("MoveCommand execute returns false when moveResult is None"): + val cmd = MoveCommand(from = sq(File.A, Rank.R1), to = sq(File.B, Rank.R3)) + cmd.execute() shouldBe false + + test("MoveCommand undo returns false when previousContext is None"): + val cmd = MoveCommand( + from = sq(File.E, Rank.R2), + to = sq(File.E, Rank.R4), + moveResult = Some(MoveResult.Successful(GameContext.initial, None)), + previousContext = None + ) + cmd.undo() shouldBe false + + test("MoveCommand execute returns true when moveResult is defined"): + val cmd = MoveCommand( + from = sq(File.E, Rank.R2), + to = sq(File.E, Rank.R4), + moveResult = Some(MoveResult.Successful(GameContext.initial, None)) + ) + cmd.execute() shouldBe true + + test("MoveCommand undo returns true when previousContext is defined"): + val cmd = MoveCommand( + from = sq(File.E, Rank.R2), + to = sq(File.E, Rank.R4), + moveResult = Some(MoveResult.Successful(GameContext.initial, None)), + previousContext = Some(GameContext.initial) + ) + cmd.undo() shouldBe true diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala index b23350a..f182489 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala @@ -1,7 +1,7 @@ package de.nowchess.chess.command -import de.nowchess.api.board.{Square, File, Rank, Board, Color} -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.board.{Square, File, Rank} +import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -10,56 +10,38 @@ class MoveCommandImmutabilityTest extends AnyFunSuite with Matchers: private def sq(f: File, r: Rank): Square = Square(f, r) test("MoveCommand should be immutable - fields cannot be mutated after creation"): - val cmd1 = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4) - ) + val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4)) - // Create second command with filled state - val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None) + val result = MoveResult.Successful(GameContext.initial, None) val cmd2 = cmd1.copy( moveResult = Some(result), - previousBoard = Some(Board.initial), - previousHistory = Some(GameHistory.empty), - previousTurn = Some(Color.White) + previousContext = Some(GameContext.initial) ) - // Original should be unchanged cmd1.moveResult shouldBe None - cmd1.previousBoard shouldBe None - cmd1.previousHistory shouldBe None - cmd1.previousTurn shouldBe None + cmd1.previousContext shouldBe None - // New should have values cmd2.moveResult shouldBe Some(result) - cmd2.previousBoard shouldBe Some(Board.initial) - cmd2.previousHistory shouldBe Some(GameHistory.empty) - cmd2.previousTurn shouldBe Some(Color.White) + cmd2.previousContext shouldBe Some(GameContext.initial) test("MoveCommand equals and hashCode respect immutability"): val cmd1 = MoveCommand( from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4), moveResult = None, - previousBoard = None, - previousHistory = None, - previousTurn = None + previousContext = None ) val cmd2 = MoveCommand( from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4), moveResult = None, - previousBoard = None, - previousHistory = None, - previousTurn = None + previousContext = None ) - // Same values should be equal cmd1 shouldBe cmd2 cmd1.hashCode shouldBe cmd2.hashCode - // Hash should be consistent (required for use as map keys) val hash1 = cmd1.hashCode val hash2 = cmd1.hashCode hash1 shouldBe hash2 diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala deleted file mode 100644 index 3ec0330..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala +++ /dev/null @@ -1,526 +0,0 @@ -package de.nowchess.chess.controller - -import de.nowchess.api.board.* -import de.nowchess.api.game.CastlingRights -import de.nowchess.api.move.PromotionPiece -import de.nowchess.chess.logic.{CastleSide, GameHistory} -import de.nowchess.chess.notation.FenParser -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class GameControllerTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = - GameController.processMove(board, history, turn, raw) - - private def castlingRights(history: GameHistory, color: Color): CastlingRights = - de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color) - - // ──── processMove ──────────────────────────────────────────────────── - - test("processMove: 'quit' input returns Quit"): - processMove(Board.initial, GameHistory.empty, Color.White, "quit") shouldBe MoveResult.Quit - - test("processMove: 'q' input returns Quit"): - processMove(Board.initial, GameHistory.empty, Color.White, "q") shouldBe MoveResult.Quit - - test("processMove: quit with surrounding whitespace returns Quit"): - processMove(Board.initial, GameHistory.empty, Color.White, " quit ") shouldBe MoveResult.Quit - - test("processMove: unparseable input returns InvalidFormat"): - processMove(Board.initial, GameHistory.empty, Color.White, "xyz") shouldBe MoveResult.InvalidFormat("xyz") - - test("processMove: valid format but empty square returns NoPiece"): - // E3 is empty in the initial position - processMove(Board.initial, GameHistory.empty, Color.White, "e3e4") shouldBe MoveResult.NoPiece - - test("processMove: piece of wrong color returns WrongColor"): - // E7 has a Black pawn; it is White's turn - processMove(Board.initial, GameHistory.empty, Color.White, "e7e6") shouldBe MoveResult.WrongColor - - test("processMove: geometrically illegal move returns IllegalMove"): - // White pawn at E2 cannot jump three squares to E5 - processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove - - test("processMove: move that leaves own king in check returns IllegalMove"): - // White King E1 is in check from Black Rook E8. Moving the D2 pawn is - // geometrically legal but does not resolve the check — must be rejected. - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.D, Rank.R2) -> Piece.WhitePawn, - sq(File.E, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove - - test("processMove: move that resolves check is allowed"): - // White King E1 is in check from Black Rook E8 along the E-file. - // White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8. - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R5) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "a5e5") match - case _: MoveResult.Moved => succeed - case other => fail(s"Expected Moved, got $other") - - test("processMove: legal pawn move returns Moved with updated board and flipped turn"): - processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => - newBoard.pieceAt(sq(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) - newBoard.pieceAt(sq(File.E, Rank.R2)) shouldBe None - captured shouldBe None - newTurn shouldBe Color.Black - case other => fail(s"Expected Moved, got $other") - - test("processMove: legal capture returns Moved with the captured piece"): - val board = Board(Map( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R6) -> Piece.BlackPawn, - sq(File.H, Rank.R1) -> Piece.BlackKing, - sq(File.H, Rank.R8) -> Piece.WhiteKing - )) - processMove(board, GameHistory.empty, Color.White, "e5d6") match - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => - captured shouldBe Some(Piece.BlackPawn) - newBoard.pieceAt(sq(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) - newTurn shouldBe Color.Black - case other => fail(s"Expected Moved, got $other") - - // ──── processMove: check / checkmate / stalemate ───────────────────── - - test("processMove: legal move that delivers check returns MovedInCheck"): - // White Ra1, Ka3; Black Kh8 — White plays Ra1-Ra8, Ra8 attacks rank 8 putting Kh8 in check - // Kh8 can escape to g7/g8/h7 so this is InCheck, not Mated - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.C, Rank.R3) -> Piece.WhiteKing, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "a1a8") match - case MoveResult.MovedInCheck(_, _, _, newTurn) => newTurn shouldBe Color.Black - case other => fail(s"Expected MovedInCheck, got $other") - - test("processMove: legal move that results in checkmate returns Checkmate"): - // White Qa1, Ka6; Black Ka8 — White plays Qa1-Qh8 (diagonal a1→h8) - // After Qh8: White Qh8 + Ka6 vs Black Ka8 = checkmate (spec-verified position) - // Qa1 does NOT currently attack Ka8 — path along file A is blocked by Ka6 - val b = Board(Map( - sq(File.A, Rank.R1) -> Piece.WhiteQueen, - sq(File.A, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "a1h8") match - case MoveResult.Checkmate(winner) => winner shouldBe Color.White - case other => fail(s"Expected Checkmate(White), got $other") - - test("processMove: legal move that results in stalemate returns Stalemate"): - // White Qb1, Kc6; Black Ka8 — White plays Qb1-Qb6 - // After Qb6: White Qb6 + Kc6 vs Black Ka8 = stalemate (spec-verified position) - val b = Board(Map( - sq(File.B, Rank.R1) -> Piece.WhiteQueen, - sq(File.C, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "b1b6") match - case MoveResult.Stalemate => succeed - case other => fail(s"Expected Stalemate, got $other") - - // ──── castling execution ───────────────────────────────────────────── - - test("processMove: e1g1 returns Moved with king on g1 and rook on f1"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "e1g1") match - case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => - newBoard.pieceAt(sq(File.G, Rank.R1)) shouldBe Some(Piece.WhiteKing) - newBoard.pieceAt(sq(File.F, Rank.R1)) shouldBe Some(Piece.WhiteRook) - newBoard.pieceAt(sq(File.E, Rank.R1)) shouldBe None - newBoard.pieceAt(sq(File.H, Rank.R1)) shouldBe None - captured shouldBe None - newTurn shouldBe Color.Black - case other => fail(s"Expected Moved, got $other") - - test("processMove: e1c1 returns Moved with king on c1 and rook on d1"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "e1c1") match - case MoveResult.Moved(newBoard, _, _, _) => - newBoard.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) - newBoard.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) - case other => fail(s"Expected Moved, got $other") - - // ──── rights revocation ────────────────────────────────────────────── - - test("processMove: e1g1 revokes both white castling rights"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "e1g1") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.White) shouldBe CastlingRights.None - case other => fail(s"Expected Moved, got $other") - - test("processMove: moving rook from h1 revokes white kingside right"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "h1h4") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.White).kingSide shouldBe false - castlingRights(newHistory, Color.White).queenSide shouldBe true - case MoveResult.MovedInCheck(_, newHistory, _, _) => - castlingRights(newHistory, Color.White).kingSide shouldBe false - castlingRights(newHistory, Color.White).queenSide shouldBe true - case other => fail(s"Expected Moved or MovedInCheck, got $other") - - test("processMove: moving king from e1 revokes both white rights"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "e1e2") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.White) shouldBe CastlingRights.None - case other => fail(s"Expected Moved, got $other") - - test("processMove: enemy capture on h1 revokes white kingside right"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R2) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.Black, "h2h1") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.White).kingSide shouldBe false - case MoveResult.MovedInCheck(_, newHistory, _, _) => - castlingRights(newHistory, Color.White).kingSide shouldBe false - case other => fail(s"Expected Moved or MovedInCheck, got $other") - - test("processMove: castle attempt when rights revoked returns IllegalMove"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)).addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R1)) - processMove(b, history, Color.White, "e1g1") shouldBe MoveResult.IllegalMove - - test("processMove: castle attempt when rook not on home square returns IllegalMove"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.G, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.White, "e1g1") shouldBe MoveResult.IllegalMove - - test("processMove: moving king from e8 revokes both black rights"): - val b = Board(Map( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.H, Rank.R1) -> Piece.WhiteKing - )) - processMove(b, GameHistory.empty, Color.Black, "e8e7") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None - case MoveResult.MovedInCheck(_, newHistory, _, _) => - castlingRights(newHistory, Color.Black) shouldBe CastlingRights.None - case other => fail(s"Expected Moved or MovedInCheck, got $other") - - test("processMove: moving rook from a8 revokes black queenside right"): - val b = Board(Map( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.A, Rank.R8) -> Piece.BlackRook, - sq(File.H, Rank.R1) -> Piece.WhiteKing - )) - processMove(b, GameHistory.empty, Color.Black, "a8a1") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.Black).queenSide shouldBe false - castlingRights(newHistory, Color.Black).kingSide shouldBe true - case MoveResult.MovedInCheck(_, newHistory, _, _) => - castlingRights(newHistory, Color.Black).queenSide shouldBe false - castlingRights(newHistory, Color.Black).kingSide shouldBe true - case other => fail(s"Expected Moved or MovedInCheck, got $other") - - test("processMove: moving rook from h8 revokes black kingside right"): - val b = Board(Map( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.H, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R1) -> Piece.WhiteKing - )) - processMove(b, GameHistory.empty, Color.Black, "h8h4") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.Black).kingSide shouldBe false - castlingRights(newHistory, Color.Black).queenSide shouldBe true - case MoveResult.MovedInCheck(_, newHistory, _, _) => - castlingRights(newHistory, Color.Black).kingSide shouldBe false - castlingRights(newHistory, Color.Black).queenSide shouldBe true - case other => fail(s"Expected Moved or MovedInCheck, got $other") - - test("processMove: enemy capture on a1 revokes white queenside right"): - val b = Board(Map( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.A, Rank.R2) -> Piece.BlackRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - )) - processMove(b, GameHistory.empty, Color.Black, "a2a1") match - case MoveResult.Moved(_, newHistory, _, _) => - castlingRights(newHistory, Color.White).queenSide shouldBe false - case MoveResult.MovedInCheck(_, newHistory, _, _) => - castlingRights(newHistory, Color.White).queenSide shouldBe false - case other => fail(s"Expected Moved or MovedInCheck, got $other") - - // ──── en passant ──────────────────────────────────────────────────────── - - test("en passant capture removes the captured pawn from the board"): - // Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6) - val b = Board(Map( - Square(File.E, Rank.R5) -> Piece.WhitePawn, - Square(File.D, Rank.R5) -> Piece.BlackPawn, - Square(File.E, Rank.R1) -> Piece.WhiteKing, - Square(File.E, Rank.R8) -> Piece.BlackKing - )) - val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5)) - val result = GameController.processMove(b, h, Color.White, "e5d6") - result match - case MoveResult.Moved(newBoard, _, captured, _) => - newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed - newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed - captured shouldBe Some(Piece.BlackPawn) - case other => fail(s"Expected Moved but got $other") - - test("en passant capture by black removes the captured white pawn"): - // Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3) - val b = Board(Map( - Square(File.D, Rank.R4) -> Piece.BlackPawn, - Square(File.E, Rank.R4) -> Piece.WhitePawn, - Square(File.E, Rank.R8) -> Piece.BlackKing, - Square(File.E, Rank.R1) -> Piece.WhiteKing - )) - val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4)) - val result = GameController.processMove(b, h, Color.Black, "d4e3") - result match - case MoveResult.Moved(newBoard, _, captured, _) => - newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed - newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed - captured shouldBe Some(Piece.WhitePawn) - case other => fail(s"Expected Moved but got $other") - - // ──── pawn promotion detection ─────────────────────────────────────────── - - test("processMove detects white pawn reaching R8 and returns PromotionRequired"): - val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") - result should matchPattern { case _: MoveResult.PromotionRequired => } - result match - case MoveResult.PromotionRequired(from, to, _, _, _, turn) => - from should be (sq(File.E, Rank.R7)) - to should be (sq(File.E, Rank.R8)) - turn should be (Color.White) - case _ => fail("Expected PromotionRequired") - - test("processMove detects black pawn reaching R1 and returns PromotionRequired"): - val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get - val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1") - result should matchPattern { case _: MoveResult.PromotionRequired => } - result match - case MoveResult.PromotionRequired(from, to, _, _, _, turn) => - from should be (sq(File.E, Rank.R2)) - to should be (sq(File.E, Rank.R1)) - turn should be (Color.Black) - case _ => fail("Expected PromotionRequired") - - test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"): - val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get - val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8") - result should matchPattern { case _: MoveResult.PromotionRequired => } - result match - case MoveResult.PromotionRequired(_, _, _, _, captured, _) => - captured should be (Some(Piece(Color.Black, PieceType.Queen))) - case _ => fail("Expected PromotionRequired") - - // ──── completePromotion ────────────────────────────────────────────────── - - test("completePromotion applies move and places queen"): - // Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals) - val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.E, Rank.R7), sq(File.E, Rank.R8), - PromotionPiece.Queen, Color.White - ) - result should matchPattern { case _: MoveResult.Moved => } - result match - case MoveResult.Moved(newBoard, newHistory, _, _) => - newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) - newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None) - newHistory.moves should have length 1 - newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) - case _ => fail("Expected Moved") - - test("completePromotion with rook underpromotion"): - // Black king on h1: not attacked by rook on e8 (different file and rank) - val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.E, Rank.R7), sq(File.E, Rank.R8), - PromotionPiece.Rook, Color.White - ) - result match - case MoveResult.Moved(newBoard, newHistory, _, _) => - newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook))) - newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook)) - case _ => fail("Expected Moved with Rook") - - test("completePromotion with bishop underpromotion"): - val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.E, Rank.R7), sq(File.E, Rank.R8), - PromotionPiece.Bishop, Color.White - ) - result match - case MoveResult.Moved(newBoard, newHistory, _, _) => - newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop))) - newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop)) - case _ => fail("Expected Moved with Bishop") - - test("completePromotion with knight underpromotion"): - val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.E, Rank.R7), sq(File.E, Rank.R8), - PromotionPiece.Knight, Color.White - ) - result match - case MoveResult.Moved(newBoard, newHistory, _, _) => - newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight))) - newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight)) - case _ => fail("Expected Moved with Knight") - - test("completePromotion captures opponent piece"): - // Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1) - val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.E, Rank.R7), sq(File.D, Rank.R8), - PromotionPiece.Queen, Color.White - ) - result match - case MoveResult.Moved(newBoard, _, captured, _) => - newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) - captured should be (Some(Piece(Color.Black, PieceType.Queen))) - case _ => fail("Expected Moved with captured piece") - - test("completePromotion for black pawn to R1"): - val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.E, Rank.R2), sq(File.E, Rank.R1), - PromotionPiece.Knight, Color.Black - ) - result match - case MoveResult.Moved(newBoard, newHistory, _, _) => - newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight))) - newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight)) - case _ => fail("Expected Moved") - - test("completePromotion evaluates check after promotion"): - val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.E, Rank.R7), sq(File.E, Rank.R8), - PromotionPiece.Queen, Color.White - ) - result should matchPattern { case _: MoveResult.MovedInCheck => } - - test("completePromotion full round-trip via processMove then completePromotion"): - // Black king on h1: not attacked by queen on e8 - val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get - GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match - case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) => - val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn) - result should matchPattern { case _: MoveResult.Moved => } - result match - case MoveResult.Moved(finalBoard, finalHistory, _, _) => - finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) - finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) - case _ => fail("Expected Moved") - case _ => fail("Expected PromotionRequired") - - test("completePromotion results in checkmate when promotion delivers checkmate"): - // Black king a8, white pawn h7, white king b6. - // After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check; - // a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape. - val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.H, Rank.R7), sq(File.H, Rank.R8), - PromotionPiece.Queen, Color.White - ) - result should matchPattern { case MoveResult.Checkmate(_) => } - result match - case MoveResult.Checkmate(winner) => winner should be (Color.White) - case _ => fail("Expected Checkmate") - - test("completePromotion results in stalemate when promotion stalemates opponent"): - // Black king a8, white pawn b7, white bishop c7, white king b6. - // After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6; - // b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves. - val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get - val result = GameController.completePromotion( - board, GameHistory.empty, - sq(File.B, Rank.R7), sq(File.B, Rank.R8), - PromotionPiece.Knight, Color.White - ) - result should be (MoveResult.Stalemate) - - // ──── half-move clock propagation ──────────────────────────────────── - - test("processMove: non-pawn non-capture increments halfMoveClock"): - // g1f3 is a knight move — not a pawn, not a capture - processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match - case MoveResult.Moved(_, newHistory, _, _) => - newHistory.halfMoveClock shouldBe 1 - case other => fail(s"Expected Moved, got $other") - - test("processMove: pawn move resets halfMoveClock to 0"): - processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match - case MoveResult.Moved(_, newHistory, _, _) => - newHistory.halfMoveClock shouldBe 0 - case other => fail(s"Expected Moved, got $other") - - test("processMove: capture resets halfMoveClock to 0"): - // White pawn on e5, Black pawn on d6 — exd6 is a capture - val board = Board(Map( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R6) -> Piece.BlackPawn, - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.E, Rank.R8) -> Piece.BlackKing - )) - val history = GameHistory(halfMoveClock = 10) - processMove(board, history, Color.White, "e5d6") match - case MoveResult.Moved(_, newHistory, _, _) => - newHistory.halfMoveClock shouldBe 0 - case other => fail(s"Expected Moved, got $other") - - test("processMove: clock carries from previous history on non-pawn non-capture"): - val history = GameHistory(halfMoveClock = 5) - processMove(Board.initial, history, Color.White, "g1f3") match - case MoveResult.Moved(_, newHistory, _, _) => - newHistory.halfMoveClock shouldBe 6 - case other => fail(s"Expected Moved, got $other") diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala index 6843e11..053ebbb 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala @@ -2,7 +2,6 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -197,7 +196,6 @@ class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers: // Access from synchronized methods val board = engine.board - val history = engine.history val turn = engine.turn val canUndo = engine.canUndo val canRedo = engine.canRedo diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala index a6132c3..0b67094 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala @@ -2,7 +2,6 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -22,12 +21,11 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: observer.events.clear() engine.processUserInput("d8h4") + + // Verify CheckmateEvent (engine also fires MoveExecutedEvent before CheckmateEvent) + observer.events.last shouldBe a[CheckmateEvent] - // Verify CheckmateEvent - observer.events.size shouldBe 1 - observer.events.head shouldBe a[CheckmateEvent] - - val event = observer.events.head.asInstanceOf[CheckmateEvent] + val event = observer.events.last.asInstanceOf[CheckmateEvent] event.winner shouldBe Color.Black // Board should be reset after checkmate @@ -50,7 +48,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e } checkEvents.size shouldBe 1 - checkEvents.head.turn shouldBe Color.Black // Black is now in check + checkEvents.head.context.turn shouldBe Color.Black // Black is now in check // Shortest known stalemate is 19 moves. Here is a faster one: // e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6 diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineHandleFailedMoveTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineHandleFailedMoveTest.scala index 6401cae..b6673bc 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineHandleFailedMoveTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineHandleFailedMoveTest.scala @@ -2,7 +2,6 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineInvalidMovesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineInvalidMovesTest.scala index 2be9947..be4846a 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineInvalidMovesTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineInvalidMovesTest.scala @@ -2,7 +2,6 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent, MoveExecutedEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala index d156e4c..865ad21 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadPgnTest.scala @@ -2,7 +2,6 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory import de.nowchess.chess.observer.* import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -14,8 +13,6 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: def onGameEvent(event: GameEvent): Unit = events += event def lastEvent: GameEvent = events.last - // ── loadPgn happy path ──────────────────────────────────────────────────── - test("loadPgn: valid PGN returns Right and updates board/history"): val engine = new GameEngine() val pgn = @@ -25,7 +22,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: """ val result = engine.loadPgn(pgn) result shouldBe Right(()) - engine.history.moves.length shouldBe 2 + engine.context.moves.size shouldBe 2 engine.turn shouldBe Color.White test("loadPgn: emits PgnLoadedEvent on success"): @@ -52,7 +49,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: cap.events.clear() engine.undo() cap.events.last shouldBe a[MoveUndoneEvent] - engine.history.moves.length shouldBe 1 + engine.context.moves.size shouldBe 1 test("loadPgn: undo then redo restores position after PGN load"): val engine = new GameEngine() @@ -65,7 +62,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: engine.redo() cap.events.last shouldBe a[MoveRedoneEvent] engine.board shouldBe boardAfterLoad - engine.history.moves.length shouldBe 2 + engine.context.moves.size shouldBe 2 test("loadPgn: longer game loads all moves into command history"): val engine = new GameEngine() @@ -75,18 +72,14 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 """ engine.loadPgn(pgn) shouldBe Right(()) - engine.history.moves.length shouldBe 6 + engine.context.moves.size shouldBe 6 engine.commandHistory.length shouldBe 6 test("loadPgn: invalid PGN returns Left and does not change state"): val engine = new GameEngine() - val initial = engine.board val result = engine.loadPgn("[Event \"T\"]\n\n1. Qd4\n") result.isLeft shouldBe true - // state is reset to initial (reset happens before replay, which fails) - engine.history.moves shouldBe empty - - // ── undo/redo notation events ───────────────────────────────────────────── + engine.context.moves shouldBe empty test("undo emits MoveUndoneEvent with pgnNotation"): val engine = new GameEngine() @@ -98,7 +91,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: cap.events.last shouldBe a[MoveUndoneEvent] val evt = cap.events.last.asInstanceOf[MoveUndoneEvent] evt.pgnNotation should not be empty - evt.pgnNotation shouldBe "e4" // pawn to e4 + evt.pgnNotation shouldBe "e4" test("redo emits MoveRedoneEvent with pgnNotation"): val engine = new GameEngine() @@ -113,17 +106,10 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: evt.pgnNotation should not be empty evt.pgnNotation shouldBe "e4" - test("undo emits MoveUndoneEvent with empty notation when history is empty (after checkmate reset)"): - // Simulate state where canUndo=true but currentHistory is empty (board reset on checkmate). - // We achieve this by examining the branch: provide a MoveCommand with empty history saved. - // The simplest proxy: undo a move that reset history (stalemate/checkmate). We'll - // use a contrived engine state by direct command manipulation — instead, just verify - // that after a normal move-and-undo the notation is present; the empty-history branch - // is exercised internally when gameEnd resets state. We cover it via a castling undo. + test("undo emits MoveUndoneEvent with O-O notation for castling"): val engine = new GameEngine() val cap = new EventCapture() engine.subscribe(cap) - // Play moves that let white castle kingside: e4 e5 Nf3 Nc6 Bc4 Bc5 O-O engine.processUserInput("e2e4") engine.processUserInput("e7e5") engine.processUserInput("g1f3") @@ -140,7 +126,6 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: val engine = new GameEngine() val cap = new EventCapture() engine.subscribe(cap) - // White builds a capture on the a-file: b4, ... a6, b5, ... h6, bxa6 engine.processUserInput("b2b4") engine.processUserInput("a7a6") engine.processUserInput("b4b5") @@ -159,7 +144,6 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers: engine.processUserInput("e2e4") val pgn = "[Event \"T\"]\n\n1. d4 d5\n" engine.loadPgn(pgn) shouldBe Right(()) - // First move should be d4, not e4 - engine.history.moves.head.to shouldBe de.nowchess.api.board.Square( + engine.context.moves.head.to shouldBe de.nowchess.api.board.Square( de.nowchess.api.board.File.D, de.nowchess.api.board.Rank.R4 ) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index c40c392..cf74f7d 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -1,8 +1,8 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} -import de.nowchess.api.move.PromotionPiece -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{MoveType, PromotionPiece} import de.nowchess.chess.notation.FenParser import de.nowchess.chess.observer.* import org.scalatest.funsuite.AnyFunSuite @@ -17,9 +17,12 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e }) events + private def engineWith(board: Board, turn: Color = Color.White): GameEngine = + new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn)) + test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = new GameEngine(initialBoard = promotionBoard) + val engine = engineWith(promotionBoard) val events = captureEvents(engine) engine.processUserInput("e7e8") @@ -30,7 +33,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: test("isPendingPromotion is true after PromotionRequired input") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = new GameEngine(initialBoard = promotionBoard) + val engine = engineWith(promotionBoard) captureEvents(engine) engine.processUserInput("e7e8") @@ -45,7 +48,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: test("completePromotion fires MoveExecutedEvent with promoted piece") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = new GameEngine(initialBoard = promotionBoard) + val engine = engineWith(promotionBoard) val events = captureEvents(engine) engine.processUserInput("e7e8") @@ -54,13 +57,13 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: engine.isPendingPromotion should be (false) engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen))) engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None) - engine.history.moves.head.promotionPiece should be (Some(PromotionPiece.Queen)) + engine.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true) } test("completePromotion with rook underpromotion") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val engine = new GameEngine(initialBoard = promotionBoard) + val engine = engineWith(promotionBoard) captureEvents(engine) engine.processUserInput("e7e8") @@ -81,7 +84,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: test("completePromotion fires CheckDetectedEvent when promotion gives check") { val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get - val engine = new GameEngine(initialBoard = promotionBoard) + val engine = engineWith(promotionBoard) val events = captureEvents(engine) engine.processUserInput("e7e8") @@ -91,9 +94,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } test("completePromotion results in Moved when promotion doesn't give check") { - // White pawn on e7, black king on a2 (far away, not in check after promotion) val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get - val engine = new GameEngine(initialBoard = board) + val engine = engineWith(board) val events = captureEvents(engine) engine.processUserInput("e7e8") @@ -106,10 +108,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } test("completePromotion results in Checkmate when promotion delivers checkmate") { - // Black king on a8, white king on b6, white pawn on h7 - // h7->h8=Q delivers checkmate val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get - val engine = new GameEngine(initialBoard = board) + val engine = engineWith(board) val events = captureEvents(engine) engine.processUserInput("h7h8") @@ -120,10 +120,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } test("completePromotion results in Stalemate when promotion creates stalemate") { - // Black king on a8, white pawn on b7, white bishop on c7, white king on b6 - // b7->b8=N: no check; Ka8 has no legal moves -> stalemate val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get - val engine = new GameEngine(initialBoard = board) + val engine = engineWith(board) val events = captureEvents(engine) engine.processUserInput("b7b8") @@ -134,10 +132,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: } test("completePromotion with black pawn promotion results in Moved") { - // Black pawn e2, white king h3 (not on rank 1 or file e), black king a8 - // e2->e1=Q: queen on e1 does not attack h3 -> normal Moved val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get - val engine = new GameEngine(initialBoard = board, initialTurn = Color.Black) + val engine = engineWith(board, Color.Black) val events = captureEvents(engine) engine.processUserInput("e2e1") @@ -148,20 +144,3 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false) } - - test("completePromotion catch-all fires InvalidMoveEvent for unexpected MoveResult") { - // Inject a function that returns an unexpected MoveResult to hit the catch-all case - val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val stubFn: (de.nowchess.api.board.Board, de.nowchess.chess.logic.GameHistory, Square, Square, PromotionPiece, Color) => de.nowchess.chess.controller.MoveResult = - (_, _, _, _, _, _) => de.nowchess.chess.controller.MoveResult.NoPiece - val engine = new GameEngine(initialBoard = promotionBoard, completePromotionFn = stubFn) - val events = captureEvents(engine) - - engine.processUserInput("e7e8") - engine.isPendingPromotion should be (true) - - engine.completePromotion(PromotionPiece.Queen) - - engine.isPendingPromotion should be (false) - events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true) - } diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala index 2712195..3138f35 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala @@ -2,7 +2,7 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.game.GameContext import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent, MoveUndoneEvent, MoveRedoneEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -12,7 +12,7 @@ class GameEngineTest extends AnyFunSuite with Matchers: test("GameEngine starts with initial board state"): val engine = new GameEngine() engine.board shouldBe Board.initial - engine.history shouldBe GameHistory.empty + engine.context.moves shouldBe empty engine.turn shouldBe Color.White test("GameEngine accepts Observer subscription"): @@ -112,7 +112,6 @@ class GameEngineTest extends AnyFunSuite with Matchers: test("GameEngine undo restores previous state"): val engine = new GameEngine() engine.processUserInput("e2e4") - val boardAfterMove = engine.board engine.undo() engine.board shouldBe Board.initial engine.turn shouldBe Color.White @@ -175,7 +174,6 @@ class GameEngineTest extends AnyFunSuite with Matchers: test("GameEngine undo via processUserInput"): val engine = new GameEngine() engine.processUserInput("e2e4") - val boardAfterMove = engine.board engine.processUserInput("undo") engine.board shouldBe Board.initial @@ -200,15 +198,11 @@ class GameEngineTest extends AnyFunSuite with Matchers: engine.processUserInput("e2e4") engine.processUserInput("e7e5") engine.processUserInput("g1f3") - engine.turn shouldBe Color.Black - engine.undo() engine.turn shouldBe Color.White - engine.undo() engine.turn shouldBe Color.Black - engine.undo() engine.turn shouldBe Color.White engine.board shouldBe Board.initial @@ -218,17 +212,13 @@ class GameEngineTest extends AnyFunSuite with Matchers: engine.processUserInput("e2e4") engine.processUserInput("e7e5") engine.processUserInput("g1f3") - engine.undo() engine.undo() engine.undo() - engine.redo() engine.turn shouldBe Color.Black - engine.redo() engine.turn shouldBe Color.White - engine.redo() engine.turn shouldBe Color.Black @@ -238,17 +228,14 @@ class GameEngineTest extends AnyFunSuite with Matchers: engine.processUserInput("e7e5") engine.undo() engine.canRedo shouldBe true - - engine.processUserInput("e7e6") // Different move + engine.processUserInput("e7e6") engine.canRedo shouldBe false test("GameEngine command history tracking"): val engine = new GameEngine() engine.commandHistory.size shouldBe 0 - engine.processUserInput("e2e4") engine.commandHistory.size shouldBe 1 - engine.processUserInput("e7e5") engine.commandHistory.size shouldBe 2 @@ -258,7 +245,6 @@ class GameEngineTest extends AnyFunSuite with Matchers: engine.subscribe(observer) val initialEvents = observer.events.size engine.processUserInput("quit") - // quit should not produce an event observer.events.size shouldBe initialEvents test("GameEngine quit via q"): @@ -268,7 +254,7 @@ class GameEngineTest extends AnyFunSuite with Matchers: val initialEvents = observer.events.size engine.processUserInput("q") observer.events.size shouldBe initialEvents - + test("GameEngine undo notifies with MoveUndoneEvent after successful undo"): val engine = new GameEngine() engine.processUserInput("e2e4") @@ -276,10 +262,7 @@ class GameEngineTest extends AnyFunSuite with Matchers: val observer = new MockObserver() engine.subscribe(observer) observer.events.clear() - engine.undo() - - // Should have received a MoveUndoneEvent on undo observer.events.size should be > 0 observer.events.exists(_.isInstanceOf[MoveUndoneEvent]) shouldBe true @@ -288,22 +271,16 @@ class GameEngineTest extends AnyFunSuite with Matchers: engine.processUserInput("e2e4") engine.processUserInput("e7e5") val boardAfterSecondMove = engine.board - engine.undo() val observer = new MockObserver() engine.subscribe(observer) observer.events.clear() - engine.redo() - - // Should have received a MoveRedoneEvent for the redo observer.events.size shouldBe 1 observer.events.head shouldBe a[MoveRedoneEvent] engine.board shouldBe boardAfterSecondMove engine.turn shouldBe Color.White - // ──── 50-move rule ─────────────────────────────────────────────────── - test("GameEngine: 'draw' rejected when halfMoveClock < 100"): val engine = new GameEngine() val observer = new MockObserver() @@ -313,7 +290,7 @@ class GameEngineTest extends AnyFunSuite with Matchers: observer.events.head shouldBe a[InvalidMoveEvent] test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"): - val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100)) + val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(100)) val observer = new MockObserver() engine.subscribe(observer) engine.processUserInput("draw") @@ -321,31 +298,27 @@ class GameEngineTest extends AnyFunSuite with Matchers: observer.events.head shouldBe a[DrawClaimedEvent] test("GameEngine: state resets to initial after draw claimed"): - val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100)) + val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(100)) engine.processUserInput("draw") engine.board shouldBe Board.initial - engine.history shouldBe GameHistory.empty + engine.context.moves shouldBe empty engine.turn shouldBe Color.White test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"): - // Start at clock 99; a knight move (non-pawn, non-capture) increments to 100 - val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99)) + val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(99)) val observer = new MockObserver() engine.subscribe(observer) - engine.processUserInput("g1f3") // knight move on initial board - // Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent + engine.processUserInput("g1f3") observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"): - val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5)) + val engine = new GameEngine(initialContext = GameContext.initial.withHalfMoveClock(5)) val observer = new MockObserver() engine.subscribe(observer) engine.processUserInput("g1f3") observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false - // Mock Observer for testing private class MockObserver extends Observer: val events = mutable.ListBuffer[GameEvent]() override def onGameEvent(event: GameEvent): Unit = events += event - diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/MoveCommandDefaultsTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/MoveCommandDefaultsTest.scala deleted file mode 100644 index 46df874..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/MoveCommandDefaultsTest.scala +++ /dev/null @@ -1,110 +0,0 @@ -package de.nowchess.chess.command - -import de.nowchess.api.board.{Square, File, Rank, Board, Color} -import de.nowchess.chess.logic.GameHistory -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class MoveCommandDefaultsTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - - // Tests for MoveCommand with default parameter values - test("MoveCommand with no moveResult defaults to None"): - val cmd = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4) - ) - cmd.moveResult shouldBe None - cmd.execute() shouldBe false - - test("MoveCommand with no previousBoard defaults to None"): - val cmd = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4) - ) - cmd.previousBoard shouldBe None - cmd.undo() shouldBe false - - test("MoveCommand with no previousHistory defaults to None"): - val cmd = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4) - ) - cmd.previousHistory shouldBe None - cmd.undo() shouldBe false - - test("MoveCommand with no previousTurn defaults to None"): - val cmd = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4) - ) - cmd.previousTurn shouldBe None - cmd.undo() shouldBe false - - test("MoveCommand description is always returned"): - val cmd = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4) - ) - cmd.description shouldBe "Move from e2 to e4" - - test("MoveCommand execute returns false when moveResult is None"): - val cmd = MoveCommand( - from = sq(File.A, Rank.R1), - to = sq(File.B, Rank.R3) - ) - cmd.execute() shouldBe false - - test("MoveCommand undo returns false when any previous state is None"): - // Missing previousBoard - val cmd1 = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4), - moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), - previousBoard = None, - previousHistory = Some(GameHistory.empty), - previousTurn = Some(Color.White) - ) - cmd1.undo() shouldBe false - - // Missing previousHistory - val cmd2 = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4), - moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), - previousBoard = Some(Board.initial), - previousHistory = None, - previousTurn = Some(Color.White) - ) - cmd2.undo() shouldBe false - - // Missing previousTurn - val cmd3 = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4), - moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), - previousBoard = Some(Board.initial), - previousHistory = Some(GameHistory.empty), - previousTurn = None - ) - cmd3.undo() shouldBe false - - test("MoveCommand execute returns true when moveResult is defined"): - val cmd = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4), - moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) - ) - cmd.execute() shouldBe true - - test("MoveCommand undo returns true when all previous states are defined"): - val cmd = MoveCommand( - from = sq(File.E, Rank.R2), - to = sq(File.E, Rank.R4), - moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)), - previousBoard = Some(Board.initial), - previousHistory = Some(GameHistory.empty), - previousTurn = Some(Color.White) - ) - cmd.undo() shouldBe true diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/CastlingRightsCalculatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/CastlingRightsCalculatorTest.scala deleted file mode 100644 index d9f3e20..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/CastlingRightsCalculatorTest.scala +++ /dev/null @@ -1,70 +0,0 @@ -package de.nowchess.chess.logic - -import de.nowchess.api.board.* -import de.nowchess.api.game.CastlingRights -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class CastlingRightsCalculatorTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - - test("Empty history gives full castling rights"): - val rights = CastlingRightsCalculator.deriveCastlingRights(GameHistory.empty, Color.White) - rights shouldBe CastlingRights.Both - - test("White loses kingside rights after h1 rook moves"): - val history = GameHistory.empty.addMove(sq(File.H, Rank.R1), sq(File.H, Rank.R2)) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White) - rights.kingSide shouldBe false - rights.queenSide shouldBe true - - test("White loses queenside rights after a1 rook moves"): - val history = GameHistory.empty.addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2)) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White) - rights.queenSide shouldBe false - rights.kingSide shouldBe true - - test("White loses all rights after king moves"): - val history = GameHistory.empty.addMove(sq(File.E, Rank.R1), sq(File.E, Rank.R2)) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White) - rights shouldBe CastlingRights.None - - test("Black loses kingside rights after h8 rook moves"): - val history = GameHistory.empty.addMove(sq(File.H, Rank.R8), sq(File.H, Rank.R7)) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black) - rights.kingSide shouldBe false - rights.queenSide shouldBe true - - test("Black loses queenside rights after a8 rook moves"): - val history = GameHistory.empty.addMove(sq(File.A, Rank.R8), sq(File.A, Rank.R7)) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black) - rights.queenSide shouldBe false - rights.kingSide shouldBe true - - test("Black loses all rights after king moves"): - val history = GameHistory.empty.addMove(sq(File.E, Rank.R8), sq(File.E, Rank.R7)) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.Black) - rights shouldBe CastlingRights.None - - test("Castle move revokes all castling rights"): - val history = GameHistory.empty.addMove( - sq(File.E, Rank.R1), - sq(File.G, Rank.R1), - Some(CastleSide.Kingside) - ) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White) - rights shouldBe CastlingRights.None - - test("Other pieces moving does not revoke castling rights"): - val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White) - rights shouldBe CastlingRights.Both - - test("Multiple moves preserve white kingside but lose queenside"): - val history = GameHistory.empty - .addMove(sq(File.A, Rank.R1), sq(File.A, Rank.R2)) // White queenside rook moves - .addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) // Black pawn moves - val rights = CastlingRightsCalculator.deriveCastlingRights(history, Color.White) - rights.kingSide shouldBe true - rights.queenSide shouldBe false diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala deleted file mode 100644 index 31963f5..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/EnPassantCalculatorTest.scala +++ /dev/null @@ -1,101 +0,0 @@ -package de.nowchess.chess.logic - -import de.nowchess.api.board.* -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class EnPassantCalculatorTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) - - // ──── enPassantTarget ──────────────────────────────────────────────── - - test("enPassantTarget returns None for empty history"): - val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn) - EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None - - test("enPassantTarget returns None when last move was a single pawn push"): - val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn) - val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3)) - EnPassantCalculator.enPassantTarget(b, h) shouldBe None - - test("enPassantTarget returns None when last move was not a pawn"): - val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook) - val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - EnPassantCalculator.enPassantTarget(b, h) shouldBe None - - test("enPassantTarget returns e3 after white pawn double push e2-e4"): - val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn) - val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3)) - - test("enPassantTarget returns e6 after black pawn double push e7-e5"): - val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn) - val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) - EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6)) - - test("enPassantTarget returns d3 after white pawn double push d2-d4"): - val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) - EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3)) - - // ──── capturedPawnSquare ───────────────────────────────────────────── - - test("capturedPawnSquare for white capturing on e6 returns e5"): - EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5) - - test("capturedPawnSquare for black capturing on e3 returns e4"): - EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4) - - test("capturedPawnSquare for white capturing on d6 returns d5"): - EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5) - - // ──── isEnPassant ──────────────────────────────────────────────────── - - test("isEnPassant returns true for valid white en passant capture"): - // White pawn on e5, black pawn just double-pushed to d5 (ep target = d6) - val b = board( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R5) -> Piece.BlackPawn - ) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) - EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true - - test("isEnPassant returns true for valid black en passant capture"): - // Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3) - val b = board( - sq(File.D, Rank.R4) -> Piece.BlackPawn, - sq(File.E, Rank.R4) -> Piece.WhitePawn - ) - val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true - - test("isEnPassant returns false when no en passant target in history"): - val b = board( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R5) -> Piece.BlackPawn - ) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push - EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false - - test("isEnPassant returns false when piece at from is not a pawn"): - val b = board( - sq(File.E, Rank.R5) -> Piece.WhiteRook, - sq(File.D, Rank.R5) -> Piece.BlackPawn - ) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) - EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false - - test("isEnPassant returns false when to does not match ep target"): - val b = board( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R5) -> Piece.BlackPawn - ) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) - EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false - - test("isEnPassant returns false when from square is empty"): - val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) - EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala index 8a6069f..919b736 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/logic/GameHistoryTest.scala @@ -2,6 +2,7 @@ package de.nowchess.chess.logic import de.nowchess.api.board.* import de.nowchess.api.move.PromotionPiece +import de.nowchess.api.game.{GameHistory, HistoryMove} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -32,9 +33,9 @@ class GameHistoryTest extends AnyFunSuite with Matchers: val history = GameHistory.empty.addMove( sq(File.E, Rank.R1), sq(File.G, Rank.R1), - Some(CastleSide.Kingside) + Some("Kingside") ) - history.moves.head.castleSide shouldBe Some(CastleSide.Kingside) + history.moves.head.castleSide shouldBe Some("Kingside") test("GameHistory.addMove with two arguments uses None for castleSide default"): val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) @@ -58,9 +59,9 @@ class GameHistoryTest extends AnyFunSuite with Matchers: test("addMove with castleSide only uses promotionPiece default (None)"): val history = GameHistory.empty // With overload 3 removed, this uses the 4-param version and triggers addMove$default$4 - val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some(CastleSide.Kingside)) + val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some("Kingside")) newHistory.moves should have length 1 - newHistory.moves.head.castleSide should be (Some(CastleSide.Kingside)) + newHistory.moves.head.castleSide should be (Some("Kingside")) newHistory.moves.head.promotionPiece should be (None) test("addMove using named parameters with only promotion, using castleSide default"): diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala deleted file mode 100644 index 5f02f19..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/GameRulesTest.scala +++ /dev/null @@ -1,161 +0,0 @@ -package de.nowchess.chess.logic - -import de.nowchess.api.board.* -import de.nowchess.api.game.CastlingRights -import de.nowchess.chess.logic.GameHistory -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class GameRulesTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) - - /** Wrap a board in a GameContext with no castling rights — for non-castling tests. */ - private def testLegalMoves(entries: (Square, Piece)*)(color: Color): Set[(Square, Square)] = - GameRules.legalMoves(Board(entries.toMap), GameHistory.empty, color) - - private def testGameStatus(entries: (Square, Piece)*)(color: Color): PositionStatus = - GameRules.gameStatus(Board(entries.toMap), GameHistory.empty, color) - - // ──── isInCheck ────────────────────────────────────────────────────── - - test("isInCheck: king attacked by enemy rook on same rank"): - // White King E1, Black Rook A1 — rook slides along rank 1 to E1 - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.BlackRook - ) - GameRules.isInCheck(b, Color.White) shouldBe true - - test("isInCheck: king not attacked"): - // Black Rook A3 does not cover E1 - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R3) -> Piece.BlackRook - ) - GameRules.isInCheck(b, Color.White) shouldBe false - - test("isInCheck: no king on board returns false"): - val b = board(sq(File.A, Rank.R1) -> Piece.BlackRook) - GameRules.isInCheck(b, Color.White) shouldBe false - - // ──── legalMoves ───────────────────────────────────────────────────── - - test("legalMoves: move that exposes own king to rook is excluded"): - // White King E1, White Rook E4 (pinned on E-file), Black Rook E8 - // Moving the White Rook off the E-file would expose the king - val moves = testLegalMoves( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.E, Rank.R4) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook - )(Color.White) - moves should not contain (sq(File.E, Rank.R4) -> sq(File.D, Rank.R4)) - - test("legalMoves: move that blocks check is included"): - // White King E1 in check from Black Rook E8; White Rook A5 can interpose on E5 - val moves = testLegalMoves( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R5) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook - )(Color.White) - moves should contain(sq(File.A, Rank.R5) -> sq(File.E, Rank.R5)) - - // ──── gameStatus ────────────────────────────────────────────────────── - - test("gameStatus: checkmate returns Mated"): - // White Qh8, Ka6; Black Ka8 - // Qh8 attacks Ka8 along rank 8; all escape squares covered (spec-verified position) - testGameStatus( - sq(File.H, Rank.R8) -> Piece.WhiteQueen, - sq(File.A, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )(Color.Black) shouldBe PositionStatus.Mated - - test("gameStatus: stalemate returns Drawn"): - // White Qb6, Kc6; Black Ka8 - // Black king has no legal moves and is not in check (spec-verified position) - testGameStatus( - sq(File.B, Rank.R6) -> Piece.WhiteQueen, - sq(File.C, Rank.R6) -> Piece.WhiteKing, - sq(File.A, Rank.R8) -> Piece.BlackKing - )(Color.Black) shouldBe PositionStatus.Drawn - - test("gameStatus: king in check with legal escape returns InCheck"): - // White Ra8 attacks Black Ke8 along rank 8; king can escape to d7, e7, f7 - testGameStatus( - sq(File.A, Rank.R8) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackKing - )(Color.Black) shouldBe PositionStatus.InCheck - - test("gameStatus: normal starting position returns Normal"): - GameRules.gameStatus(Board.initial, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal - - test("legalMoves: includes castling destination when available"): - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - ) - GameRules.legalMoves(b, GameHistory.empty, Color.White) should contain(sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) - - test("legalMoves: excludes castling when king is in check"): - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.E, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - ) - GameRules.legalMoves(b, GameHistory.empty, Color.White) should not contain (sq(File.E, Rank.R1) -> sq(File.G, Rank.R1)) - - test("gameStatus: returns Normal (not Drawn) when castling is the only legal move"): - // White King e1, Rook h1 (kingside castling available). - // Black Rooks d2 and f2 box the king: d1 attacked by d2, e2 attacked by both, - // f1 attacked by f2. King cannot move to any adjacent square without entering - // an attacked square or an enemy piece. Only legal move: castle to g1. - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.H, Rank.R1) -> Piece.WhiteRook, - sq(File.D, Rank.R2) -> Piece.BlackRook, - sq(File.F, Rank.R2) -> Piece.BlackRook, - sq(File.A, Rank.R8) -> Piece.BlackKing - ) - // No history means castling rights are intact - GameRules.gameStatus(b, GameHistory.empty, Color.White) shouldBe PositionStatus.Normal - - test("CastleSide.withCastle correctly positions pieces for Queenside castling"): - // Directly test the withCastle extension for Queenside (coverage gap on line 10) - val b = board( - sq(File.E, Rank.R1) -> Piece.WhiteKing, - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.H, Rank.R8) -> Piece.BlackKing - ) - val result = b.withCastle(Color.White, CastleSide.Queenside) - result.pieceAt(sq(File.C, Rank.R1)) shouldBe Some(Piece.WhiteKing) - result.pieceAt(sq(File.D, Rank.R1)) shouldBe Some(Piece.WhiteRook) - result.pieceAt(sq(File.E, Rank.R1)) shouldBe None - result.pieceAt(sq(File.A, Rank.R1)) shouldBe None - - test("CastleSide.withCastle correctly positions pieces for Black Kingside castling"): - val b = board( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.H, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R1) -> Piece.WhiteKing - ) - val result = b.withCastle(Color.Black, CastleSide.Kingside) - result.pieceAt(sq(File.G, Rank.R8)) shouldBe Some(Piece.BlackKing) - result.pieceAt(sq(File.F, Rank.R8)) shouldBe Some(Piece.BlackRook) - result.pieceAt(sq(File.E, Rank.R8)) shouldBe None - result.pieceAt(sq(File.H, Rank.R8)) shouldBe None - - test("CastleSide.withCastle correctly positions pieces for Black Queenside castling"): - val b = board( - sq(File.E, Rank.R8) -> Piece.BlackKing, - sq(File.A, Rank.R8) -> Piece.BlackRook, - sq(File.A, Rank.R1) -> Piece.WhiteKing - ) - val result = b.withCastle(Color.Black, CastleSide.Queenside) - result.pieceAt(sq(File.C, Rank.R8)) shouldBe Some(Piece.BlackKing) - result.pieceAt(sq(File.D, Rank.R8)) shouldBe Some(Piece.BlackRook) - result.pieceAt(sq(File.E, Rank.R8)) shouldBe None - result.pieceAt(sq(File.A, Rank.R8)) shouldBe None diff --git a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala deleted file mode 100644 index b5dce75..0000000 --- a/modules/core/src/test/scala/de/nowchess/chess/logic/MoveValidatorTest.scala +++ /dev/null @@ -1,280 +0,0 @@ -package de.nowchess.chess.logic - -import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} -import de.nowchess.api.game.CastlingRights -import de.nowchess.chess.logic.{CastleSide, GameHistory} -import de.nowchess.chess.notation.FenParser -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class MoveValidatorTest extends AnyFunSuite with Matchers: - - private def sq(f: File, r: Rank): Square = Square(f, r) - private def board(entries: (Square, Piece)*): Board = Board(entries.toMap) - - // ──── Empty square ─────────────────────────────────────────────────── - - test("legalTargets returns empty set when no piece at from square"): - MoveValidator.legalTargets(Board.initial, sq(File.E, Rank.R4)) shouldBe empty - - // ──── isLegal delegates to legalTargets ────────────────────────────── - - test("isLegal returns true for a valid pawn move"): - MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R4)) shouldBe true - - test("isLegal returns false for an invalid move"): - MoveValidator.isLegal(Board.initial, sq(File.E, Rank.R2), sq(File.E, Rank.R5)) shouldBe false - - // ──── Pawn – White ─────────────────────────────────────────────────── - - test("white pawn on starting rank can move forward one square"): - val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) - MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R3)) - - test("white pawn on starting rank can move forward two squares"): - val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) - MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.E, Rank.R4)) - - test("white pawn not on starting rank cannot move two squares"): - val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn) - MoveValidator.legalTargets(b, sq(File.E, Rank.R3)) should not contain sq(File.E, Rank.R5) - - test("white pawn is blocked by piece directly in front, and cannot jump over it"): - val b = board( - sq(File.E, Rank.R2) -> Piece.WhitePawn, - sq(File.E, Rank.R3) -> Piece.BlackPawn - ) - val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) - targets should not contain sq(File.E, Rank.R3) - targets should not contain sq(File.E, Rank.R4) - - test("white pawn on starting rank cannot move two squares if destination square is occupied"): - val b = board( - sq(File.E, Rank.R2) -> Piece.WhitePawn, - sq(File.E, Rank.R4) -> Piece.BlackPawn - ) - val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) - targets should contain(sq(File.E, Rank.R3)) - targets should not contain sq(File.E, Rank.R4) - - test("white pawn can capture diagonally when enemy piece is present"): - val b = board( - sq(File.E, Rank.R2) -> Piece.WhitePawn, - sq(File.D, Rank.R3) -> Piece.BlackPawn - ) - MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should contain(sq(File.D, Rank.R3)) - - test("white pawn cannot capture diagonally when no enemy piece is present"): - val b = board(sq(File.E, Rank.R2) -> Piece.WhitePawn) - MoveValidator.legalTargets(b, sq(File.E, Rank.R2)) should not contain sq(File.D, Rank.R3) - - test("white pawn at A-file does not generate diagonal to the left off the board"): - val b = board(sq(File.A, Rank.R2) -> Piece.WhitePawn) - val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R2)) - targets should contain(sq(File.A, Rank.R3)) - targets should contain(sq(File.A, Rank.R4)) - targets.size shouldBe 2 - - // ──── Pawn – Black ─────────────────────────────────────────────────── - - test("black pawn on starting rank can move forward one and two squares"): - val b = board(sq(File.E, Rank.R7) -> Piece.BlackPawn) - val targets = MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) - targets should contain(sq(File.E, Rank.R6)) - targets should contain(sq(File.E, Rank.R5)) - - test("black pawn not on starting rank cannot move two squares"): - val b = board(sq(File.E, Rank.R6) -> Piece.BlackPawn) - MoveValidator.legalTargets(b, sq(File.E, Rank.R6)) should not contain sq(File.E, Rank.R4) - - test("black pawn can capture diagonally when enemy piece is present"): - val b = board( - sq(File.E, Rank.R7) -> Piece.BlackPawn, - sq(File.F, Rank.R6) -> Piece.WhitePawn - ) - MoveValidator.legalTargets(b, sq(File.E, Rank.R7)) should contain(sq(File.F, Rank.R6)) - - // ──── Knight ───────────────────────────────────────────────────────── - - test("knight in center has 8 possible moves"): - val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKnight) - MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8 - - test("knight in corner has only 2 possible moves"): - val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKnight) - MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 2 - - test("knight cannot land on own piece"): - val b = board( - sq(File.D, Rank.R4) -> Piece.WhiteKnight, - sq(File.F, Rank.R5) -> Piece.WhiteRook - ) - MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.F, Rank.R5) - - test("knight can capture enemy piece"): - val b = board( - sq(File.D, Rank.R4) -> Piece.WhiteKnight, - sq(File.F, Rank.R5) -> Piece.BlackRook - ) - MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.F, Rank.R5)) - - // ──── Bishop ───────────────────────────────────────────────────────── - - test("bishop slides diagonally across an empty board"): - val b = board(sq(File.D, Rank.R4) -> Piece.WhiteBishop) - val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) - targets should contain(sq(File.E, Rank.R5)) - targets should contain(sq(File.H, Rank.R8)) - targets should contain(sq(File.C, Rank.R3)) - targets should contain(sq(File.A, Rank.R1)) - - test("bishop is blocked by own piece and squares beyond are unreachable"): - val b = board( - sq(File.D, Rank.R4) -> Piece.WhiteBishop, - sq(File.F, Rank.R6) -> Piece.WhiteRook - ) - val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) - targets should contain(sq(File.E, Rank.R5)) - targets should not contain sq(File.F, Rank.R6) - targets should not contain sq(File.G, Rank.R7) - - test("bishop captures enemy piece and cannot slide further"): - val b = board( - sq(File.D, Rank.R4) -> Piece.WhiteBishop, - sq(File.F, Rank.R6) -> Piece.BlackRook - ) - val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) - targets should contain(sq(File.E, Rank.R5)) - targets should contain(sq(File.F, Rank.R6)) - targets should not contain sq(File.G, Rank.R7) - - // ──── Rook ─────────────────────────────────────────────────────────── - - test("rook slides orthogonally across an empty board"): - val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) - val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) - targets should contain(sq(File.D, Rank.R8)) - targets should contain(sq(File.D, Rank.R1)) - targets should contain(sq(File.A, Rank.R4)) - targets should contain(sq(File.H, Rank.R4)) - - test("rook is blocked by own piece and squares beyond are unreachable"): - val b = board( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.C, Rank.R1) -> Piece.WhitePawn - ) - val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1)) - targets should contain(sq(File.B, Rank.R1)) - targets should not contain sq(File.C, Rank.R1) - targets should not contain sq(File.D, Rank.R1) - - test("rook captures enemy piece and cannot slide further"): - val b = board( - sq(File.A, Rank.R1) -> Piece.WhiteRook, - sq(File.C, Rank.R1) -> Piece.BlackPawn - ) - val targets = MoveValidator.legalTargets(b, sq(File.A, Rank.R1)) - targets should contain(sq(File.B, Rank.R1)) - targets should contain(sq(File.C, Rank.R1)) - targets should not contain sq(File.D, Rank.R1) - - // ──── Queen ────────────────────────────────────────────────────────── - - test("queen combines rook and bishop movement for 27 squares from d4"): - val b = board(sq(File.D, Rank.R4) -> Piece.WhiteQueen) - val targets = MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) - targets should contain(sq(File.D, Rank.R8)) - targets should contain(sq(File.H, Rank.R4)) - targets should contain(sq(File.H, Rank.R8)) - targets should contain(sq(File.A, Rank.R1)) - targets.size shouldBe 27 - - // ──── King ─────────────────────────────────────────────────────────── - - test("king moves one step in all 8 directions from center"): - val b = board(sq(File.D, Rank.R4) -> Piece.WhiteKing) - MoveValidator.legalTargets(b, sq(File.D, Rank.R4)).size shouldBe 8 - - test("king at corner has only 3 reachable squares"): - val b = board(sq(File.A, Rank.R1) -> Piece.WhiteKing) - MoveValidator.legalTargets(b, sq(File.A, Rank.R1)).size shouldBe 3 - - test("king cannot capture own piece"): - val b = board( - sq(File.D, Rank.R4) -> Piece.WhiteKing, - sq(File.E, Rank.R4) -> Piece.WhiteRook - ) - MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should not contain sq(File.E, Rank.R4) - - test("king can capture enemy piece"): - val b = board( - sq(File.D, Rank.R4) -> Piece.WhiteKing, - sq(File.E, Rank.R4) -> Piece.BlackRook - ) - MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4)) - - // ──── Pawn – en passant targets ────────────────────────────────────── - - test("white pawn includes ep target in legal moves after black double push"): - // Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5 - val b = board( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R5) -> Piece.BlackPawn - ) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) - MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6)) - - test("white pawn does not include ep target without a preceding double push"): - val b = board( - sq(File.E, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R5) -> Piece.BlackPawn - ) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push - MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6) - - test("black pawn includes ep target in legal moves after white double push"): - // White pawn just double-pushed to e4 (ep target = e3); black pawn on d4 - val b = board( - sq(File.D, Rank.R4) -> Piece.BlackPawn, - sq(File.E, Rank.R4) -> Piece.WhitePawn - ) - val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3)) - - test("pawn on wrong file does not get ep target from adjacent double push"): - // White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5 - val b = board( - sq(File.A, Rank.R5) -> Piece.WhitePawn, - sq(File.D, Rank.R5) -> Piece.BlackPawn - ) - val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5)) - MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6) - - // ──── History-aware legalTargets fallback for non-pawn non-king pieces ───── - - test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"): - val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) - val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) - MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) - - // ──── isPromotionMove ──────────────────────────────────────────────── - - test("White pawn reaching R8 is a promotion move"): - val b = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true) - - test("Black pawn reaching R1 is a promotion move"): - val b = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get - MoveValidator.isPromotionMove(b, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true) - - test("Pawn capturing to back rank is a promotion move"): - val b = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get - MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.D, Rank.R8)) should be (true) - - test("Pawn not reaching back rank is not a promotion move"): - val b = FenParser.parseBoard("8/8/8/4P3/8/8/8/8").get - MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false) - - test("Non-pawn piece is never a promotion move"): - val b = FenParser.parseBoard("8/8/8/4Q3/8/8/8/8").get - MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false) diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala index 6734b15..7b4d11a 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/FenExporterTest.scala @@ -1,7 +1,8 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* -import de.nowchess.api.game.* +import de.nowchess.api.game.{CastlingRights, GameState, GameStatus} +import de.nowchess.api.board.Color import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -69,7 +70,7 @@ class FenExporterTest extends AnyFunSuite with Matchers: fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" test("halfMoveClock round-trips through FEN export and import"): - import de.nowchess.chess.logic.GameHistory + import de.nowchess.api.game.GameHistory import de.nowchess.chess.notation.FenParser val history = GameHistory(halfMoveClock = 42) val gameState = GameState( diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala index 7d453df..6a4f15a 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnExporterTest.scala @@ -2,7 +2,7 @@ package de.nowchess.chess.notation import de.nowchess.api.board.{PieceType, *} import de.nowchess.api.move.PromotionPiece -import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} +import de.nowchess.api.game.{GameHistory, HistoryMove} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -30,7 +30,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers: test("export castling") { val headers = Map("Event" -> "Test") val history = GameHistory() - .addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some(CastleSide.Kingside))) + .addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some("Kingside"))) val pgn = PgnExporter.exportGame(headers, history) pgn.contains("O-O") shouldBe true @@ -59,7 +59,7 @@ class PgnExporterTest extends AnyFunSuite with Matchers: test("export queenside castling") { val headers = Map("Event" -> "Test") val history = GameHistory() - .addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some(CastleSide.Queenside))) + .addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some("Queenside"))) val pgn = PgnExporter.exportGame(headers, history) pgn.contains("O-O-O") shouldBe true diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala index 520f842..7694a9d 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnParserTest.scala @@ -1,8 +1,8 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* -import de.nowchess.api.move.PromotionPiece -import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} +import de.nowchess.api.move.{MoveType, PromotionPiece} +import de.nowchess.api.game.GameContext import de.nowchess.chess.notation.FenParser import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -49,13 +49,13 @@ class PgnParserTest extends AnyFunSuite with Matchers: [White "A"] [Black "B"] -1. e4 e5 2. Nxe5 +1. Nf3 e5 2. Nxe5 """ val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true game.get.moves.length shouldBe 3 - // Nxe5: knight captures on e5 + // Nxe5: knight on f3 captures pawn on e5 game.get.moves(2).to shouldBe Square(File.E, Rank.R5) } @@ -89,16 +89,16 @@ class PgnParserTest extends AnyFunSuite with Matchers: } test("parse PGN black kingside castling O-O") { - // After e4 e5 Nf3 Nc6 Bc4 Bc5, black can castle kingside + // After e4 e5 Nf3 Nf6 Bc4 Be7, both sides have cleared kingside for castling val pgn = """[Event "Test"] -1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O +1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O """ val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true val blackCastle = game.get.moves.last - blackCastle.castleSide shouldBe Some(CastleSide.Kingside) + blackCastle.castleSide shouldBe Some("Kingside") blackCastle.from shouldBe Square(File.E, Rank.R8) blackCastle.to shouldBe Square(File.G, Rank.R8) } @@ -117,28 +117,25 @@ class PgnParserTest extends AnyFunSuite with Matchers: test("parseAlgebraicMove: unrecognised token returns None and is skipped") { val board = Board.initial - val history = GameHistory.empty // "zzz" is not valid algebraic notation - val result = PgnParser.parseAlgebraicMove("zzz", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("zzz", GameContext.initial.withBoard(board), Color.White) result shouldBe None } test("parseAlgebraicMove: piece moves use charToPieceType for N B R Q K") { // Test that piece type characters are recognised val board = Board.initial - val history = GameHistory.empty // Nf3 - knight move - val nMove = PgnParser.parseAlgebraicMove("Nf3", board, history, Color.White) + val nMove = PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White) nMove.isDefined shouldBe true nMove.get.to shouldBe Square(File.F, Rank.R3) } test("parseAlgebraicMove: single char that is too short returns None") { val board = Board.initial - val history = GameHistory.empty // Single char that is not castling and cleaned length < 2 - val result = PgnParser.parseAlgebraicMove("e", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("e", GameContext.initial.withBoard(board), Color.White) result shouldBe None } @@ -153,9 +150,8 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val board = Board(pieces) - val history = GameHistory.empty - val result = PgnParser.parseAlgebraicMove("Rad1", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(board), Color.White) result.isDefined shouldBe true result.get.from shouldBe Square(File.A, Rank.R1) result.get.to shouldBe Square(File.D, Rank.R1) @@ -171,9 +167,8 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val board = Board(pieces) - val history = GameHistory.empty - val result = PgnParser.parseAlgebraicMove("R1a3", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(board), Color.White) result.isDefined shouldBe true result.get.from shouldBe Square(File.A, Rank.R1) result.get.to shouldBe Square(File.A, Rank.R3) @@ -188,7 +183,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val boardBishop = Board(piecesForBishop) - val bResult = PgnParser.parseAlgebraicMove("Bd2", boardBishop, GameHistory.empty, Color.White) + val bResult = PgnParser.parseAlgebraicMove("Bd2", GameContext.initial.withBoard(boardBishop), Color.White) bResult.isDefined shouldBe true // Rook move @@ -198,7 +193,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val boardRook = Board(piecesForRook) - val rResult = PgnParser.parseAlgebraicMove("Ra4", boardRook, GameHistory.empty, Color.White) + val rResult = PgnParser.parseAlgebraicMove("Ra4", GameContext.initial.withBoard(boardRook), Color.White) rResult.isDefined shouldBe true // Queen move @@ -208,7 +203,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val boardQueen = Board(piecesForQueen) - val qResult = PgnParser.parseAlgebraicMove("Qd4", boardQueen, GameHistory.empty, Color.White) + val qResult = PgnParser.parseAlgebraicMove("Qd4", GameContext.initial.withBoard(boardQueen), Color.White) qResult.isDefined shouldBe true // King move @@ -217,7 +212,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val boardKing = Board(piecesForKing) - val kResult = PgnParser.parseAlgebraicMove("Ke2", boardKing, GameHistory.empty, Color.White) + val kResult = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(boardKing), Color.White) kResult.isDefined shouldBe true } @@ -230,7 +225,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: game.isDefined shouldBe true val lastMove = game.get.moves.last - lastMove.castleSide shouldBe Some(CastleSide.Queenside) + lastMove.castleSide shouldBe Some("Queenside") lastMove.from shouldBe Square(File.E, Rank.R1) lastMove.to shouldBe Square(File.C, Rank.R1) } @@ -245,7 +240,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: game.isDefined shouldBe true val lastMove = game.get.moves.last - lastMove.castleSide shouldBe Some(CastleSide.Queenside) + lastMove.castleSide shouldBe Some("Queenside") lastMove.from shouldBe Square(File.E, Rank.R8) lastMove.to shouldBe Square(File.C, Rank.R8) } @@ -275,10 +270,9 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val board = Board(pieces) - val history = GameHistory.empty // "Rae4" - Rook from a-file to e4; disambig = "a", clean.head = 'R' uppercase - val result = PgnParser.parseAlgebraicMove("Rae4", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("Rae4", GameContext.initial.withBoard(board), Color.White) result.isDefined shouldBe true result.get.from shouldBe Square(File.A, Rank.R4) result.get.to shouldBe Square(File.E, Rank.R4) @@ -288,13 +282,12 @@ class PgnParserTest extends AnyFunSuite with Matchers: // 'Z' is not a valid piece letter - the regex clean should return None import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType} val board = Board.initial - val history = GameHistory.empty // "Ze4" - Z is not a valid piece, charToPieceType('Z') returns None // The result will be None because requiredPieceType is None and filtering by None.forall = true // so it finds any piece that can reach e4, but since clean="Ze4" -> destStr="e4", disambig="Z" // disambig.head.isUpper so charToPieceType('Z') is called - val result = PgnParser.parseAlgebraicMove("Ze4", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("Ze4", GameContext.initial.withBoard(board), Color.White) // With None piece type, forall(pt => ...) is vacuously true so any piece reaching e4 is candidate // But there's no piece named Z so requiredPieceType=None, meaning any piece can match // This tests that charToPieceType('Z') returns None without crashing @@ -306,9 +299,8 @@ class PgnParserTest extends AnyFunSuite with Matchers: // This exercises line 97 (else if clean.head.isUpper) and line 152 (case _ => None) import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType} val board = Board.initial - val history = GameHistory.empty // 'E' is not a valid piece type but we still get a result since requiredPieceType is None - val result = PgnParser.parseAlgebraicMove("E4", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("E4", GameContext.initial.withBoard(board), Color.White) // Result may be defined (pawn that can reach e4) or None; main goal is no crash and line coverage result should not be null // just verifies code path executes without exception } @@ -324,12 +316,11 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) ) val board = Board(pieces) - val history = GameHistory.empty // "R9d1" - clean = "R9d1", destStr = "d1", disambig = "R9" // disambig.head = 'R' is upper -> charToPieceType('R') = Rook, hint = "9" // matchesHint called with hint "9" -> '9' not in a-h, not in 1-8 -> else true - val result = PgnParser.parseAlgebraicMove("R9d1", board, history, Color.White) + val result = PgnParser.parseAlgebraicMove("R9d1", GameContext.initial.withBoard(board), Color.White) // Should find a rook (hint "9" matches everything) result.isDefined shouldBe true result.get.to shouldBe Square(File.D, Rank.R1) @@ -337,28 +328,28 @@ class PgnParserTest extends AnyFunSuite with Matchers: test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") { val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White) + val result = PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White) result.isDefined should be (true) - result.get.promotionPiece should be (Some(PromotionPiece.Queen)) + result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) result.get.to should be (Square(File.E, Rank.R8)) } test("parseAlgebraicMove preserves promotion to Rook") { val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White) - result.get.promotionPiece should be (Some(PromotionPiece.Rook)) + val result = PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White) + result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook) } test("parseAlgebraicMove preserves promotion to Bishop") { val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White) - result.get.promotionPiece should be (Some(PromotionPiece.Bishop)) + val result = PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White) + result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop) } test("parseAlgebraicMove preserves promotion to Knight") { val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get - val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White) - result.get.promotionPiece should be (Some(PromotionPiece.Knight)) + val result = PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White) + result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight) } test("parsePgn applies promoted piece to board for subsequent moves") { @@ -370,9 +361,9 @@ class PgnParserTest extends AnyFunSuite with Matchers: Square(File.H, Rank.R1) -> Piece(Color.Black, PieceType.King) ) val board = Board(pieces) - val move = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White) + val move = PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White) move.isDefined should be (true) - move.get.promotionPiece should be (Some(PromotionPiece.Queen)) + move.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen) // After applying the promotion the square e8 should hold a White Queen val (boardAfterPawnMove, _) = board.withMove(move.get.from, move.get.to) val promotedBoard = boardAfterPawnMove.updated(move.get.to, Piece(Color.White, PieceType.Queen)) @@ -380,32 +371,29 @@ class PgnParserTest extends AnyFunSuite with Matchers: } test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") { - // This test exercises lines 53-58 in PgnParser.parseMovesText which contain - // the pattern match over PromotionPiece for Queen, Rook, Bishop, Knight - val pgn = """[Event "Promotion Test"] -[White "A"] -[Black "B"] - -1. a2a3 h7h5 2. a3a4 h5h4 3. a4a5 h4h3 4. a5a6 h3h2 5. a6a7 h2h1=Q 6. a7a8=R 1-0 -""" - val game = PgnParser.parsePgn(pgn) - - game.isDefined shouldBe true - // Move 10 is h2h1=Q (black pawn promotes to queen) - val blackPromotionToQ = game.get.moves(9) // 0-indexed - blackPromotionToQ.promotionPiece shouldBe Some(PromotionPiece.Queen) - - // Move 11 is a7a8=R (white pawn promotes to rook) - val whitePromotionToR = game.get.moves(10) - whitePromotionToR.promotionPiece shouldBe Some(PromotionPiece.Rook) + // Exercises the promotion piece type branches in PgnParser.parseMovesText / toHistoryMove + // White pawn advances via capture chain (a4xb5, b5xc6 e.p., c6-c7, c7xd8) and promotes + val baseSequence = "1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=" + for (piece, expected) <- List( + "Q" -> PromotionPiece.Queen, + "R" -> PromotionPiece.Rook, + "B" -> PromotionPiece.Bishop, + "N" -> PromotionPiece.Knight + ) do + val pgn = s"""[Event "Promotion Test"]\n\n${baseSequence}$piece\n""" + val game = PgnParser.parsePgn(pgn) + game.isDefined shouldBe true + game.get.moves should not be empty + game.get.moves.last.promotionPiece shouldBe Some(expected) } test("parseAlgebraicMove promotion with Rook through full PGN parse") { + // White pawn advances via capture chain and promotes by capturing black queen on d8 val pgn = """[Event "Test"] [White "A"] [Black "B"] -1. a2a3 h7h6 2. a3a4 h6h5 3. a4a5 h5h4 4. a5a6 h4h3 5. a6a7 h3h2 6. a7a8=R +1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=R """ val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true @@ -418,7 +406,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: [White "A"] [Black "B"] -1. b2b3 h7h6 2. b3b4 h6h5 3. b4b5 h5h4 4. b5b6 h4h3 5. b6b7 h3h2 6. b7b8=B +1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=B """ val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true @@ -431,7 +419,7 @@ class PgnParserTest extends AnyFunSuite with Matchers: [White "A"] [Black "B"] -1. c2c3 h7h6 2. c3c4 h6h5 3. c4c5 h5h4 4. c5c6 h4h3 5. c6c7 h3h2 6. c7c8=N +1. a2a4 b7b5 2. a4b5 c7c5 3. b5c6 d7d5 4. c6c7 d5d4 5. c7d8=N """ val game = PgnParser.parsePgn(pgn) game.isDefined shouldBe true diff --git a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnValidatorTest.scala b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnValidatorTest.scala index c3dadca..ff80cb6 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/notation/PgnValidatorTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/notation/PgnValidatorTest.scala @@ -2,7 +2,7 @@ package de.nowchess.chess.notation import de.nowchess.api.board.* import de.nowchess.api.move.PromotionPiece -import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} +import de.nowchess.api.game.{GameHistory, HistoryMove} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -71,7 +71,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers: """ PgnParser.validatePgn(pgn) match case Right(game) => - game.moves.last.castleSide shouldBe Some(CastleSide.Kingside) + game.moves.last.castleSide shouldBe Some("Kingside") case Left(err) => fail(s"Expected Right but got Left($err)") test("validatePgn: castling when not legal returns Left"): @@ -93,7 +93,7 @@ class PgnValidatorTest extends AnyFunSuite with Matchers: """ PgnParser.validatePgn(pgn) match case Right(game) => - game.moves.last.castleSide shouldBe Some(CastleSide.Queenside) + game.moves.last.castleSide shouldBe Some("Queenside") case Left(err) => fail(s"Expected Right but got Left($err)") test("validatePgn: disambiguation with two rooks is accepted"): diff --git a/modules/core/src/test/scala/de/nowchess/chess/observer/ObservableThreadSafetyTest.scala b/modules/core/src/test/scala/de/nowchess/chess/observer/ObservableThreadSafetyTest.scala index 0a6192e..144e0f2 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/observer/ObservableThreadSafetyTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/observer/ObservableThreadSafetyTest.scala @@ -1,7 +1,7 @@ package de.nowchess.chess.observer import de.nowchess.api.board.{Board, Color} -import de.nowchess.chess.logic.GameHistory +import de.nowchess.api.game.GameContext import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import scala.collection.mutable @@ -21,11 +21,7 @@ class ObservableThreadSafetyTest extends AnyFunSuite with Matchers: lastEvent = Some(event) private def createTestEvent(): GameEvent = - BoardResetEvent( - board = Board.initial, - history = GameHistory.empty, - turn = Color.White - ) + BoardResetEvent(context = GameContext.initial) test("Observable is thread-safe for concurrent subscribe and notify"): val observable = new TestObservable() diff --git a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala index ab8437b..35497ca 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala @@ -31,3 +31,9 @@ trait RuleSet: /** True if halfMoveClock >= 100 (50-move rule). */ def isFiftyMoveRule(context: GameContext): Boolean + + /** Apply a legal move to produce the next game context. + * Handles all special move types: castling, en passant, promotion. + * Updates castling rights, en passant square, half-move clock, turn, and move history. + */ + def applyMove(context: GameContext, move: Move): GameContext diff --git a/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala index 0d3a7d0..c78b9b4 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/StandardRules.scala @@ -1,7 +1,7 @@ package de.nowchess.rules import de.nowchess.api.game.GameContext -import de.nowchess.api.board.{Board, Color, Square, PieceType, Piece} +import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square, PieceType, Piece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import scala.annotation.tailrec @@ -274,6 +274,95 @@ object StandardRules extends RuleSet: val nextContext = context.withBoard(nextBoard) isCheck(nextContext) + // ── Move application ─────────────────────────────────────────────── + + override def applyMove(context: GameContext, move: Move): GameContext = + val color = context.turn + val board = context.board + + val newBoard = move.moveType match + case MoveType.CastleKingside => applyCastle(board, color, kingside = true) + case MoveType.CastleQueenside => applyCastle(board, color, kingside = false) + case MoveType.EnPassant => applyEnPassant(board, move, color) + case MoveType.Promotion(pp) => applyPromotion(board, move, color, pp) + case MoveType.Normal => board.applyMove(move) + + val newCastlingRights = updateCastlingRights(context.castlingRights, board, move, color) + val newEnPassantSquare = computeEnPassantSquare(board, move, color) + val isCapture = board.pieceAt(move.to).isDefined || move.moveType == MoveType.EnPassant + val isPawnMove = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) + val newClock = if isPawnMove || isCapture then 0 else context.halfMoveClock + 1 + + context + .withBoard(newBoard) + .withTurn(color.opposite) + .withCastlingRights(newCastlingRights) + .withEnPassantSquare(newEnPassantSquare) + .withHalfMoveClock(newClock) + .withMove(move) + + private def applyCastle(board: Board, color: Color, kingside: Boolean): Board = + val rank = if color == Color.White then Rank.R1 else Rank.R8 + val (kingFrom, kingTo, rookFrom, rookTo) = + if kingside then + (Square(File.E, rank), Square(File.G, rank), Square(File.H, rank), Square(File.F, rank)) + else + (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank)) + val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King)) + val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook)) + board + .removed(kingFrom).removed(rookFrom) + .updated(kingTo, king) + .updated(rookTo, rook) + + private def applyEnPassant(board: Board, move: Move, color: Color): Board = + val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn + val capturedSquare = Square(move.to.file, capturedRank) + board.applyMove(move).removed(capturedSquare) + + private def applyPromotion(board: Board, move: Move, color: Color, pp: PromotionPiece): Board = + val promotedType = pp match + case PromotionPiece.Queen => PieceType.Queen + case PromotionPiece.Rook => PieceType.Rook + case PromotionPiece.Bishop => PieceType.Bishop + case PromotionPiece.Knight => PieceType.Knight + board.removed(move.from).updated(move.to, Piece(color, promotedType)) + + private def updateCastlingRights(rights: CastlingRights, board: Board, move: Move, color: Color): CastlingRights = + val piece = board.pieceAt(move.from) + val isKingMove = piece.exists(_.pieceType == PieceType.King) + val isRookMove = piece.exists(_.pieceType == PieceType.Rook) + + // Helper to check if a square is a rook's starting square + val whiteKingsideRook = Square(File.H, Rank.R1) + val whiteQueensideRook = Square(File.A, Rank.R1) + val blackKingsideRook = Square(File.H, Rank.R8) + val blackQueensideRook = Square(File.A, Rank.R8) + + var r = rights + if isKingMove then r = r.revokeColor(color) + else if isRookMove then + if move.from == whiteKingsideRook then r = r.revokeKingSide(Color.White) + if move.from == whiteQueensideRook then r = r.revokeQueenSide(Color.White) + if move.from == blackKingsideRook then r = r.revokeKingSide(Color.Black) + if move.from == blackQueensideRook then r = r.revokeQueenSide(Color.Black) + // Also revoke if a rook is captured + if move.to == whiteKingsideRook then r = r.revokeKingSide(Color.White) + if move.to == whiteQueensideRook then r = r.revokeQueenSide(Color.White) + if move.to == blackKingsideRook then r = r.revokeKingSide(Color.Black) + if move.to == blackQueensideRook then r = r.revokeQueenSide(Color.Black) + r + + private def computeEnPassantSquare(board: Board, move: Move, color: Color): Option[Square] = + val piece = board.pieceAt(move.from) + val isDoublePawnPush = piece.exists(_.pieceType == PieceType.Pawn) && + math.abs(move.to.rank.ordinal - move.from.rank.ordinal) == 2 + if isDoublePawnPush then + // EP square is the square the pawn passed through + val epRankOrd = (move.from.rank.ordinal + move.to.rank.ordinal) / 2 + Some(Square(move.from.file, Rank.values(epRankOrd))) + else None + // ── Insufficient material ────────────────────────────────────────── private def insufficientMaterial(board: Board): Boolean = diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts index 4faa5c7..a083675 100644 --- a/modules/ui/build.gradle.kts +++ b/modules/ui/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { } implementation(project(":modules:core")) + implementation(project(":modules:rule")) implementation(project(":modules:api")) // ScalaFX dependencies diff --git a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala index 69ad40c..a8f40e0 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/gui/ChessBoardView.scala @@ -269,7 +269,6 @@ class ChessBoardView(val stage: Stage, private val engine: GameEngine) extends B private def doPgnImport(): Unit = showMessage("PGN import temporarily disabled during NCS-22 refactoring") - } private def showCopyDialog(title: String, content: String): Unit = val area = new javafx.scene.control.TextArea(content) diff --git a/modules/ui/src/test/scala/de/nowchess/ui/MainTest.scala b/modules/ui/src/test/scala/de/nowchess/ui/MainTest.scala deleted file mode 100644 index dea2b2f..0000000 --- a/modules/ui/src/test/scala/de/nowchess/ui/MainTest.scala +++ /dev/null @@ -1,22 +0,0 @@ -package de.nowchess.ui - -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers -import java.io.{ByteArrayInputStream, ByteArrayOutputStream} - -class MainTest extends AnyFunSuite with Matchers { - - test("main should execute and quit immediately when fed 'quit'") { - val in = new ByteArrayInputStream("quit\n".getBytes) - val out = new ByteArrayOutputStream() - - Console.withIn(in) { - Console.withOut(out) { - Main.main(Array.empty) - } - } - - val output = out.toString - output should include ("Game over. Goodbye!") - } -} diff --git a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala deleted file mode 100644 index 514ad0d..0000000 --- a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala +++ /dev/null @@ -1,327 +0,0 @@ -package de.nowchess.ui.terminal - -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers -import java.io.{ByteArrayInputStream, ByteArrayOutputStream} -import de.nowchess.chess.engine.GameEngine -import de.nowchess.chess.observer.* -import de.nowchess.api.board.{Board, Color, File, Rank, Square} -import de.nowchess.chess.logic.GameHistory - -class TerminalUITest extends AnyFunSuite with Matchers { - - test("TerminalUI should start, print initial state, and correctly respond to 'q'") { - val in = new ByteArrayInputStream("q\n".getBytes) - val out = new ByteArrayOutputStream() - - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - val output = out.toString - output should include("White's turn.") - output should include("Game over. Goodbye!") - } - - test("TerminalUI should ignore empty inputs and re-print prompt") { - val in = new ByteArrayInputStream("\nq\n".getBytes) - val out = new ByteArrayOutputStream() - - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - val output = out.toString - // Prompt appears three times: Initial, after empty, on exit. - output.split("White's turn.").length should be > 2 - } - - test("TerminalUI should explicitly handle empty input by re-prompting") { - val in = new ByteArrayInputStream("\n\nq\n".getBytes) - val out = new ByteArrayOutputStream() - - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - val output = out.toString - // With two empty inputs, prompt should appear at least 4 times: - // 1. Initial board display - // 2. After first empty input - // 3. After second empty input - // 4. Before quit - val promptCount = output.split("White's turn.").length - promptCount should be >= 4 - output should include("Game over. Goodbye!") - } - - test("TerminalUI printPrompt should include undo and redo hints if engine returns true") { - val in = new ByteArrayInputStream("\nq\n".getBytes) - val out = new ByteArrayOutputStream() - - val engine = new GameEngine() { - // Stub engine to force undo/redo to true - override def canUndo: Boolean = true - override def canRedo: Boolean = true - } - - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - val output = out.toString - output should include("[undo]") - output should include("[redo]") - } - - test("TerminalUI onGameEvent should properly format InvalidMoveEvent") { - val out = new ByteArrayOutputStream() - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withOut(out) { - ui.onGameEvent(InvalidMoveEvent(Board(Map.empty), GameHistory(), Color.Black, "Invalid move format")) - } - - out.toString should include("⚠️") - out.toString should include("Invalid move format") - } - - test("TerminalUI onGameEvent should properly format CheckDetectedEvent") { - val out = new ByteArrayOutputStream() - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withOut(out) { - ui.onGameEvent(CheckDetectedEvent(Board(Map.empty), GameHistory(), Color.Black)) - } - - out.toString should include("Black is in check!") - } - - test("TerminalUI onGameEvent should properly format CheckmateEvent") { - val out = new ByteArrayOutputStream() - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withOut(out) { - ui.onGameEvent(CheckmateEvent(Board(Map.empty), GameHistory(), Color.Black, Color.White)) - } - - val ostr = out.toString - ostr should include("Checkmate! White wins.") - } - - test("TerminalUI onGameEvent should properly format StalemateEvent") { - val out = new ByteArrayOutputStream() - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withOut(out) { - ui.onGameEvent(StalemateEvent(Board(Map.empty), GameHistory(), Color.Black)) - } - - out.toString should include("Stalemate! The game is a draw.") - } - - test("TerminalUI onGameEvent should properly format BoardResetEvent") { - val out = new ByteArrayOutputStream() - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withOut(out) { - ui.onGameEvent(BoardResetEvent(Board(Map.empty), GameHistory(), Color.White)) - } - - out.toString should include("Board has been reset to initial position.") - } - - test("TerminalUI onGameEvent should properly format MoveExecutedEvent with capturing piece") { - val out = new ByteArrayOutputStream() - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withOut(out) { - ui.onGameEvent(MoveExecutedEvent(Board(Map.empty), GameHistory(), Color.Black, "A1", "A8", Some("Knight(White)"))) - } - - out.toString should include("Captured: Knight(White) on A8") // Depending on how piece/coord serialize - } - - test("TerminalUI processes valid move input via processUserInput") { - val in = new ByteArrayInputStream("e2e4\nq\n".getBytes) - val out = new ByteArrayOutputStream() - - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - val output = out.toString - output should include("White's turn.") - output should include("Game over. Goodbye!") - // The move should have been processed and the board displayed - engine.turn shouldBe Color.Black - } - - test("TerminalUI shows promotion prompt on PromotionRequiredEvent") { - val out = new ByteArrayOutputStream() - val engine = new GameEngine() - val ui = new TerminalUI(engine) - - Console.withOut(out) { - ui.onGameEvent(PromotionRequiredEvent( - Board(Map.empty), GameHistory(), Color.White, - Square(File.E, Rank.R7), Square(File.E, Rank.R8) - )) - } - - out.toString should include("Promote to") - } - - test("TerminalUI routes promotion choice to engine.completePromotion") { - import de.nowchess.api.move.PromotionPiece - - var capturedPiece: Option[PromotionPiece] = None - - val engine = new GameEngine() { - override def processUserInput(rawInput: String): Unit = - if rawInput.trim == "e7e8" then - notifyObservers(PromotionRequiredEvent( - Board(Map.empty), GameHistory.empty, Color.White, - Square(File.E, Rank.R7), Square(File.E, Rank.R8) - )) - override def completePromotion(piece: PromotionPiece): Unit = - capturedPiece = Some(piece) - notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None)) - } - - val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes) - val out = new ByteArrayOutputStream() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - capturedPiece should be(Some(PromotionPiece.Queen)) - out.toString should include("Promote to") - } - - test("TerminalUI re-prompts on invalid promotion choice") { - import de.nowchess.api.move.PromotionPiece - - var capturedPiece: Option[PromotionPiece] = None - - val engine = new GameEngine() { - override def processUserInput(rawInput: String): Unit = - if rawInput.trim == "e7e8" then - notifyObservers(PromotionRequiredEvent( - Board(Map.empty), GameHistory.empty, Color.White, - Square(File.E, Rank.R7), Square(File.E, Rank.R8) - )) - override def completePromotion(piece: PromotionPiece): Unit = - capturedPiece = Some(piece) - notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None)) - } - - // "x" is invalid, then "r" for rook - val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes) - val out = new ByteArrayOutputStream() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - capturedPiece should be(Some(PromotionPiece.Rook)) - out.toString should include("Invalid") - } - - test("TerminalUI routes Bishop promotion choice to engine.completePromotion") { - import de.nowchess.api.move.PromotionPiece - - var capturedPiece: Option[PromotionPiece] = None - - val engine = new GameEngine() { - override def processUserInput(rawInput: String): Unit = - if rawInput.trim == "e7e8" then - notifyObservers(PromotionRequiredEvent( - Board(Map.empty), GameHistory.empty, Color.White, - Square(File.E, Rank.R7), Square(File.E, Rank.R8) - )) - override def completePromotion(piece: PromotionPiece): Unit = - capturedPiece = Some(piece) - notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None)) - } - - val in = new ByteArrayInputStream("e7e8\nb\nquit\n".getBytes) - val out = new ByteArrayOutputStream() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - capturedPiece should be(Some(PromotionPiece.Bishop)) - } - - test("TerminalUI routes Knight promotion choice to engine.completePromotion") { - import de.nowchess.api.move.PromotionPiece - - var capturedPiece: Option[PromotionPiece] = None - - val engine = new GameEngine() { - override def processUserInput(rawInput: String): Unit = - if rawInput.trim == "e7e8" then - notifyObservers(PromotionRequiredEvent( - Board(Map.empty), GameHistory.empty, Color.White, - Square(File.E, Rank.R7), Square(File.E, Rank.R8) - )) - override def completePromotion(piece: PromotionPiece): Unit = - capturedPiece = Some(piece) - notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None)) - } - - val in = new ByteArrayInputStream("e7e8\nn\nquit\n".getBytes) - val out = new ByteArrayOutputStream() - val ui = new TerminalUI(engine) - - Console.withIn(in) { - Console.withOut(out) { - ui.start() - } - } - - capturedPiece should be(Some(PromotionPiece.Knight)) - } -}