diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 4f4edba..f1d0a36 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -12,6 +12,7 @@
+
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 8db25d6..a0f8d4f 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/modules/core/src/main/scala/de/nowchess/chess/Main.scala b/modules/core/src/main/scala/de/nowchess/chess/Main.scala
deleted file mode 100644
index 3fb72e6..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/Main.scala
+++ /dev/null
@@ -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)
-}
diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala
new file mode 100644
index 0000000..5bc93a3
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala
@@ -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"
diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala
new file mode 100644
index 0000000..7913ba6
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala
@@ -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
+ }
diff --git a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
index 4717430..120b9e9 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/controller/GameController.scala
@@ -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)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
new file mode 100644
index 0000000..59aafe4
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
@@ -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."
+ ))
+
diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
new file mode 100644
index 0000000..3ed526b
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
@@ -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
+ }
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala
new file mode 100644
index 0000000..b8ab9a4
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala
@@ -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
+
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala
new file mode 100644
index 0000000..2e06aac
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala
@@ -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
+
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerThreadSafetyTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerThreadSafetyTest.scala
new file mode 100644
index 0000000..8b6215d
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerThreadSafetyTest.scala
@@ -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
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala
new file mode 100644
index 0000000..ca71cdc
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala
@@ -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"
+
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala
new file mode 100644
index 0000000..b23350a
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandImmutabilityTest.scala
@@ -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
diff --git a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
index 8124005..f5493b0 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/controller/GameControllerTest.scala
@@ -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"):
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala
new file mode 100644
index 0000000..6843e11
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineEdgeCasesTest.scala
@@ -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
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala
new file mode 100644
index 0000000..a6132c3
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala
@@ -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
\ No newline at end of file
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineHandleFailedMoveTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineHandleFailedMoveTest.scala
new file mode 100644
index 0000000..6401cae
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineHandleFailedMoveTest.scala
@@ -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
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineInvalidMovesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineInvalidMovesTest.scala
new file mode 100644
index 0000000..2be9947
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineInvalidMovesTest.scala
@@ -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
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala
new file mode 100644
index 0000000..755ddb8
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala
@@ -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
+
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/MoveCommandDefaultsTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/MoveCommandDefaultsTest.scala
new file mode 100644
index 0000000..46df874
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/MoveCommandDefaultsTest.scala
@@ -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
diff --git a/modules/core/src/test/scala/de/nowchess/chess/main/MainTest.scala b/modules/core/src/test/scala/de/nowchess/chess/main/MainTest.scala
deleted file mode 100644
index 22553e8..0000000
--- a/modules/core/src/test/scala/de/nowchess/chess/main/MainTest.scala
+++ /dev/null
@@ -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)
diff --git a/modules/core/src/test/scala/de/nowchess/chess/observer/ObservableThreadSafetyTest.scala b/modules/core/src/test/scala/de/nowchess/chess/observer/ObservableThreadSafetyTest.scala
new file mode 100644
index 0000000..0a6192e
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/observer/ObservableThreadSafetyTest.scala
@@ -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
diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts
new file mode 100644
index 0000000..f70d8c1
--- /dev/null
+++ b/modules/ui/build.gradle.kts
@@ -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
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+}
+
+application {
+ mainClass.set("de.nowchess.ui.Main")
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+tasks.named("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)
+}
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala
new file mode 100644
index 0000000..c8f5562
--- /dev/null
+++ b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala
@@ -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()
+
diff --git a/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala
new file mode 100644
index 0000000..5fc32af
--- /dev/null
+++ b/modules/ui/src/main/scala/de/nowchess/ui/terminal/TerminalUI.scala
@@ -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: ")
+
diff --git a/modules/ui/src/test/scala/de/nowchess/ui/MainTest.scala b/modules/ui/src/test/scala/de/nowchess/ui/MainTest.scala
new file mode 100644
index 0000000..dea2b2f
--- /dev/null
+++ b/modules/ui/src/test/scala/de/nowchess/ui/MainTest.scala
@@ -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!")
+ }
+}
diff --git a/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala
new file mode 100644
index 0000000..16ccba4
--- /dev/null
+++ b/modules/ui/src/test/scala/de/nowchess/ui/terminal/TerminalUITest.scala
@@ -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
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 4259047..f164a80 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,2 +1,2 @@
rootProject.name = "NowChessSystems"
-include("modules:core", "modules:api")
\ No newline at end of file
+include("modules:core", "modules:api", "modules:ui")
\ No newline at end of file