Compare commits
2 Commits
core-0.4.0
...
api-0.0.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1361dfc895 | |||
| 707c4826a4 |
Generated
+1
@@ -12,6 +12,7 @@
|
||||
<option value="$PROJECT_DIR$/modules" />
|
||||
<option value="$PROJECT_DIR$/modules/api" />
|
||||
<option value="$PROJECT_DIR$/modules/core" />
|
||||
<option value="$PROJECT_DIR$/modules/ui" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
</profile>
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test">
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
<parameters>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
## (2026-03-27)
|
||||
## (2026-03-28)
|
||||
## (2026-03-28)
|
||||
## (2026-03-29)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=3
|
||||
PATCH=4
|
||||
|
||||
@@ -44,3 +44,20 @@
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
## (2026-03-29)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
package de.nowchess.chess
|
||||
|
||||
import de.nowchess.api.board.Board
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.chess.controller.GameController
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
object Main {
|
||||
def main(args: Array[String]): Unit =
|
||||
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
|
||||
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Square, Board, Color, Piece}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
/** Marker trait for all commands that can be executed and undone.
|
||||
* Commands encapsulate user actions and game state transitions.
|
||||
*/
|
||||
trait Command:
|
||||
/** Execute the command and return true if successful, false otherwise. */
|
||||
def execute(): Boolean
|
||||
|
||||
/** Undo the command and return true if successful, false otherwise. */
|
||||
def undo(): Boolean
|
||||
|
||||
/** A human-readable description of this command. */
|
||||
def description: String
|
||||
|
||||
/** Command to move a piece from one square to another.
|
||||
* Stores the move result so undo can restore previous state.
|
||||
*/
|
||||
case class MoveCommand(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousBoard: Option[Board] = None,
|
||||
previousHistory: Option[GameHistory] = None,
|
||||
previousTurn: Option[Color] = None
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean =
|
||||
moveResult.isDefined
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.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 object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
|
||||
/** Command to quit the game. */
|
||||
case class QuitCommand() extends Command:
|
||||
override def execute(): Boolean = true
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Quit game"
|
||||
|
||||
/** Command to reset the board to initial position. */
|
||||
case class ResetCommand(
|
||||
previousBoard: Option[Board] = None,
|
||||
previousHistory: Option[GameHistory] = None,
|
||||
previousTurn: Option[Color] = None
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean = true
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
|
||||
|
||||
override def description: String = "Reset board"
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
/** Manages command execution and history for undo/redo support. */
|
||||
class CommandInvoker:
|
||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||
private var currentIndex = -1
|
||||
|
||||
/** Execute a command and add it to history.
|
||||
* Discards any redo history if not at the end of the stack.
|
||||
*/
|
||||
def execute(command: Command): Boolean = synchronized {
|
||||
if command.execute() then
|
||||
// Remove any commands after current index (redo stack is discarded)
|
||||
while currentIndex < executedCommands.size - 1 do
|
||||
executedCommands.remove(executedCommands.size - 1)
|
||||
executedCommands += command
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
/** Undo the last executed command if possible. */
|
||||
def undo(): Boolean = synchronized {
|
||||
if currentIndex >= 0 && currentIndex < executedCommands.size then
|
||||
val command = executedCommands(currentIndex)
|
||||
if command.undo() then
|
||||
currentIndex -= 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
/** Redo the next command in history if available. */
|
||||
def redo(): Boolean = synchronized {
|
||||
if currentIndex + 1 < executedCommands.size then
|
||||
val command = executedCommands(currentIndex + 1)
|
||||
if command.execute() then
|
||||
currentIndex += 1
|
||||
true
|
||||
else
|
||||
false
|
||||
else
|
||||
false
|
||||
}
|
||||
|
||||
/** Get the history of all executed commands. */
|
||||
def history: List[Command] = synchronized {
|
||||
executedCommands.toList
|
||||
}
|
||||
|
||||
/** Get the current position in command history. */
|
||||
def getCurrentIndex: Int = synchronized {
|
||||
currentIndex
|
||||
}
|
||||
|
||||
/** Clear all command history. */
|
||||
def clear(): Unit = synchronized {
|
||||
executedCommands.clear()
|
||||
currentIndex = -1
|
||||
}
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized {
|
||||
currentIndex >= 0
|
||||
}
|
||||
|
||||
/** Check if redo is available. */
|
||||
def canRedo: Boolean = synchronized {
|
||||
currentIndex + 1 < executedCommands.size
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package de.nowchess.chess.controller
|
||||
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
|
||||
import de.nowchess.chess.logic.*
|
||||
import de.nowchess.chess.view.Renderer
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result ADT returned by the pure processMove function
|
||||
@@ -66,46 +64,3 @@ object GameController:
|
||||
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
|
||||
case PositionStatus.Mated => MoveResult.Checkmate(turn)
|
||||
case PositionStatus.Drawn => MoveResult.Stalemate
|
||||
|
||||
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
|
||||
* prints the outcome, and recurses until the game ends.
|
||||
*/
|
||||
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
|
||||
println()
|
||||
print(Renderer.render(board))
|
||||
println(s"${turn.label}'s turn. Enter move: ")
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
processMove(board, history, turn, input) match
|
||||
case MoveResult.Quit =>
|
||||
println("Game over. Goodbye!")
|
||||
case MoveResult.InvalidFormat(raw) =>
|
||||
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.NoPiece =>
|
||||
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.WrongColor =>
|
||||
println(s"That is not your piece.")
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.IllegalMove =>
|
||||
println(s"Illegal move.")
|
||||
gameLoop(board, history, turn)
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
val prevTurn = newTurn.opposite
|
||||
captured.foreach: cap =>
|
||||
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
|
||||
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
|
||||
gameLoop(newBoard, newHistory, newTurn)
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
val prevTurn = newTurn.opposite
|
||||
captured.foreach: cap =>
|
||||
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
|
||||
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
|
||||
println(s"${newTurn.label} is in check!")
|
||||
gameLoop(newBoard, newHistory, newTurn)
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
println(s"Checkmate! ${winner.label} wins.")
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
case MoveResult.Stalemate =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.{Board, Color, Piece, Square}
|
||||
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
|
||||
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
class GameEngine extends Observable:
|
||||
private var currentBoard: Board = Board.initial
|
||||
private var currentHistory: GameHistory = GameHistory.empty
|
||||
private var currentTurn: Color = Color.White
|
||||
private val invoker = new CommandInvoker()
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized { currentBoard }
|
||||
def history: GameHistory = synchronized { currentHistory }
|
||||
def turn: Color = synchronized { currentTurn }
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized { invoker.canUndo }
|
||||
|
||||
/** Check if redo is available. */
|
||||
def canRedo: Boolean = synchronized { invoker.canRedo }
|
||||
|
||||
/** Get the command history for inspection (testing/debugging). */
|
||||
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
|
||||
|
||||
/** Process a raw move input string and update game state if valid.
|
||||
* Notifies all observers of the outcome via GameEvent.
|
||||
*/
|
||||
def processUserInput(rawInput: String): Unit = synchronized {
|
||||
val trimmed = rawInput.trim.toLowerCase
|
||||
trimmed match
|
||||
case "quit" | "q" =>
|
||||
// Client should handle quit logic; we just return
|
||||
()
|
||||
|
||||
case "undo" =>
|
||||
performUndo()
|
||||
|
||||
case "redo" =>
|
||||
performRedo()
|
||||
|
||||
case "" =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"Please enter a valid move or command."
|
||||
)
|
||||
notifyObservers(event)
|
||||
|
||||
case moveInput =>
|
||||
// Try to parse as a move
|
||||
Parser.parseMove(moveInput) match
|
||||
case None =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||
)
|
||||
notifyObservers(event)
|
||||
|
||||
case Some((from, to)) =>
|
||||
// Create a move command with current state snapshot
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
previousBoard = Some(currentBoard),
|
||||
previousHistory = Some(currentHistory),
|
||||
previousTurn = Some(currentTurn)
|
||||
)
|
||||
|
||||
// Execute the move through GameController
|
||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
||||
handleFailedMove(moveInput)
|
||||
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
// Move succeeded - store result and execute through invoker
|
||||
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)
|
||||
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
// Move succeeded with check
|
||||
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(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
// Move resulted in checkmate
|
||||
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(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
case MoveResult.Stalemate =>
|
||||
// Move resulted in 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(currentBoard, currentHistory, currentTurn))
|
||||
}
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized {
|
||||
performUndo()
|
||||
}
|
||||
|
||||
/** Redo the last undone move. */
|
||||
def redo(): Unit = synchronized {
|
||||
performRedo()
|
||||
}
|
||||
|
||||
/** Reset the board to initial position. */
|
||||
def reset(): Unit = synchronized {
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn
|
||||
))
|
||||
}
|
||||
|
||||
// ──── Private Helpers ────
|
||||
|
||||
private def performUndo(): Unit =
|
||||
if invoker.canUndo then
|
||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
||||
(cmd: @unchecked) match
|
||||
case moveCmd: MoveCommand =>
|
||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
||||
invoker.undo()
|
||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
||||
|
||||
private def performRedo(): Unit =
|
||||
if invoker.canRedo then
|
||||
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)
|
||||
invoker.redo()
|
||||
emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, cap, nt)
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "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(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
newTurn,
|
||||
fromSq,
|
||||
toSq,
|
||||
capturedDesc
|
||||
))
|
||||
|
||||
private def handleFailedMove(moveInput: String): Unit =
|
||||
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
|
||||
case MoveResult.NoPiece =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"No piece on that square."
|
||||
))
|
||||
case MoveResult.WrongColor =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"That is not your piece."
|
||||
))
|
||||
case MoveResult.IllegalMove =>
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
"Illegal move."
|
||||
))
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
|
||||
/** Base trait for all game state events.
|
||||
* Events are immutable snapshots of game state changes.
|
||||
*/
|
||||
sealed trait GameEvent:
|
||||
def board: Board
|
||||
def history: GameHistory
|
||||
def turn: Color
|
||||
|
||||
/** Fired when a move is successfully executed. */
|
||||
case class MoveExecutedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
fromSquare: String,
|
||||
toSquare: String,
|
||||
capturedPiece: Option[String]
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the current player is in check. */
|
||||
case class CheckDetectedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches checkmate. */
|
||||
case class CheckmateEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
winner: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the game reaches stalemate. */
|
||||
case class StalemateEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a move is invalid. */
|
||||
case class InvalidMoveEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color,
|
||||
reason: String
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when the board is reset. */
|
||||
case class BoardResetEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
trait Observer:
|
||||
def onGameEvent(event: GameEvent): Unit
|
||||
|
||||
/** Observable trait: manages observers and notifies them of events. */
|
||||
trait Observable:
|
||||
private val observers = scala.collection.mutable.Set[Observer]()
|
||||
|
||||
/** Register an observer to receive game events. */
|
||||
def subscribe(observer: Observer): Unit = synchronized {
|
||||
observers += observer
|
||||
}
|
||||
|
||||
/** Unregister an observer. */
|
||||
def unsubscribe(observer: Observer): Unit = synchronized {
|
||||
observers -= observer
|
||||
}
|
||||
|
||||
/** Notify all observers of a game event. */
|
||||
protected def notifyObservers(event: GameEvent): Unit = synchronized {
|
||||
observers.foreach(_.onGameEvent(event))
|
||||
}
|
||||
|
||||
/** Return current list of observers (for testing). */
|
||||
def observerCount: Int = synchronized {
|
||||
observers.size
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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 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(
|
||||
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)
|
||||
)
|
||||
cmd
|
||||
|
||||
// ──── BRANCH: execute() returns false ────
|
||||
test("CommandInvoker.execute() with failing command returns false"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = FailingCommand()
|
||||
invoker.execute(cmd) shouldBe false
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker.execute() does not add failed command to history"):
|
||||
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"):
|
||||
val invoker = new CommandInvoker()
|
||||
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
|
||||
|
||||
// ──── 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
|
||||
|
||||
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
|
||||
|
||||
test("CommandInvoker.redo() returns false when currentIndex + 1 >= 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 = 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
|
||||
|
||||
invoker.execute(cmd1)
|
||||
invoker.execute(redoFailCmd) // Succeeds and added to history
|
||||
|
||||
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
|
||||
|
||||
test("CommandInvoker.execute() discards multiple redo commands"):
|
||||
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.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.history.size shouldBe 3
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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 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)
|
||||
)
|
||||
|
||||
test("CommandInvoker executes a command and adds it to history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd) shouldBe true
|
||||
invoker.history.size shouldBe 1
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker executes multiple commands in sequence"):
|
||||
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) shouldBe true
|
||||
invoker.execute(cmd2) shouldBe true
|
||||
invoker.history.size shouldBe 2
|
||||
invoker.getCurrentIndex shouldBe 1
|
||||
|
||||
test("CommandInvoker.canUndo returns false when empty"):
|
||||
val invoker = new CommandInvoker()
|
||||
invoker.canUndo shouldBe false
|
||||
|
||||
test("CommandInvoker.canUndo returns true after execution"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.canUndo shouldBe true
|
||||
|
||||
test("CommandInvoker.undo decrements current index"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
invoker.undo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker.canRedo returns true after undo"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo()
|
||||
invoker.canRedo shouldBe true
|
||||
|
||||
test("CommandInvoker.redo re-executes a command"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo() shouldBe true
|
||||
invoker.redo() shouldBe true
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker.canUndo returns false when at beginning"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.undo()
|
||||
invoker.canUndo shouldBe false
|
||||
|
||||
test("CommandInvoker clear removes all history"):
|
||||
val invoker = new CommandInvoker()
|
||||
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
|
||||
invoker.execute(cmd)
|
||||
invoker.clear()
|
||||
invoker.history.size shouldBe 0
|
||||
invoker.getCurrentIndex shouldBe -1
|
||||
|
||||
test("CommandInvoker discards all history when executing after undoing all"):
|
||||
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)
|
||||
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
|
||||
invoker.history(0) shouldBe cmd3
|
||||
invoker.getCurrentIndex shouldBe 0
|
||||
|
||||
test("CommandInvoker discards redo history when executing mid-history"):
|
||||
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)
|
||||
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
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
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
|
||||
import scala.collection.mutable
|
||||
|
||||
class CommandInvokerThreadSafetyTest 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)
|
||||
)
|
||||
|
||||
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
|
||||
val invoker = new CommandInvoker()
|
||||
@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)
|
||||
)
|
||||
invoker.execute(cmd)
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
executorThread.start()
|
||||
readerThread.start()
|
||||
executorThread.join()
|
||||
readerThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
raceDetected shouldBe false
|
||||
|
||||
test("CommandInvoker is thread-safe for concurrent execute, undo, and redo"):
|
||||
val invoker = new CommandInvoker()
|
||||
@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 {
|
||||
for _ <- 1 to 500 do
|
||||
invoker.execute(createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)))
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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()
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 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()
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
exceptions += e
|
||||
raceDetected = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
executorThread.start()
|
||||
undoThread.start()
|
||||
redoThread.start()
|
||||
executorThread.join()
|
||||
undoThread.join()
|
||||
redoThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
raceDetected shouldBe false
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class CommandTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("QuitCommand can be created"):
|
||||
val cmd = QuitCommand()
|
||||
cmd shouldNot be(null)
|
||||
|
||||
test("QuitCommand execute returns true"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.execute() shouldBe true
|
||||
|
||||
test("QuitCommand undo returns false (cannot undo quit)"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("QuitCommand description"):
|
||||
val cmd = QuitCommand()
|
||||
cmd.description shouldBe "Quit game"
|
||||
|
||||
test("ResetCommand with no prior state"):
|
||||
val cmd = ResetCommand()
|
||||
cmd.execute() shouldBe true
|
||||
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)
|
||||
)
|
||||
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)
|
||||
)
|
||||
cmd.execute() shouldBe true
|
||||
cmd.undo() shouldBe false
|
||||
|
||||
test("ResetCommand description"):
|
||||
val cmd = ResetCommand()
|
||||
cmd.description shouldBe "Reset board"
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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 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)
|
||||
)
|
||||
|
||||
// Create second command with filled state
|
||||
val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
|
||||
val cmd2 = cmd1.copy(
|
||||
moveResult = Some(result),
|
||||
previousBoard = Some(Board.initial),
|
||||
previousHistory = Some(GameHistory.empty),
|
||||
previousTurn = Some(Color.White)
|
||||
)
|
||||
|
||||
// Original should be unchanged
|
||||
cmd1.moveResult shouldBe None
|
||||
cmd1.previousBoard shouldBe None
|
||||
cmd1.previousHistory shouldBe None
|
||||
cmd1.previousTurn 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)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
val cmd2 = MoveCommand(
|
||||
from = sq(File.E, Rank.R2),
|
||||
to = sq(File.E, Rank.R4),
|
||||
moveResult = None,
|
||||
previousBoard = None,
|
||||
previousHistory = None,
|
||||
previousTurn = 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
|
||||
@@ -6,17 +6,12 @@ import de.nowchess.chess.logic.{CastleSide, GameHistory}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
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 gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
|
||||
GameController.gameLoop(board, history, turn)
|
||||
|
||||
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
|
||||
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
|
||||
|
||||
@@ -69,59 +64,6 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
newTurn shouldBe Color.Black
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
// ──── gameLoop ───────────────────────────────────────────────────────
|
||||
|
||||
private def withInput(input: String)(block: => Unit): Unit =
|
||||
val stream = ByteArrayInputStream(input.getBytes("UTF-8"))
|
||||
scala.Console.withIn(stream)(block)
|
||||
|
||||
test("gameLoop: 'quit' exits cleanly without exception"):
|
||||
withInput("quit\n"):
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: EOF (null readLine) exits via quit fallback"):
|
||||
withInput(""):
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: invalid format prints message and recurses until quit"):
|
||||
withInput("badmove\nquit\n"):
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: NoPiece prints message and recurses until quit"):
|
||||
// E3 is empty in the initial position
|
||||
withInput("e3e4\nquit\n"):
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: WrongColor prints message and recurses until quit"):
|
||||
// E7 has a Black pawn; it is White's turn
|
||||
withInput("e7e6\nquit\n"):
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: IllegalMove prints message and recurses until quit"):
|
||||
withInput("e2e5\nquit\n"):
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: legal non-capture move recurses with new board then quits"):
|
||||
withInput("e2e4\nquit\n"):
|
||||
gameLoop(Board.initial, GameHistory.empty, Color.White)
|
||||
|
||||
test("gameLoop: capture move prints capture message then recurses and quits"):
|
||||
val captureBoard = 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
|
||||
))
|
||||
withInput("e5d6\nquit\n"):
|
||||
gameLoop(captureBoard, GameHistory.empty, Color.White)
|
||||
|
||||
// ──── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private def captureOutput(block: => Unit): String =
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
scala.Console.withOut(out)(block)
|
||||
out.toString("UTF-8")
|
||||
|
||||
// ──── processMove: check / checkmate / stalemate ─────────────────────
|
||||
|
||||
test("processMove: legal move that delivers check returns MovedInCheck"):
|
||||
@@ -161,56 +103,6 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
case MoveResult.Stalemate => succeed
|
||||
case other => fail(s"Expected Stalemate, got $other")
|
||||
|
||||
// ──── gameLoop: check / checkmate / stalemate ─────────────────────────
|
||||
|
||||
test("gameLoop: checkmate prints winner message and resets to new game"):
|
||||
// After Qa1-Qh8, position is checkmate; second "quit" exits the new game
|
||||
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
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1h8\nquit\n"):
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("Checkmate! White wins.")
|
||||
|
||||
test("gameLoop: stalemate prints draw message and resets to new game"):
|
||||
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
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("b1b6\nquit\n"):
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("Stalemate! The game is a draw.")
|
||||
|
||||
test("gameLoop: MovedInCheck without capture prints check message"):
|
||||
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
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1a8\nquit\n"):
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("Black is in check!")
|
||||
|
||||
test("gameLoop: MovedInCheck with capture prints both capture and check message"):
|
||||
// White Rook A1 captures Black Pawn on A8, Ra8 then attacks rank 8 putting Kh8 in check
|
||||
val b = Board(Map(
|
||||
sq(File.A, Rank.R1) -> Piece.WhiteRook,
|
||||
sq(File.C, Rank.R3) -> Piece.WhiteKing,
|
||||
sq(File.A, Rank.R8) -> Piece.BlackPawn,
|
||||
sq(File.H, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val output = captureOutput:
|
||||
withInput("a1a8\nquit\n"):
|
||||
gameLoop(b, GameHistory.empty, Color.White)
|
||||
output should include("captures")
|
||||
output should include("Black is in check!")
|
||||
|
||||
// ──── castling execution ─────────────────────────────────────────────
|
||||
|
||||
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
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
|
||||
|
||||
/** Tests for GameEngine edge cases and uncovered paths */
|
||||
class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles empty input"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Please enter a valid move or command")
|
||||
|
||||
test("GameEngine processes quit command"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("quit")
|
||||
// Quit just returns, no events
|
||||
observer.events.isEmpty shouldBe true
|
||||
|
||||
test("GameEngine processes q command (short form)"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("q")
|
||||
observer.events.isEmpty shouldBe true
|
||||
|
||||
test("GameEngine handles uppercase quit"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("QUIT")
|
||||
observer.events.isEmpty shouldBe true
|
||||
|
||||
test("GameEngine handles undo on empty history"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.canUndo shouldBe false
|
||||
engine.processUserInput("undo")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Nothing to undo")
|
||||
|
||||
test("GameEngine handles redo on empty redo history"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.canRedo shouldBe false
|
||||
engine.processUserInput("redo")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Nothing to redo")
|
||||
|
||||
test("GameEngine parses invalid move format"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("invalid_move_format")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Invalid move format")
|
||||
|
||||
test("GameEngine handles lowercase input normalization"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput(" UNDO ") // With spaces and uppercase
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent] // No moves to undo yet
|
||||
|
||||
test("GameEngine preserves board state on invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val initialBoard = engine.board
|
||||
|
||||
engine.processUserInput("invalid")
|
||||
|
||||
engine.board shouldBe initialBoard
|
||||
|
||||
test("GameEngine preserves turn on invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val initialTurn = engine.turn
|
||||
|
||||
engine.processUserInput("invalid")
|
||||
|
||||
engine.turn shouldBe initialTurn
|
||||
|
||||
test("GameEngine undo with no commands available"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Make a valid move
|
||||
engine.processUserInput("e2e4")
|
||||
observer.events.clear()
|
||||
|
||||
// Undo it
|
||||
engine.processUserInput("undo")
|
||||
|
||||
// Board should be reset
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine redo after undo"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
val turnAfterMove = engine.turn
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
|
||||
engine.board shouldBe boardAfterMove
|
||||
engine.turn shouldBe turnAfterMove
|
||||
|
||||
test("GameEngine canUndo flag tracks state correctly"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.canUndo shouldBe false
|
||||
engine.processUserInput("e2e4")
|
||||
engine.canUndo shouldBe true
|
||||
engine.processUserInput("undo")
|
||||
engine.canUndo shouldBe false
|
||||
|
||||
test("GameEngine canRedo flag tracks state correctly"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.canRedo shouldBe false
|
||||
engine.processUserInput("e2e4")
|
||||
engine.canRedo shouldBe false
|
||||
engine.processUserInput("undo")
|
||||
engine.canRedo shouldBe true
|
||||
|
||||
test("GameEngine command history is accessible"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.commandHistory.isEmpty shouldBe true
|
||||
engine.processUserInput("e2e4")
|
||||
engine.commandHistory.size shouldBe 1
|
||||
|
||||
test("GameEngine processes multiple moves in sequence"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
|
||||
observer.events.size shouldBe 2
|
||||
engine.commandHistory.size shouldBe 2
|
||||
|
||||
test("GameEngine can undo multiple moves"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
|
||||
engine.processUserInput("undo")
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
engine.processUserInput("undo")
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine thread-safe operations"):
|
||||
val engine = new GameEngine()
|
||||
|
||||
// Access from synchronized methods
|
||||
val board = engine.board
|
||||
val history = engine.history
|
||||
val turn = engine.turn
|
||||
val canUndo = engine.canUndo
|
||||
val canRedo = engine.canRedo
|
||||
|
||||
board shouldBe Board.initial
|
||||
canUndo shouldBe false
|
||||
canRedo shouldBe false
|
||||
|
||||
|
||||
private class MockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
events += event
|
||||
@@ -0,0 +1,93 @@
|
||||
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
|
||||
|
||||
/** Tests for GameEngine check/checkmate/stalemate paths */
|
||||
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles Checkmate (Fool's Mate)"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Play Fool's mate
|
||||
engine.processUserInput("f2f3")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("g2g4")
|
||||
|
||||
observer.events.clear()
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
// Verify CheckmateEvent
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[CheckmateEvent]
|
||||
|
||||
val event = observer.events.head.asInstanceOf[CheckmateEvent]
|
||||
event.winner shouldBe Color.Black
|
||||
|
||||
// Board should be reset after checkmate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine handles check detection"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Play a simple check
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.processUserInput("f1c4")
|
||||
engine.processUserInput("g8f6")
|
||||
|
||||
observer.events.clear()
|
||||
engine.processUserInput("c4f7") // Check!
|
||||
|
||||
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
||||
checkEvents.size shouldBe 1
|
||||
checkEvents.head.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
|
||||
// Wait, let's just use Sam Loyd's 10-move stalemate:
|
||||
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
|
||||
test("GameEngine handles Stalemate via 10-move known sequence"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new EndingMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
val moves = List(
|
||||
"e2e3", "a7a5",
|
||||
"d1h5", "a8a6",
|
||||
"h5a5", "h7h5",
|
||||
"h2h4", "a6h6",
|
||||
"a5c7", "f7f6",
|
||||
"c7d7", "e8f7",
|
||||
"d7b7", "d8d3",
|
||||
"b7b8", "d3h7",
|
||||
"b8c8", "f7g6",
|
||||
"c8e6"
|
||||
)
|
||||
|
||||
moves.dropRight(1).foreach(engine.processUserInput)
|
||||
|
||||
observer.events.clear()
|
||||
engine.processUserInput(moves.last)
|
||||
|
||||
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
|
||||
stalemateEvents.size shouldBe 1
|
||||
|
||||
// Board should be reset after stalemate
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
private class EndingMockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
events += event
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
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
|
||||
|
||||
/** Tests to maximize handleFailedMove coverage */
|
||||
class GameEngineHandleFailedMoveTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles InvalidFormat error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("not_a_valid_move_format")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg1 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg1 should include("Invalid move format")
|
||||
|
||||
test("GameEngine handles NoPiece error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("h3h4")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg2 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg2 should include("No piece on that square")
|
||||
|
||||
test("GameEngine handles WrongColor error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4") // White move
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("a1b2") // Try to move black's rook position with white's move (wrong color)
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg3 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg3 should include("That is not your piece")
|
||||
|
||||
test("GameEngine handles IllegalMove error type"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e1") // Try pawn backward
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val msg4 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
||||
msg4 should include("Illegal move")
|
||||
|
||||
test("GameEngine invalid move message for InvalidFormat"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("xyz123")
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("coordinate notation")
|
||||
|
||||
test("GameEngine invalid move message for NoPiece"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("a3a4") // a3 is empty
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("No piece")
|
||||
|
||||
test("GameEngine invalid move message for WrongColor"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
observer.events.clear()
|
||||
|
||||
engine.processUserInput("e4e5") // e4 has white pawn, it's black's turn
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("not your piece")
|
||||
|
||||
test("GameEngine invalid move message for IllegalMove"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.processUserInput("e2e1") // Pawn can't move backward
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Illegal move")
|
||||
|
||||
test("GameEngine board unchanged after each type of invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val initial = engine.board
|
||||
|
||||
engine.processUserInput("invalid")
|
||||
engine.board shouldBe initial
|
||||
|
||||
engine.processUserInput("h3h4")
|
||||
engine.board shouldBe initial
|
||||
|
||||
engine.processUserInput("e2e1")
|
||||
engine.board shouldBe initial
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
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
|
||||
|
||||
/** Tests for GameEngine invalid move handling via handleFailedMove */
|
||||
class GameEngineInvalidMovesTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("GameEngine handles no piece at source square"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Try to move from h1 which may be empty or not have our piece
|
||||
// We'll try from a clearly empty square
|
||||
engine.processUserInput("h1h2")
|
||||
|
||||
// Should get an InvalidMoveEvent about NoPiece
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine handles moving wrong color piece"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// White moves first
|
||||
engine.processUserInput("e2e4")
|
||||
observer.events.clear()
|
||||
|
||||
// White tries to move again (should fail - it's black's turn)
|
||||
// But we need to try a move that looks legal but has wrong color
|
||||
// This is hard to test because we'd need to be black and move white's piece
|
||||
// Let's skip this for now and focus on testable cases
|
||||
|
||||
// Actually, let's try moving a square that definitely has the wrong piece
|
||||
// Move a white pawn as black by reaching that position
|
||||
engine.processUserInput("e7e5")
|
||||
observer.events.clear()
|
||||
|
||||
// Now try to move white's e4 pawn as black (it's black's turn but e4 is white)
|
||||
engine.processUserInput("e4e5")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
val event = observer.events.head
|
||||
event shouldBe an[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine handles illegal move"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// A pawn can't move backward
|
||||
engine.processUserInput("e2e1")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("Illegal move")
|
||||
|
||||
test("GameEngine handles pawn trying to move 3 squares"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Pawn can only move 1 or 2 squares on first move, not 3
|
||||
engine.processUserInput("e2e5")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine handles moving from empty square"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// h3 is empty in starting position
|
||||
engine.processUserInput("h3h4")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
||||
event.reason should include("No piece on that square")
|
||||
|
||||
test("GameEngine processes valid move after invalid attempt"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
// Try invalid move
|
||||
engine.processUserInput("h3h4")
|
||||
observer.events.clear()
|
||||
|
||||
// Make valid move
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe an[MoveExecutedEvent]
|
||||
|
||||
test("GameEngine maintains state after failed move attempt"):
|
||||
val engine = new GameEngine()
|
||||
val initialTurn = engine.turn
|
||||
val initialBoard = engine.board
|
||||
|
||||
// Try invalid move
|
||||
engine.processUserInput("h3h4")
|
||||
|
||||
// State should not change
|
||||
engine.turn shouldBe initialTurn
|
||||
engine.board shouldBe initialBoard
|
||||
@@ -0,0 +1,310 @@
|
||||
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
|
||||
|
||||
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.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine accepts Observer subscription"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.observerCount shouldBe 1
|
||||
|
||||
test("GameEngine notifies observers on valid move"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.processUserInput("e2e4")
|
||||
mockObserver.events.size shouldBe 1
|
||||
mockObserver.events.head shouldBe a[MoveExecutedEvent]
|
||||
|
||||
test("GameEngine updates state after valid move"):
|
||||
val engine = new GameEngine()
|
||||
val initialTurn = engine.turn
|
||||
engine.processUserInput("e2e4")
|
||||
engine.turn shouldNot be(initialTurn)
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("GameEngine notifies observers on invalid move"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.processUserInput("invalid_move")
|
||||
mockObserver.events.size shouldBe 1
|
||||
|
||||
test("GameEngine notifies multiple observers"):
|
||||
val engine = new GameEngine()
|
||||
val observer1 = new MockObserver()
|
||||
val observer2 = new MockObserver()
|
||||
engine.subscribe(observer1)
|
||||
engine.subscribe(observer2)
|
||||
engine.processUserInput("e2e4")
|
||||
observer1.events.size shouldBe 1
|
||||
observer2.events.size shouldBe 1
|
||||
|
||||
test("GameEngine allows observer unsubscription"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.unsubscribe(mockObserver)
|
||||
engine.observerCount shouldBe 0
|
||||
|
||||
test("GameEngine unsubscribed observer receives no events"):
|
||||
val engine = new GameEngine()
|
||||
val mockObserver = new MockObserver()
|
||||
engine.subscribe(mockObserver)
|
||||
engine.unsubscribe(mockObserver)
|
||||
engine.processUserInput("e2e4")
|
||||
mockObserver.events.size shouldBe 0
|
||||
|
||||
test("GameEngine reset notifies observers and resets state"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.reset()
|
||||
engine.board shouldBe Board.initial
|
||||
engine.turn shouldBe Color.White
|
||||
observer.events.size shouldBe 1
|
||||
|
||||
test("GameEngine processes sequence of moves"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
observer.events.size shouldBe 2
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine is thread-safe for synchronized operations"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
val t = new Thread(() => engine.processUserInput("e2e4"))
|
||||
t.start()
|
||||
t.join()
|
||||
observer.events.size shouldBe 1
|
||||
|
||||
test("GameEngine canUndo returns false initially"):
|
||||
val engine = new GameEngine()
|
||||
engine.canUndo shouldBe false
|
||||
|
||||
test("GameEngine canUndo returns true after move"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.canUndo shouldBe true
|
||||
|
||||
test("GameEngine canRedo returns false initially"):
|
||||
val engine = new GameEngine()
|
||||
engine.canRedo shouldBe false
|
||||
|
||||
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
|
||||
|
||||
test("GameEngine undo notifies observers"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
observer.events.clear()
|
||||
engine.undo()
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[BoardResetEvent]
|
||||
|
||||
test("GameEngine redo replays undone move"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
engine.undo()
|
||||
engine.redo()
|
||||
engine.board shouldBe boardAfterMove
|
||||
engine.turn shouldBe Color.Black
|
||||
|
||||
test("GameEngine canUndo false when nothing to undo"):
|
||||
val engine = new GameEngine()
|
||||
engine.canUndo shouldBe false
|
||||
engine.processUserInput("e2e4")
|
||||
engine.undo()
|
||||
engine.canUndo shouldBe false
|
||||
|
||||
test("GameEngine canRedo true after undo"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.undo()
|
||||
engine.canRedo shouldBe true
|
||||
|
||||
test("GameEngine canRedo false after redo"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.undo()
|
||||
engine.redo()
|
||||
engine.canRedo shouldBe false
|
||||
|
||||
test("GameEngine undo on empty history sends invalid event"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.undo()
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine redo on empty redo sends invalid event"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.redo()
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine undo via processUserInput"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
engine.processUserInput("undo")
|
||||
engine.board shouldBe Board.initial
|
||||
|
||||
test("GameEngine redo via processUserInput"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
val boardAfterMove = engine.board
|
||||
engine.processUserInput("undo")
|
||||
engine.processUserInput("redo")
|
||||
engine.board shouldBe boardAfterMove
|
||||
|
||||
test("GameEngine handles empty input"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine multiple undo/redo sequence"):
|
||||
val engine = new GameEngine()
|
||||
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
|
||||
|
||||
test("GameEngine redo after multiple undos"):
|
||||
val engine = new GameEngine()
|
||||
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
|
||||
|
||||
test("GameEngine new move after undo clears redo history"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
engine.undo()
|
||||
engine.canRedo shouldBe true
|
||||
|
||||
engine.processUserInput("e7e6") // Different move
|
||||
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
|
||||
|
||||
test("GameEngine quit input"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
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"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
val initialEvents = observer.events.size
|
||||
engine.processUserInput("q")
|
||||
observer.events.size shouldBe initialEvents
|
||||
|
||||
test("GameEngine undo notifies with BoardResetEvent after successful undo"):
|
||||
val engine = new GameEngine()
|
||||
engine.processUserInput("e2e4")
|
||||
engine.processUserInput("e7e5")
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
observer.events.clear()
|
||||
|
||||
engine.undo()
|
||||
|
||||
// Should have received a BoardResetEvent on undo
|
||||
observer.events.size should be > 0
|
||||
observer.events.exists(_.isInstanceOf[BoardResetEvent]) shouldBe true
|
||||
|
||||
test("GameEngine redo notifies with MoveExecutedEvent after successful redo"):
|
||||
val engine = new GameEngine()
|
||||
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 MoveExecutedEvent for the redo
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[MoveExecutedEvent]
|
||||
engine.board shouldBe boardAfterSecondMove
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// Mock Observer for testing
|
||||
private class MockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
events += event
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
package de.nowchess.chess.main
|
||||
|
||||
import de.nowchess.chess.Main
|
||||
import java.io.ByteArrayInputStream
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class MainTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("main exits cleanly when 'quit' is entered"):
|
||||
scala.Console.withIn(ByteArrayInputStream("quit\n".getBytes("UTF-8"))):
|
||||
Main.main(Array.empty)
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package de.nowchess.chess.observer
|
||||
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import scala.collection.mutable
|
||||
|
||||
class ObservableThreadSafetyTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private class TestObservable extends Observable:
|
||||
def testNotifyObservers(event: GameEvent): Unit =
|
||||
notifyObservers(event)
|
||||
|
||||
private class CountingObserver extends Observer:
|
||||
@volatile private var eventCount = 0
|
||||
@volatile private var lastEvent: Option[GameEvent] = None
|
||||
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
eventCount += 1
|
||||
lastEvent = Some(event)
|
||||
|
||||
private def createTestEvent(): GameEvent =
|
||||
BoardResetEvent(
|
||||
board = Board.initial,
|
||||
history = GameHistory.empty,
|
||||
turn = Color.White
|
||||
)
|
||||
|
||||
test("Observable is thread-safe for concurrent subscribe and notify"):
|
||||
val observable = new TestObservable()
|
||||
val testEvent = createTestEvent()
|
||||
@volatile var raceConditionCaught = false
|
||||
|
||||
// Thread 1: repeatedly notifies observers with long iteration
|
||||
val notifierThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500000 do
|
||||
observable.testNotifyObservers(testEvent)
|
||||
} catch {
|
||||
case _: java.util.ConcurrentModificationException =>
|
||||
raceConditionCaught = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: rapidly subscribes/unsubscribes observers during notify
|
||||
val subscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500000 do
|
||||
val obs = new CountingObserver()
|
||||
observable.subscribe(obs)
|
||||
observable.unsubscribe(obs)
|
||||
} catch {
|
||||
case _: java.util.ConcurrentModificationException =>
|
||||
raceConditionCaught = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
notifierThread.start()
|
||||
subscriberThread.start()
|
||||
notifierThread.join()
|
||||
subscriberThread.join()
|
||||
|
||||
raceConditionCaught shouldBe false
|
||||
|
||||
test("Observable is thread-safe for concurrent subscribe, unsubscribe, and notify"):
|
||||
val observable = new TestObservable()
|
||||
val testEvent = createTestEvent()
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
val observers = mutable.ListBuffer[CountingObserver]()
|
||||
|
||||
// Pre-subscribe some observers
|
||||
for _ <- 1 to 10 do
|
||||
val obs = new CountingObserver()
|
||||
observers += obs
|
||||
observable.subscribe(obs)
|
||||
|
||||
// Thread 1: notifies observers
|
||||
val notifierThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 5000 do
|
||||
observable.testNotifyObservers(testEvent)
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: subscribes new observers
|
||||
val subscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 5000 do
|
||||
val obs = new CountingObserver()
|
||||
observable.subscribe(obs)
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 3: unsubscribes observers
|
||||
val unsubscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for i <- 1 to 5000 do
|
||||
if observers.nonEmpty then
|
||||
val obs = observers(i % observers.size)
|
||||
observable.unsubscribe(obs)
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
notifierThread.start()
|
||||
subscriberThread.start()
|
||||
unsubscriberThread.start()
|
||||
notifierThread.join()
|
||||
subscriberThread.join()
|
||||
unsubscriberThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
|
||||
test("Observable.observerCount is thread-safe during concurrent modifications"):
|
||||
val observable = new TestObservable()
|
||||
val exceptions = mutable.ListBuffer[Exception]()
|
||||
val countResults = mutable.ListBuffer[Int]()
|
||||
|
||||
// Thread 1: subscribes observers
|
||||
val subscriberThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
observable.subscribe(new CountingObserver())
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Thread 2: reads observer count
|
||||
val readerThread = new Thread(new Runnable {
|
||||
def run(): Unit = {
|
||||
try {
|
||||
for _ <- 1 to 500 do
|
||||
val count = observable.observerCount
|
||||
countResults += count
|
||||
} catch {
|
||||
case e: Exception => exceptions += e
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
subscriberThread.start()
|
||||
readerThread.start()
|
||||
subscriberThread.join()
|
||||
readerThread.join()
|
||||
|
||||
exceptions.isEmpty shouldBe true
|
||||
// Count should never go backwards
|
||||
for i <- 1 until countResults.size do
|
||||
countResults(i) >= countResults(i - 1) shouldBe true
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=3
|
||||
MINOR=4
|
||||
PATCH=0
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
application
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
scala {
|
||||
scalaVersion = versions["SCALA3"]!!
|
||||
}
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("de.nowchess.ui.Main")
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
tasks.named<JavaExec>("run") {
|
||||
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
|
||||
standardInput = System.`in`
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
implementation("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
implementation("org.scala-lang:scala3-library_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
|
||||
implementation(project(":modules:core"))
|
||||
implementation(project(":modules:api"))
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.nowchess.ui
|
||||
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.ui.terminal.TerminalUI
|
||||
|
||||
/** Application entry point - starts the Terminal UI for the chess game. */
|
||||
object Main:
|
||||
def main(args: Array[String]): Unit =
|
||||
// Create the core game engine (single source of truth)
|
||||
val engine = new GameEngine()
|
||||
|
||||
// Create and start the terminal UI
|
||||
val tui = new TerminalUI(engine)
|
||||
tui.start()
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.nowchess.ui.terminal
|
||||
|
||||
import scala.io.StdIn
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, *}
|
||||
import de.nowchess.chess.view.Renderer
|
||||
|
||||
/** Terminal UI that implements Observer pattern.
|
||||
* Subscribes to GameEngine and receives state change events.
|
||||
* Handles all I/O and user interaction in the terminal.
|
||||
*/
|
||||
class TerminalUI(engine: GameEngine) extends Observer:
|
||||
private var running = true
|
||||
|
||||
/** Called by GameEngine whenever a game event occurs. */
|
||||
override def onGameEvent(event: GameEvent): Unit =
|
||||
event match
|
||||
case e: MoveExecutedEvent =>
|
||||
println()
|
||||
print(Renderer.render(e.board))
|
||||
e.capturedPiece.foreach: cap =>
|
||||
println(s"Captured: $cap on ${e.toSquare}")
|
||||
printPrompt(e.turn)
|
||||
|
||||
case e: CheckDetectedEvent =>
|
||||
println(s"${e.turn.label} is in check!")
|
||||
|
||||
case e: CheckmateEvent =>
|
||||
println(s"Checkmate! ${e.winner.label} wins.")
|
||||
println()
|
||||
print(Renderer.render(e.board))
|
||||
|
||||
case e: StalemateEvent =>
|
||||
println("Stalemate! The game is a draw.")
|
||||
println()
|
||||
print(Renderer.render(e.board))
|
||||
|
||||
case e: InvalidMoveEvent =>
|
||||
println(s"⚠️ ${e.reason}")
|
||||
|
||||
case e: BoardResetEvent =>
|
||||
println("Board has been reset to initial position.")
|
||||
println()
|
||||
print(Renderer.render(e.board))
|
||||
printPrompt(e.turn)
|
||||
|
||||
/** Start the terminal UI game loop. */
|
||||
def start(): Unit =
|
||||
// Register as observer
|
||||
engine.subscribe(this)
|
||||
|
||||
// Show initial board
|
||||
println()
|
||||
print(Renderer.render(engine.board))
|
||||
printPrompt(engine.turn)
|
||||
|
||||
// Game loop
|
||||
while running do
|
||||
val input = Option(StdIn.readLine()).getOrElse("quit").trim
|
||||
input.toLowerCase match
|
||||
case "quit" | "q" =>
|
||||
running = false
|
||||
println("Game over. Goodbye!")
|
||||
case "" =>
|
||||
printPrompt(engine.turn)
|
||||
case _ =>
|
||||
engine.processUserInput(input)
|
||||
|
||||
// Unsubscribe when done
|
||||
engine.unsubscribe(this)
|
||||
|
||||
private def printPrompt(turn: de.nowchess.api.board.Color): Unit =
|
||||
val undoHint = if engine.canUndo then " [undo]" else ""
|
||||
val redoHint = if engine.canRedo then " [redo]" else ""
|
||||
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
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!")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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}
|
||||
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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
rootProject.name = "NowChessSystems"
|
||||
include("modules:core", "modules:api")
|
||||
include("modules:core", "modules:api", "modules:ui")
|
||||
Reference in New Issue
Block a user