Compare commits

...

6 Commits

Author SHA1 Message Date
shosho996 1361dfc895 feat: NCS-16 Core Separation via Patterns (#10)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: Janis <janis-e@gmx.de>
Co-authored-by: shahdlala66 <shahd.lala66@gmail.com>
Co-authored-by: Janis <janis.e.20@gmx.de>
Reviewed-on: #10
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Shahd Lala <shosho996@blackhole.local>
Co-committed-by: Shahd Lala <shosho996@blackhole.local>
2026-03-31 10:31:02 +02:00
TeamCity 707c4826a4 ci: bump version with Build-21 2026-03-29 15:10:35 +00:00
lq64 919beb3b4b feat: NCS-9 En passant implementation (#8)
Build & Test (NowChessSystems) TeamCity build finished
- Add EnPassantCalculator to derive the en passant target square from GameHistory, detect en passant captures, and
  compute the captured pawn's square
  - Extend MoveValidator.legalTargets to include the en passant diagonal square in pawn legal targets
  - Extend GameController.processMove to remove the captured pawn from the board when an en passant capture is played

  Details

  En passant is derived purely from the last HistoryMove — no new state is introduced. If the last move was a double
  pawn push, the target square is the square the pawn passed through. The board mutation follows the same pattern as
  castling: board.withMove moves the capturing pawn, then board.removed removes the captured pawn from its actual square
   (which differs from the destination square).

  Test Plan

  - EnPassantCalculatorTest — 14 unit tests covering target derivation, captured square calculation, and capture
  detection for both colors
  - MoveValidatorTest — 5 new tests: ep target included/excluded based on history, adjacency filter, both colors, case _
   branch coverage
  - GameControllerTest — 2 integration tests: white and black en passant capture removes pawn from board and returns
  correct captured piece
  - 100% scoverage (line/branch/method) confirmed

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #8
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-03-29 17:06:50 +02:00
TeamCity ee79dc5b98 ci: bump version with Build-20 2026-03-29 12:06:38 +00:00
Janis f28e69dc18 feat: NCS-6 Implementing FEN & PGN (#7)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #7
Reviewed-by: Leon Hermann <lq@blackhole.local>
2026-03-29 14:02:25 +02:00
TeamCity 5f485fed9b ci: bump version with Build-19 2026-03-28 17:12:54 +00:00
46 changed files with 3739 additions and 184 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ You write tests for Scala 3 + Quarkus services.
- Unit tests: `extends AnyFunSuite with Matchers` — use `test("description") { ... }` DSL, no `@Test` annotation, no `: Unit` return type needed.
- Integration tests: `@QuarkusTest` with JUnit 5 — `@Test` methods MUST be explicitly typed `: Unit`.
Target 95%+ conditional coverage.
Target 100% conditional coverage if possible.
When invoked BEFORE scala-implementer (no implementation exists yet):
Use the contract-first-test-writing skill — write failing tests from docs/api/{service}.yaml.
+1
View File
@@ -12,6 +12,7 @@
<option value="$PROJECT_DIR$/modules" />
<option value="$PROJECT_DIR$/modules/api" />
<option value="$PROJECT_DIR$/modules/core" />
<option value="$PROJECT_DIR$/modules/ui" />
</set>
</option>
</GradleProjectSettings>
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
</profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test">
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
<parameters>
+1 -1
View File
@@ -36,7 +36,7 @@ Versions in root `extra["VERSIONS"]`; modules read via `rootProject.extra["VERSI
- Integration tests: `@QuarkusTest` + JUnit 5 — `@Test` methods need explicit `: Unit`
## Coverage
Line ≥ 95% · Branch ≥ 90% · Method ≥ 90% (document exceptions)
Line = 100% · Branch = 100% · Method = 100% · Regression tests · document exceptions
Check: `jacoco-reporter/scoverage_coverage_gaps.py modules/{svc}/build/reports/scoverageTest/scoverage.xml`
⚠️ Use `scoverageTest/`, NOT `scoverage/`.
+2
View File
@@ -1,2 +1,4 @@
## (2026-03-27)
## (2026-03-28)
## (2026-03-28)
## (2026-03-29)
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=0
PATCH=2
PATCH=4
+33
View File
@@ -28,3 +28,36 @@
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-03-29)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
## (2026-03-29)
### Features
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
### Bug Fixes
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
@@ -1,12 +0,0 @@
package de.nowchess.chess
import de.nowchess.api.board.Board
import de.nowchess.api.board.Color
import de.nowchess.chess.controller.GameController
import de.nowchess.chess.logic.GameHistory
object Main {
def main(args: Array[String]): Unit =
println("NowChess TUI — type moves in coordinate notation (e.g. e2e4). Type 'quit' to exit.")
GameController.gameLoop(Board.initial, GameHistory.empty, Color.White)
}
@@ -0,0 +1,64 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Board, Color, Piece}
import de.nowchess.chess.logic.GameHistory
/** Marker trait for all commands that can be executed and undone.
* Commands encapsulate user actions and game state transitions.
*/
trait Command:
/** Execute the command and return true if successful, false otherwise. */
def execute(): Boolean
/** Undo the command and return true if successful, false otherwise. */
def undo(): Boolean
/** A human-readable description of this command. */
def description: String
/** Command to move a piece from one square to another.
* Stores the move result so undo can restore previous state.
*/
case class MoveCommand(
from: Square,
to: Square,
moveResult: Option[MoveResult] = None,
previousBoard: Option[Board] = None,
previousHistory: Option[GameHistory] = None,
previousTurn: Option[Color] = None
) extends Command:
override def execute(): Boolean =
moveResult.isDefined
override def undo(): Boolean =
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
override def description: String = s"Move from $from to $to"
// Sealed hierarchy of move outcomes (for tracking state changes)
sealed trait MoveResult
object MoveResult:
case class Successful(newBoard: Board, newHistory: GameHistory, newTurn: Color, captured: Option[Piece]) extends MoveResult
case object InvalidFormat extends MoveResult
case object InvalidMove extends MoveResult
/** Command to quit the game. */
case class QuitCommand() extends Command:
override def execute(): Boolean = true
override def undo(): Boolean = false
override def description: String = "Quit game"
/** Command to reset the board to initial position. */
case class ResetCommand(
previousBoard: Option[Board] = None,
previousHistory: Option[GameHistory] = None,
previousTurn: Option[Color] = None
) extends Command:
override def execute(): Boolean = true
override def undo(): Boolean =
previousBoard.isDefined && previousHistory.isDefined && previousTurn.isDefined
override def description: String = "Reset board"
@@ -0,0 +1,73 @@
package de.nowchess.chess.command
/** Manages command execution and history for undo/redo support. */
class CommandInvoker:
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
private var currentIndex = -1
/** Execute a command and add it to history.
* Discards any redo history if not at the end of the stack.
*/
def execute(command: Command): Boolean = synchronized {
if command.execute() then
// Remove any commands after current index (redo stack is discarded)
while currentIndex < executedCommands.size - 1 do
executedCommands.remove(executedCommands.size - 1)
executedCommands += command
currentIndex += 1
true
else
false
}
/** Undo the last executed command if possible. */
def undo(): Boolean = synchronized {
if currentIndex >= 0 && currentIndex < executedCommands.size then
val command = executedCommands(currentIndex)
if command.undo() then
currentIndex -= 1
true
else
false
else
false
}
/** Redo the next command in history if available. */
def redo(): Boolean = synchronized {
if currentIndex + 1 < executedCommands.size then
val command = executedCommands(currentIndex + 1)
if command.execute() then
currentIndex += 1
true
else
false
else
false
}
/** Get the history of all executed commands. */
def history: List[Command] = synchronized {
executedCommands.toList
}
/** Get the current position in command history. */
def getCurrentIndex: Int = synchronized {
currentIndex
}
/** Clear all command history. */
def clear(): Unit = synchronized {
executedCommands.clear()
currentIndex = -1
}
/** Check if undo is available. */
def canUndo: Boolean = synchronized {
currentIndex >= 0
}
/** Check if redo is available. */
def canRedo: Boolean = synchronized {
currentIndex + 1 < executedCommands.size
}
@@ -1,9 +1,7 @@
package de.nowchess.chess.controller
import scala.io.StdIn
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.chess.logic.*
import de.nowchess.chess.view.Renderer
// ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function
@@ -51,55 +49,18 @@ object GameController:
val castleOpt = if MoveValidator.isCastle(board, from, to)
then Some(MoveValidator.castleSide(from, to))
else None
val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
val (newBoard, captured) = castleOpt match
case Some(side) => (board.withCastle(turn, side), None)
case None => board.withMove(from, to)
case None =>
val (b, cap) = board.withMove(from, to)
if isEP then
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
(b.removed(capturedSq), board.pieceAt(capturedSq))
else (b, cap)
val newHistory = history.addMove(from, to, castleOpt)
GameRules.gameStatus(newBoard, newHistory, turn.opposite) match
case PositionStatus.Normal => MoveResult.Moved(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.InCheck => MoveResult.MovedInCheck(newBoard, newHistory, captured, turn.opposite)
case PositionStatus.Mated => MoveResult.Checkmate(turn)
case PositionStatus.Drawn => MoveResult.Stalemate
/** Thin I/O shell: renders the board, reads a line, delegates to processMove,
* prints the outcome, and recurses until the game ends.
*/
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
println()
print(Renderer.render(board))
println(s"${turn.label}'s turn. Enter move: ")
val input = Option(StdIn.readLine()).getOrElse("quit").trim
processMove(board, history, turn, input) match
case MoveResult.Quit =>
println("Game over. Goodbye!")
case MoveResult.InvalidFormat(raw) =>
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.")
gameLoop(board, history, turn)
case MoveResult.NoPiece =>
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.")
gameLoop(board, history, turn)
case MoveResult.WrongColor =>
println(s"That is not your piece.")
gameLoop(board, history, turn)
case MoveResult.IllegalMove =>
println(s"Illegal move.")
gameLoop(board, history, turn)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val prevTurn = newTurn.opposite
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString)
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq")
println(s"${newTurn.label} is in check!")
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.Checkmate(winner) =>
println(s"Checkmate! ${winner.label} wins.")
gameLoop(Board.initial, GameHistory.empty, Color.White)
case MoveResult.Stalemate =>
println("Stalemate! The game is a draw.")
gameLoop(Board.initial, GameHistory.empty, Color.White)
@@ -0,0 +1,209 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, Square}
import de.nowchess.chess.logic.{GameHistory, GameRules, PositionStatus}
import de.nowchess.chess.controller.{GameController, Parser, MoveResult}
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand}
/** Pure game engine that manages game state and notifies observers of state changes.
* This class is the single source of truth for the game state.
* All user interactions must go through this engine via Commands, and all state changes
* are communicated to observers via GameEvent notifications.
*/
class GameEngine extends Observable:
private var currentBoard: Board = Board.initial
private var currentHistory: GameHistory = GameHistory.empty
private var currentTurn: Color = Color.White
private val invoker = new CommandInvoker()
// Synchronized accessors for current state
def board: Board = synchronized { currentBoard }
def history: GameHistory = synchronized { currentHistory }
def turn: Color = synchronized { currentTurn }
/** Check if undo is available. */
def canUndo: Boolean = synchronized { invoker.canUndo }
/** Check if redo is available. */
def canRedo: Boolean = synchronized { invoker.canRedo }
/** Get the command history for inspection (testing/debugging). */
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized { invoker.history }
/** Process a raw move input string and update game state if valid.
* Notifies all observers of the outcome via GameEvent.
*/
def processUserInput(rawInput: String): Unit = synchronized {
val trimmed = rawInput.trim.toLowerCase
trimmed match
case "quit" | "q" =>
// Client should handle quit logic; we just return
()
case "undo" =>
performUndo()
case "redo" =>
performRedo()
case "" =>
val event = InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"Please enter a valid move or command."
)
notifyObservers(event)
case moveInput =>
// Try to parse as a move
Parser.parseMove(moveInput) match
case None =>
val event = InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
)
notifyObservers(event)
case Some((from, to)) =>
// Create a move command with current state snapshot
val cmd = MoveCommand(
from = from,
to = to,
previousBoard = Some(currentBoard),
previousHistory = Some(currentHistory),
previousTurn = Some(currentTurn)
)
// Execute the move through GameController
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
handleFailedMove(moveInput)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
// Move succeeded - store result and execute through invoker
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
// Move succeeded with check
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) =>
// Move resulted in checkmate
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate =>
// Move resulted in stalemate
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
}
/** Undo the last move. */
def undo(): Unit = synchronized {
performUndo()
}
/** Redo the last undone move. */
def redo(): Unit = synchronized {
performRedo()
}
/** Reset the board to initial position. */
def reset(): Unit = synchronized {
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
invoker.clear()
notifyObservers(BoardResetEvent(
currentBoard,
currentHistory,
currentTurn
))
}
// ──── Private Helpers ────
private def performUndo(): Unit =
if invoker.canUndo then
val cmd = invoker.history(invoker.getCurrentIndex)
(cmd: @unchecked) match
case moveCmd: MoveCommand =>
moveCmd.previousBoard.foreach(currentBoard = _)
moveCmd.previousHistory.foreach(currentHistory = _)
moveCmd.previousTurn.foreach(currentTurn = _)
invoker.undo()
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
else
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
private def performRedo(): Unit =
if invoker.canRedo then
val cmd = invoker.history(invoker.getCurrentIndex + 1)
(cmd: @unchecked) match
case moveCmd: MoveCommand =>
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
updateGameState(nb, nh, nt)
invoker.redo()
emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, cap, nt)
else
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
private def updateGameState(newBoard: Board, newHistory: GameHistory, newTurn: Color): Unit =
currentBoard = newBoard
currentHistory = newHistory
currentTurn = newTurn
private def emitMoveEvent(fromSq: String, toSq: String, captured: Option[Piece], newTurn: Color): Unit =
val capturedDesc = captured.map(c => s"${c.color.label} ${c.pieceType.label}")
notifyObservers(MoveExecutedEvent(
currentBoard,
currentHistory,
newTurn,
fromSq,
toSq,
capturedDesc
))
private def handleFailedMove(moveInput: String): Unit =
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
case MoveResult.NoPiece =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"No piece on that square."
))
case MoveResult.WrongColor =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"That is not your piece."
))
case MoveResult.IllegalMove =>
notifyObservers(InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"Illegal move."
))
@@ -0,0 +1,32 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
object EnPassantCalculator:
/** Returns the en passant target square if the last move was a double pawn push.
* The target is the square the pawn passed through (e.g. e2e4 yields e3).
*/
def enPassantTarget(board: Board, history: GameHistory): Option[Square] =
history.moves.lastOption.flatMap: move =>
val rankDiff = move.to.rank.ordinal - move.from.rank.ordinal
val isDoublePush = math.abs(rankDiff) == 2
val isPawn = board.pieceAt(move.to).exists(_.pieceType == PieceType.Pawn)
if isDoublePush && isPawn then
val midRankIdx = move.from.rank.ordinal + rankDiff / 2
Some(Square(move.to.file, Rank.values(midRankIdx)))
else None
/** True if moving from→to is an en passant capture. */
def isEnPassant(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
board.pieceAt(from).exists(_.pieceType == PieceType.Pawn) &&
enPassantTarget(board, history).contains(to) &&
math.abs(to.file.ordinal - from.file.ordinal) == 1
/** Returns the square of the pawn to remove when an en passant capture lands on `to`.
* White captures upward → captured pawn is one rank below `to`.
* Black captures downward → captured pawn is one rank above `to`.
*/
def capturedPawnSquare(to: Square, color: Color): Square =
val capturedRankIdx = to.rank.ordinal + (if color == Color.White then -1 else 1)
Square(to.file, Rank.values(capturedRankIdx))
@@ -155,8 +155,21 @@ object MoveValidator:
board.pieceAt(from) match
case Some(piece) if piece.pieceType == PieceType.King =>
legalTargets(board, from) ++ castlingTargets(board, history, piece.color)
case Some(piece) if piece.pieceType == PieceType.Pawn =>
pawnTargets(board, history, from, piece.color)
case _ =>
legalTargets(board, from)
private def pawnTargets(board: Board, history: GameHistory, from: Square, color: Color): Set[Square] =
val existing = pawnTargets(board, from, color)
val fi = from.file.ordinal
val ri = from.rank.ordinal
val dir = if color == Color.White then 1 else -1
val epCapture: Set[Square] =
EnPassantCalculator.enPassantTarget(board, history).filter: target =>
squareAt(fi - 1, ri + dir).contains(target) || squareAt(fi + 1, ri + dir).contains(target)
.toSet
existing ++ epCapture
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to)
@@ -0,0 +1,60 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.{CastlingRights, GameState}
import de.nowchess.api.board.Color
object FenExporter:
/** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */
def boardToFen(board: Board): String =
Rank.values.reverse
.map(rank => buildRankString(board, rank))
.mkString("/")
/** Build the FEN representation for a single rank. */
private def buildRankString(board: Board, rank: Rank): String =
val rankSquares = File.values.map(file => Square(file, rank))
val rankChars = scala.collection.mutable.ListBuffer[Char]()
var emptyCount = 0
for square <- rankSquares do
board.pieceAt(square) match
case Some(piece) =>
if emptyCount > 0 then
rankChars += emptyCount.toString.charAt(0)
emptyCount = 0
rankChars += pieceToPgnChar(piece)
case None =>
emptyCount += 1
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
rankChars.mkString
/** Convert a GameState to a complete FEN string. */
def gameStateToFen(state: GameState): String =
val piecePlacement = state.piecePlacement
val activeColor = if state.activeColor == Color.White then "w" else "b"
val castling = castlingString(state.castlingWhite, state.castlingBlack)
val enPassant = state.enPassantTarget.map(_.toString).getOrElse("-")
s"$piecePlacement $activeColor $castling $enPassant ${state.halfMoveClock} ${state.fullMoveNumber}"
/** Convert castling rights to FEN notation. */
private def castlingString(white: CastlingRights, black: CastlingRights): String =
val wk = if white.kingSide then "K" else ""
val wq = if white.queenSide then "Q" else ""
val bk = if black.kingSide then "k" else ""
val bq = if black.queenSide then "q" else ""
val result = s"$wk$wq$bk$bq"
if result.isEmpty then "-" else result
/** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */
private def pieceToPgnChar(piece: Piece): Char =
val base = piece.pieceType match
case PieceType.Pawn => 'p'
case PieceType.Knight => 'n'
case PieceType.Bishop => 'b'
case PieceType.Rook => 'r'
case PieceType.Queen => 'q'
case PieceType.King => 'k'
if piece.color == Color.White then base.toUpper else base
@@ -0,0 +1,103 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
object FenParser:
/** Parse a complete FEN string into a GameState.
* Returns None if the format is invalid. */
def parseFen(fen: String): Option[GameState] =
val parts = fen.trim.split("\\s+")
Option.when(parts.length == 6)(parts).flatMap: parts =>
for
_ <- parseBoard(parts(0))
activeColor <- parseColor(parts(1))
castlingRights <- parseCastling(parts(2))
enPassant <- parseEnPassant(parts(3))
halfMoveClock <- parts(4).toIntOption
fullMoveNumber <- parts(5).toIntOption
if halfMoveClock >= 0 && fullMoveNumber >= 1
yield GameState(
piecePlacement = parts(0),
activeColor = activeColor,
castlingWhite = castlingRights._1,
castlingBlack = castlingRights._2,
enPassantTarget = enPassant,
halfMoveClock = halfMoveClock,
fullMoveNumber = fullMoveNumber,
status = GameStatus.InProgress
)
/** Parse active color ("w" or "b"). */
private def parseColor(s: String): Option[Color] =
if s == "w" then Some(Color.White)
else if s == "b" then Some(Color.Black)
else None
/** Parse castling rights string (e.g. "KQkq", "K", "-") into rights for White and Black. */
private def parseCastling(s: String): Option[(CastlingRights, CastlingRights)] =
if s == "-" then
Some((CastlingRights.None, CastlingRights.None))
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
val white = CastlingRights(kingSide = s.contains('K'), queenSide = s.contains('Q'))
val black = CastlingRights(kingSide = s.contains('k'), queenSide = s.contains('q'))
Some((white, black))
else
None
/** Parse en passant target square ("-" for none, or algebraic like "e3"). */
private def parseEnPassant(s: String): Option[Option[Square]] =
if s == "-" then Some(None)
else Square.fromAlgebraic(s).map(Some(_))
/** Parses a FEN piece-placement string (rank 8 to rank 1, separated by '/') into a Board.
* Returns None if the format is invalid. */
def parseBoard(fen: String): Option[Board] =
val rankStrings = fen.split("/", -1)
if rankStrings.length != 8 then None
else
// Parse each rank, collecting all (Square, Piece) pairs or failing on the first error
val parsedRanks: Option[List[List[(Square, Piece)]]] =
rankStrings.zipWithIndex.foldLeft(Option(List.empty[List[(Square, Piece)]])):
case (None, _) => None
case (Some(acc), (rankStr, rankIdx)) =>
val rank = Rank.values(7 - rankIdx) // ranks go 8→1, so reverse
parsePieceRank(rankStr, rank).map(squares => acc :+ squares)
parsedRanks.map(ranks => Board(ranks.flatten.toMap))
/** Parse a single rank string (e.g. "rnbqkbnr" or "p3p3") into a list of (Square, Piece) pairs.
* Returns None if the rank string contains invalid characters or the wrong number of files. */
private def parsePieceRank(rankStr: String, rank: Rank): Option[List[(Square, Piece)]] =
var fileIdx = 0
val squares = scala.collection.mutable.ListBuffer[(Square, Piece)]()
var failed = false
for c <- rankStr if !failed do
if fileIdx > 7 then
failed = true
else if c.isDigit then
fileIdx += c.asDigit
else
charToPiece(c) match
case None => failed = true
case Some(piece) =>
val file = File.values(fileIdx)
squares += (Square(file, rank) -> piece)
fileIdx += 1
if failed || fileIdx != 8 then None
else Some(squares.toList)
/** Convert a FEN piece character to a Piece. Uppercase = White, lowercase = Black. */
private def charToPiece(c: Char): Option[Piece] =
val color = if Character.isUpperCase(c) then Color.White else Color.Black
val pieceTypeOpt = c.toLower match
case 'p' => Some(PieceType.Pawn)
case 'n' => Some(PieceType.Knight)
case 'b' => Some(PieceType.Bishop)
case 'r' => Some(PieceType.Rook)
case 'q' => Some(PieceType.Queen)
case 'k' => Some(PieceType.King)
case _ => None
pieceTypeOpt.map(pt => Piece(color, pt))
@@ -0,0 +1,35 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
object PgnExporter:
/** Export a game with headers and history to PGN format. */
def exportGame(headers: Map[String, String], history: GameHistory): String =
val headerLines = headers.map { case (key, value) =>
s"""[$key "$value"]"""
}.mkString("\n")
val moveText = if history.moves.isEmpty then ""
else
val groupedMoves = history.moves.zipWithIndex.groupBy(_._2 / 2)
val moveLines = for (moveNumber, movePairs) <- groupedMoves.toList.sortBy(_._1) yield
val moveNum = moveNumber + 1
val whiteMoveStr = movePairs.find(_._2 % 2 == 0).map(p => moveToAlgebraic(p._1)).getOrElse("")
val blackMoveStr = movePairs.find(_._2 % 2 == 1).map(p => moveToAlgebraic(p._1)).getOrElse("")
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr"
moveLines.mkString(" ") + " *"
if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines
else s"$headerLines\n\n$moveText"
/** Convert a HistoryMove to algebraic notation. */
private def moveToAlgebraic(move: HistoryMove): String =
move.castleSide match
case Some(CastleSide.Kingside) => "O-O"
case Some(CastleSide.Queenside) => "O-O-O"
case None => s"${move.from}${move.to}"
@@ -0,0 +1,150 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
/** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame(
headers: Map[String, String],
moves: List[HistoryMove]
)
object PgnParser:
/** Parse a complete PGN text into a PgnGame with headers and moves.
* Always succeeds (returns Some); malformed tokens are silently skipped. */
def parsePgn(pgn: String): Option[PgnGame] =
val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines)
val moveText = rest.mkString(" ")
val moves = parseMovesText(moveText)
Some(PgnGame(headers, moves))
/** Parse PGN header lines of the form [Key "Value"]. */
private def parseHeaders(lines: Array[String]): Map[String, String] =
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
private def parseMovesText(moveText: String): List[HistoryMove] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
// Fold over tokens, threading (board, history, currentColor, accumulator)
val (_, _, _, moves) = tokens.foldLeft(
(Board.initial, GameHistory.empty, Color.White, List.empty[HistoryMove])
):
case (state @ (board, history, color, acc), token) =>
// Skip move-number markers (e.g. "1.", "2.") and result tokens
if isMoveNumberOrResult(token) then state
else
parseAlgebraicMove(token, board, history, color) match
case None => state // unrecognised token — skip silently
case Some(move) =>
val newBoard = move.castleSide match
case Some(side) => board.withCastle(color, side)
case None => board.withMove(move.from, move.to)._1
val newHistory = history.addMove(move)
(newBoard, newHistory, color.opposite, acc :+ move)
moves
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */
private def isMoveNumberOrResult(token: String): Boolean =
token.matches("""\d+\.""") ||
token == "*" ||
token == "1-0" ||
token == "0-1" ||
token == "1/2-1/2"
/** Parse a single algebraic notation token into a HistoryMove, given the current board state. */
def parseAlgebraicMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
notation match
case "O-O" | "O-O+" | "O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.G, rank), Some(CastleSide.Kingside)))
case "O-O-O" | "O-O-O+" | "O-O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8
Some(HistoryMove(Square(File.E, rank), Square(File.C, rank), Some(CastleSide.Queenside)))
case _ =>
parseRegularMove(notation, board, history, color)
/** Parse regular algebraic notation (pawn moves, piece moves, captures, disambiguation). */
private def parseRegularMove(notation: String, board: Board, history: GameHistory, color: Color): Option[HistoryMove] =
// Strip check/mate/capture indicators and promotion suffix (e.g. =Q)
val clean = notation
.replace("+", "")
.replace("#", "")
.replace("x", "")
.replaceAll("=[NBRQ]$", "")
// The destination square is always the last two characters
if clean.length < 2 then None
else
val destStr = clean.takeRight(2)
Square.fromAlgebraic(destStr).flatMap: toSquare =>
val disambig = clean.dropRight(2) // "" | "N"|"B"|"R"|"Q"|"K" | file | rank | file+rank
// Determine required piece type: upper-case first char = piece letter; else pawn
val requiredPieceType: Option[PieceType] =
if disambig.nonEmpty && disambig.head.isUpper then charToPieceType(disambig.head)
else if clean.head.isUpper then charToPieceType(clean.head)
else Some(PieceType.Pawn)
// Collect the disambiguation hint that remains after stripping the piece letter
val hint =
if disambig.nonEmpty && disambig.head.isUpper then disambig.tail
else disambig // hint is file/rank info or empty
// Candidate source squares: pieces of `color` that can geometrically reach `toSquare`.
// We prefer pieces that can actually reach the target; if none can (positionally illegal
// PGN input), fall back to any piece of the matching type belonging to `color`.
val reachable: Set[Square] =
board.pieces.collect {
case (from, piece) if piece.color == color &&
MoveValidator.legalTargets(board, from).contains(toSquare) => from
}.toSet
val candidates: Set[Square] =
if reachable.nonEmpty then reachable
else
// Fallback for positionally-illegal but syntactically valid PGN notation:
// find any piece of `color` with the correct piece type on the board.
board.pieces.collect {
case (from, piece) if piece.color == color => from
}.toSet
// Filter by required piece type
val byPiece = candidates.filter(from =>
requiredPieceType.forall(pt => board.pieceAt(from).exists(_.pieceType == pt))
)
// Apply disambiguation hint (file letter or rank digit)
val disambiguated =
if hint.isEmpty then byPiece
else byPiece.filter(from => matchesHint(from, hint))
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None))
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean =
hint.foldLeft(true): (ok, c) =>
ok && (
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true
)
/** Convert a piece-letter character to a PieceType. */
private def charToPieceType(c: Char): Option[PieceType] =
c match
case 'N' => Some(PieceType.Knight)
case 'B' => Some(PieceType.Bishop)
case 'R' => Some(PieceType.Rook)
case 'Q' => Some(PieceType.Queen)
case 'K' => Some(PieceType.King)
case _ => None
@@ -0,0 +1,87 @@
package de.nowchess.chess.observer
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
/** Base trait for all game state events.
* Events are immutable snapshots of game state changes.
*/
sealed trait GameEvent:
def board: Board
def history: GameHistory
def turn: Color
/** Fired when a move is successfully executed. */
case class MoveExecutedEvent(
board: Board,
history: GameHistory,
turn: Color,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
) extends GameEvent
/** Fired when the current player is in check. */
case class CheckDetectedEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Fired when the game reaches checkmate. */
case class CheckmateEvent(
board: Board,
history: GameHistory,
turn: Color,
winner: Color
) extends GameEvent
/** Fired when the game reaches stalemate. */
case class StalemateEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Fired when a move is invalid. */
case class InvalidMoveEvent(
board: Board,
history: GameHistory,
turn: Color,
reason: String
) extends GameEvent
/** Fired when the board is reset. */
case class BoardResetEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Observer trait: implement to receive game state updates. */
trait Observer:
def onGameEvent(event: GameEvent): Unit
/** Observable trait: manages observers and notifies them of events. */
trait Observable:
private val observers = scala.collection.mutable.Set[Observer]()
/** Register an observer to receive game events. */
def subscribe(observer: Observer): Unit = synchronized {
observers += observer
}
/** Unregister an observer. */
def unsubscribe(observer: Observer): Unit = synchronized {
observers -= observer
}
/** Notify all observers of a game event. */
protected def notifyObservers(event: GameEvent): Unit = synchronized {
observers.foreach(_.onGameEvent(event))
}
/** Return current list of observers (for testing). */
def observerCount: Int = synchronized {
observers.size
}
@@ -0,0 +1,216 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
// ──── Helper: Command that always fails ────
private case class FailingCommand() extends Command:
override def execute(): Boolean = false
override def undo(): Boolean = false
override def description: String = "Failing command"
// ──── Helper: Command that conditionally fails on undo or execute ────
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
override def execute(): Boolean = !shouldFailOnExecute
override def undo(): Boolean = !shouldFailOnUndo
override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
val cmd = MoveCommand(
from = from,
to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None,
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
cmd
// ──── BRANCH: execute() returns false ────
test("CommandInvoker.execute() with failing command returns false"):
val invoker = new CommandInvoker()
val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
test("CommandInvoker.execute() does not add failed command to history"):
val invoker = new CommandInvoker()
val failingCmd = FailingCommand()
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(failingCmd) shouldBe false
invoker.history.size shouldBe 0
invoker.execute(successCmd) shouldBe true
invoker.history.size shouldBe 1
invoker.history(0) shouldBe successCmd
// ──── BRANCH: undo() with invalid index (currentIndex < 0) ────
test("CommandInvoker.undo() returns false when currentIndex < 0"):
val invoker = new CommandInvoker()
// currentIndex starts at -1
invoker.undo() shouldBe false
test("CommandInvoker.undo() returns false when empty history"):
val invoker = new CommandInvoker()
invoker.canUndo shouldBe false
invoker.undo() shouldBe false
// ──── BRANCH: undo() with invalid index (currentIndex >= size) ────
test("CommandInvoker.undo() returns false when currentIndex >= history size"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1)
invoker.execute(cmd2)
// currentIndex now = 1, history.size = 2
invoker.undo() // currentIndex becomes 0
invoker.undo() // currentIndex becomes -1
invoker.undo() // currentIndex still -1, should fail
// ──── BRANCH: undo() command returns false ────
test("CommandInvoker.undo() returns false when command.undo() fails"):
val invoker = new CommandInvoker()
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true)
invoker.execute(failingCmd) shouldBe true
invoker.canUndo shouldBe true
invoker.undo() shouldBe false
// Index should not change when undo fails
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker.undo() returns true when command.undo() succeeds"):
val invoker = new CommandInvoker()
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false)
invoker.execute(successCmd) shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
// ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ────
test("CommandInvoker.redo() returns false when nothing to redo"):
val invoker = new CommandInvoker()
invoker.redo() shouldBe false
test("CommandInvoker.redo() returns false when at end of history"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
// currentIndex = 0, history.size = 1
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1)
invoker.execute(cmd2)
// currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
// ──── BRANCH: redo() command returns false ────
test("CommandInvoker.redo() returns false when command.execute() fails"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute
invoker.execute(cmd1)
invoker.execute(redoFailCmd) // Succeeds and added to history
invoker.undo()
// currentIndex = 0, redoFailCmd is at index 1
invoker.canRedo shouldBe true
// Now modify to fail on next execute (redo)
redoFailCmd.shouldFailOnExecute = true
invoker.redo() shouldBe false
// currentIndex should not change
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker.redo() returns true when command.execute() succeeds"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true
invoker.undo() shouldBe true
invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0
// ──── BRANCH: execute() with redo history discarding (while loop) ────
test("CommandInvoker.execute() discards redo history via while loop"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
// currentIndex = 1, size = 2
invoker.undo()
// currentIndex = 0, size = 2
// Redo history exists: cmd2 is at index 1
invoker.canRedo shouldBe true
invoker.execute(cmd3)
// while loop should discard cmd2
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
test("CommandInvoker.execute() discards multiple redo commands"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.execute(cmd3)
invoker.execute(cmd4)
// currentIndex = 3, size = 4
invoker.undo()
invoker.undo()
// currentIndex = 1, size = 4
// Redo history: cmd3 (idx 2), cmd4 (idx 3)
invoker.canRedo shouldBe true
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
invoker.execute(newCmd)
// While loop should discard indices 2 and 3 (cmd3 and cmd4)
invoker.history.size shouldBe 3
invoker.canRedo shouldBe false
// ──── BRANCH: execute() with no redo history to discard ────
test("CommandInvoker.execute() with no redo history (while condition false)"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1)
invoker.execute(cmd2)
// currentIndex = 1, size = 2
// currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run
invoker.canRedo shouldBe false
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd3) // While loop condition should be false, no iterations
invoker.history.size shouldBe 3
@@ -0,0 +1,123 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandInvokerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def createMoveCommand(from: Square, to: Square): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
test("CommandInvoker executes a command and adds it to history"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true
invoker.history.size shouldBe 1
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker executes multiple commands in sequence"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1) shouldBe true
invoker.execute(cmd2) shouldBe true
invoker.history.size shouldBe 2
invoker.getCurrentIndex shouldBe 1
test("CommandInvoker.canUndo returns false when empty"):
val invoker = new CommandInvoker()
invoker.canUndo shouldBe false
test("CommandInvoker.canUndo returns true after execution"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.canUndo shouldBe true
test("CommandInvoker.undo decrements current index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.getCurrentIndex shouldBe 0
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
test("CommandInvoker.canRedo returns true after undo"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo()
invoker.canRedo shouldBe true
test("CommandInvoker.redo re-executes a command"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo() shouldBe true
invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker.canUndo returns false when at beginning"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo()
invoker.canUndo shouldBe false
test("CommandInvoker clear removes all history"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.clear()
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
test("CommandInvoker discards all history when executing after undoing all"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.undo()
// After undoing twice, we're at the beginning (before any commands)
invoker.getCurrentIndex shouldBe -1
invoker.canRedo shouldBe true
// Executing a new command from the beginning discards all redo history
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 1
invoker.history(0) shouldBe cmd3
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker discards redo history when executing mid-history"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
// After one undo, we're at the end of cmd1
invoker.getCurrentIndex shouldBe 0
invoker.canRedo shouldBe true
// Executing a new command discards cmd2 (the redo history)
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(0) shouldBe cmd1
invoker.history(1) shouldBe cmd3
invoker.getCurrentIndex shouldBe 1
@@ -0,0 +1,131 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scala.collection.mutable
class CommandInvokerThreadSafetyTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def createMoveCommand(from: Square, to: Square): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
test("CommandInvoker is thread-safe for concurrent execute and history reads"):
val invoker = new CommandInvoker()
@volatile var raceDetected = false
val exceptions = mutable.ListBuffer[Exception]()
// Thread 1: executes commands
val executorThread = new Thread(new Runnable {
def run(): Unit = {
try {
for i <- 1 to 1000 do
val cmd = createMoveCommand(
sq(File.E, Rank.R2),
sq(File.E, Rank.R4)
)
invoker.execute(cmd)
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
// Thread 2: reads history during execution
val readerThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 1000 do
val _ = invoker.history
val _ = invoker.getCurrentIndex
Thread.sleep(0) // Yield to increase contention
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
executorThread.start()
readerThread.start()
executorThread.join()
readerThread.join()
exceptions.isEmpty shouldBe true
raceDetected shouldBe false
test("CommandInvoker is thread-safe for concurrent execute, undo, and redo"):
val invoker = new CommandInvoker()
@volatile var raceDetected = false
val exceptions = mutable.ListBuffer[Exception]()
// Pre-populate with some commands
for _ <- 1 to 5 do
invoker.execute(createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)))
// Thread 1: executes new commands
val executorThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
invoker.execute(createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)))
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
// Thread 2: undoes commands
val undoThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
if invoker.canUndo then
invoker.undo()
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
// Thread 3: redoes commands
val redoThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
if invoker.canRedo then
invoker.redo()
} catch {
case e: Exception =>
exceptions += e
raceDetected = true
}
}
})
executorThread.start()
undoThread.start()
redoThread.start()
executorThread.join()
undoThread.join()
redoThread.join()
exceptions.isEmpty shouldBe true
raceDetected shouldBe false
@@ -0,0 +1,52 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand can be created"):
val cmd = QuitCommand()
cmd shouldNot be(null)
test("QuitCommand execute returns true"):
val cmd = QuitCommand()
cmd.execute() shouldBe true
test("QuitCommand undo returns false (cannot undo quit)"):
val cmd = QuitCommand()
cmd.undo() shouldBe false
test("QuitCommand description"):
val cmd = QuitCommand()
cmd.description shouldBe "Quit game"
test("ResetCommand with no prior state"):
val cmd = ResetCommand()
cmd.execute() shouldBe true
cmd.undo() shouldBe false
test("ResetCommand with prior state can undo"):
val cmd = ResetCommand(
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
cmd.execute() shouldBe true
cmd.undo() shouldBe true
test("ResetCommand with partial state cannot undo"):
val cmd = ResetCommand(
previousBoard = Some(Board.initial),
previousHistory = None, // missing
previousTurn = Some(Color.White)
)
cmd.execute() shouldBe true
cmd.undo() shouldBe false
test("ResetCommand description"):
val cmd = ResetCommand()
cmd.description shouldBe "Reset board"
@@ -0,0 +1,65 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveCommandImmutabilityTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
test("MoveCommand should be immutable - fields cannot be mutated after creation"):
val cmd1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
// Create second command with filled state
val result = MoveResult.Successful(Board.initial, GameHistory.empty, Color.Black, None)
val cmd2 = cmd1.copy(
moveResult = Some(result),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
// Original should be unchanged
cmd1.moveResult shouldBe None
cmd1.previousBoard shouldBe None
cmd1.previousHistory shouldBe None
cmd1.previousTurn shouldBe None
// New should have values
cmd2.moveResult shouldBe Some(result)
cmd2.previousBoard shouldBe Some(Board.initial)
cmd2.previousHistory shouldBe Some(GameHistory.empty)
cmd2.previousTurn shouldBe Some(Color.White)
test("MoveCommand equals and hashCode respect immutability"):
val cmd1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousBoard = None,
previousHistory = None,
previousTurn = None
)
val cmd2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousBoard = None,
previousHistory = None,
previousTurn = None
)
// Same values should be equal
cmd1 shouldBe cmd2
cmd1.hashCode shouldBe cmd2.hashCode
// Hash should be consistent (required for use as map keys)
val hash1 = cmd1.hashCode
val hash2 = cmd1.hashCode
hash1 shouldBe hash2
@@ -6,17 +6,12 @@ import de.nowchess.chess.logic.{CastleSide, GameHistory}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.ByteArrayInputStream
class GameControllerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
GameController.processMove(board, history, turn, raw)
private def gameLoop(board: Board, history: GameHistory, turn: Color): Unit =
GameController.gameLoop(board, history, turn)
private def castlingRights(history: GameHistory, color: Color): CastlingRights =
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
@@ -69,59 +64,6 @@ class GameControllerTest extends AnyFunSuite with Matchers:
newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other")
// ──── gameLoop ───────────────────────────────────────────────────────
private def withInput(input: String)(block: => Unit): Unit =
val stream = ByteArrayInputStream(input.getBytes("UTF-8"))
scala.Console.withIn(stream)(block)
test("gameLoop: 'quit' exits cleanly without exception"):
withInput("quit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: EOF (null readLine) exits via quit fallback"):
withInput(""):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: invalid format prints message and recurses until quit"):
withInput("badmove\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: NoPiece prints message and recurses until quit"):
// E3 is empty in the initial position
withInput("e3e4\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: WrongColor prints message and recurses until quit"):
// E7 has a Black pawn; it is White's turn
withInput("e7e6\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: IllegalMove prints message and recurses until quit"):
withInput("e2e5\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: legal non-capture move recurses with new board then quits"):
withInput("e2e4\nquit\n"):
gameLoop(Board.initial, GameHistory.empty, Color.White)
test("gameLoop: capture move prints capture message then recurses and quits"):
val captureBoard = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R6) -> Piece.BlackPawn,
sq(File.H, Rank.R1) -> Piece.BlackKing,
sq(File.H, Rank.R8) -> Piece.WhiteKing
))
withInput("e5d6\nquit\n"):
gameLoop(captureBoard, GameHistory.empty, Color.White)
// ──── helpers ────────────────────────────────────────────────────────
private def captureOutput(block: => Unit): String =
val out = java.io.ByteArrayOutputStream()
scala.Console.withOut(out)(block)
out.toString("UTF-8")
// ──── processMove: check / checkmate / stalemate ─────────────────────
test("processMove: legal move that delivers check returns MovedInCheck"):
@@ -161,56 +103,6 @@ class GameControllerTest extends AnyFunSuite with Matchers:
case MoveResult.Stalemate => succeed
case other => fail(s"Expected Stalemate, got $other")
// ──── gameLoop: check / checkmate / stalemate ─────────────────────────
test("gameLoop: checkmate prints winner message and resets to new game"):
// After Qa1-Qh8, position is checkmate; second "quit" exits the new game
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteQueen,
sq(File.A, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1h8\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("Checkmate! White wins.")
test("gameLoop: stalemate prints draw message and resets to new game"):
val b = Board(Map(
sq(File.B, Rank.R1) -> Piece.WhiteQueen,
sq(File.C, Rank.R6) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("b1b6\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("Stalemate! The game is a draw.")
test("gameLoop: MovedInCheck without capture prints check message"):
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("Black is in check!")
test("gameLoop: MovedInCheck with capture prints both capture and check message"):
// White Rook A1 captures Black Pawn on A8, Ra8 then attacks rank 8 putting Kh8 in check
val b = Board(Map(
sq(File.A, Rank.R1) -> Piece.WhiteRook,
sq(File.C, Rank.R3) -> Piece.WhiteKing,
sq(File.A, Rank.R8) -> Piece.BlackPawn,
sq(File.H, Rank.R8) -> Piece.BlackKing
))
val output = captureOutput:
withInput("a1a8\nquit\n"):
gameLoop(b, GameHistory.empty, Color.White)
output should include("captures")
output should include("Black is in check!")
// ──── castling execution ─────────────────────────────────────────────
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
@@ -365,3 +257,39 @@ class GameControllerTest extends AnyFunSuite with Matchers:
case MoveResult.MovedInCheck(_, newHistory, _, _) =>
castlingRights(newHistory, Color.White).queenSide shouldBe false
case other => fail(s"Expected Moved or MovedInCheck, got $other")
// ──── en passant ────────────────────────────────────────────────────────
test("en passant capture removes the captured pawn from the board"):
// Setup: white pawn e5, black pawn just double-pushed to d5 (ep target = d6)
val b = Board(Map(
Square(File.E, Rank.R5) -> Piece.WhitePawn,
Square(File.D, Rank.R5) -> Piece.BlackPawn,
Square(File.E, Rank.R1) -> Piece.WhiteKing,
Square(File.E, Rank.R8) -> Piece.BlackKing
))
val h = GameHistory.empty.addMove(Square(File.D, Rank.R7), Square(File.D, Rank.R5))
val result = GameController.processMove(b, h, Color.White, "e5d6")
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(Square(File.D, Rank.R5)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.D, Rank.R6)) shouldBe Some(Piece.WhitePawn) // capturing pawn placed
captured shouldBe Some(Piece.BlackPawn)
case other => fail(s"Expected Moved but got $other")
test("en passant capture by black removes the captured white pawn"):
// Setup: black pawn d4, white pawn just double-pushed to e4 (ep target = e3)
val b = Board(Map(
Square(File.D, Rank.R4) -> Piece.BlackPawn,
Square(File.E, Rank.R4) -> Piece.WhitePawn,
Square(File.E, Rank.R8) -> Piece.BlackKing,
Square(File.E, Rank.R1) -> Piece.WhiteKing
))
val h = GameHistory.empty.addMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
val result = GameController.processMove(b, h, Color.Black, "d4e3")
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(Square(File.E, Rank.R4)) shouldBe None // captured pawn removed
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
captured shouldBe Some(Piece.WhitePawn)
case other => fail(s"Expected Moved but got $other")
@@ -0,0 +1,214 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests for GameEngine edge cases and uncovered paths */
class GameEngineEdgeCasesTest extends AnyFunSuite with Matchers:
test("GameEngine handles empty input"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Please enter a valid move or command")
test("GameEngine processes quit command"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("quit")
// Quit just returns, no events
observer.events.isEmpty shouldBe true
test("GameEngine processes q command (short form)"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("q")
observer.events.isEmpty shouldBe true
test("GameEngine handles uppercase quit"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("QUIT")
observer.events.isEmpty shouldBe true
test("GameEngine handles undo on empty history"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.canUndo shouldBe false
engine.processUserInput("undo")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Nothing to undo")
test("GameEngine handles redo on empty redo history"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.canRedo shouldBe false
engine.processUserInput("redo")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Nothing to redo")
test("GameEngine parses invalid move format"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("invalid_move_format")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Invalid move format")
test("GameEngine handles lowercase input normalization"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput(" UNDO ") // With spaces and uppercase
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent] // No moves to undo yet
test("GameEngine preserves board state on invalid move"):
val engine = new GameEngine()
val initialBoard = engine.board
engine.processUserInput("invalid")
engine.board shouldBe initialBoard
test("GameEngine preserves turn on invalid move"):
val engine = new GameEngine()
val initialTurn = engine.turn
engine.processUserInput("invalid")
engine.turn shouldBe initialTurn
test("GameEngine undo with no commands available"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Make a valid move
engine.processUserInput("e2e4")
observer.events.clear()
// Undo it
engine.processUserInput("undo")
// Board should be reset
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine redo after undo"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
val boardAfterMove = engine.board
val turnAfterMove = engine.turn
observer.events.clear()
engine.processUserInput("undo")
engine.processUserInput("redo")
engine.board shouldBe boardAfterMove
engine.turn shouldBe turnAfterMove
test("GameEngine canUndo flag tracks state correctly"):
val engine = new GameEngine()
engine.canUndo shouldBe false
engine.processUserInput("e2e4")
engine.canUndo shouldBe true
engine.processUserInput("undo")
engine.canUndo shouldBe false
test("GameEngine canRedo flag tracks state correctly"):
val engine = new GameEngine()
engine.canRedo shouldBe false
engine.processUserInput("e2e4")
engine.canRedo shouldBe false
engine.processUserInput("undo")
engine.canRedo shouldBe true
test("GameEngine command history is accessible"):
val engine = new GameEngine()
engine.commandHistory.isEmpty shouldBe true
engine.processUserInput("e2e4")
engine.commandHistory.size shouldBe 1
test("GameEngine processes multiple moves in sequence"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
observer.events.clear()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
observer.events.size shouldBe 2
engine.commandHistory.size shouldBe 2
test("GameEngine can undo multiple moves"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("undo")
engine.turn shouldBe Color.Black
engine.processUserInput("undo")
engine.turn shouldBe Color.White
test("GameEngine thread-safe operations"):
val engine = new GameEngine()
// Access from synchronized methods
val board = engine.board
val history = engine.history
val turn = engine.turn
val canUndo = engine.canUndo
val canRedo = engine.canRedo
board shouldBe Board.initial
canUndo shouldBe false
canRedo shouldBe false
private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -0,0 +1,93 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests for GameEngine check/checkmate/stalemate paths */
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
test("GameEngine handles Checkmate (Fool's Mate)"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
engine.subscribe(observer)
// Play Fool's mate
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
observer.events.clear()
engine.processUserInput("d8h4")
// Verify CheckmateEvent
observer.events.size shouldBe 1
observer.events.head shouldBe a[CheckmateEvent]
val event = observer.events.head.asInstanceOf[CheckmateEvent]
event.winner shouldBe Color.Black
// Board should be reset after checkmate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine handles check detection"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
engine.subscribe(observer)
// Play a simple check
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("f1c4")
engine.processUserInput("g8f6")
observer.events.clear()
engine.processUserInput("c4f7") // Check!
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
checkEvents.size shouldBe 1
checkEvents.head.turn shouldBe Color.Black // Black is now in check
// Shortest known stalemate is 19 moves. Here is a faster one:
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
// Wait, let's just use Sam Loyd's 10-move stalemate:
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
test("GameEngine handles Stalemate via 10-move known sequence"):
val engine = new GameEngine()
val observer = new EndingMockObserver()
engine.subscribe(observer)
val moves = List(
"e2e3", "a7a5",
"d1h5", "a8a6",
"h5a5", "h7h5",
"h2h4", "a6h6",
"a5c7", "f7f6",
"c7d7", "e8f7",
"d7b7", "d8d3",
"b7b8", "d3h7",
"b8c8", "f7g6",
"c8e6"
)
moves.dropRight(1).foreach(engine.processUserInput)
observer.events.clear()
engine.processUserInput(moves.last)
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
stalemateEvents.size shouldBe 1
// Board should be reset after stalemate
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
private class EndingMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -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
@@ -0,0 +1,114 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent, MoveExecutedEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests for GameEngine invalid move handling via handleFailedMove */
class GameEngineInvalidMovesTest extends AnyFunSuite with Matchers:
test("GameEngine handles no piece at source square"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Try to move from h1 which may be empty or not have our piece
// We'll try from a clearly empty square
engine.processUserInput("h1h2")
// Should get an InvalidMoveEvent about NoPiece
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
test("GameEngine handles moving wrong color piece"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// White moves first
engine.processUserInput("e2e4")
observer.events.clear()
// White tries to move again (should fail - it's black's turn)
// But we need to try a move that looks legal but has wrong color
// This is hard to test because we'd need to be black and move white's piece
// Let's skip this for now and focus on testable cases
// Actually, let's try moving a square that definitely has the wrong piece
// Move a white pawn as black by reaching that position
engine.processUserInput("e7e5")
observer.events.clear()
// Now try to move white's e4 pawn as black (it's black's turn but e4 is white)
engine.processUserInput("e4e5")
observer.events.size shouldBe 1
val event = observer.events.head
event shouldBe an[InvalidMoveEvent]
test("GameEngine handles illegal move"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// A pawn can't move backward
engine.processUserInput("e2e1")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("Illegal move")
test("GameEngine handles pawn trying to move 3 squares"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Pawn can only move 1 or 2 squares on first move, not 3
engine.processUserInput("e2e5")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
test("GameEngine handles moving from empty square"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// h3 is empty in starting position
engine.processUserInput("h3h4")
observer.events.size shouldBe 1
observer.events.head shouldBe an[InvalidMoveEvent]
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
event.reason should include("No piece on that square")
test("GameEngine processes valid move after invalid attempt"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
// Try invalid move
engine.processUserInput("h3h4")
observer.events.clear()
// Make valid move
engine.processUserInput("e2e4")
observer.events.size shouldBe 1
observer.events.head shouldBe an[MoveExecutedEvent]
test("GameEngine maintains state after failed move attempt"):
val engine = new GameEngine()
val initialTurn = engine.turn
val initialBoard = engine.board
// Try invalid move
engine.processUserInput("h3h4")
// State should not change
engine.turn shouldBe initialTurn
engine.board shouldBe initialBoard
@@ -0,0 +1,310 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineTest extends AnyFunSuite with Matchers:
test("GameEngine starts with initial board state"):
val engine = new GameEngine()
engine.board shouldBe Board.initial
engine.history shouldBe GameHistory.empty
engine.turn shouldBe Color.White
test("GameEngine accepts Observer subscription"):
val engine = new GameEngine()
val mockObserver = new MockObserver()
engine.subscribe(mockObserver)
engine.observerCount shouldBe 1
test("GameEngine notifies observers on valid move"):
val engine = new GameEngine()
val mockObserver = new MockObserver()
engine.subscribe(mockObserver)
engine.processUserInput("e2e4")
mockObserver.events.size shouldBe 1
mockObserver.events.head shouldBe a[MoveExecutedEvent]
test("GameEngine updates state after valid move"):
val engine = new GameEngine()
val initialTurn = engine.turn
engine.processUserInput("e2e4")
engine.turn shouldNot be(initialTurn)
engine.turn shouldBe Color.Black
test("GameEngine notifies observers on invalid move"):
val engine = new GameEngine()
val mockObserver = new MockObserver()
engine.subscribe(mockObserver)
engine.processUserInput("invalid_move")
mockObserver.events.size shouldBe 1
test("GameEngine notifies multiple observers"):
val engine = new GameEngine()
val observer1 = new MockObserver()
val observer2 = new MockObserver()
engine.subscribe(observer1)
engine.subscribe(observer2)
engine.processUserInput("e2e4")
observer1.events.size shouldBe 1
observer2.events.size shouldBe 1
test("GameEngine allows observer unsubscription"):
val engine = new GameEngine()
val mockObserver = new MockObserver()
engine.subscribe(mockObserver)
engine.unsubscribe(mockObserver)
engine.observerCount shouldBe 0
test("GameEngine unsubscribed observer receives no events"):
val engine = new GameEngine()
val mockObserver = new MockObserver()
engine.subscribe(mockObserver)
engine.unsubscribe(mockObserver)
engine.processUserInput("e2e4")
mockObserver.events.size shouldBe 0
test("GameEngine reset notifies observers and resets state"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
val observer = new MockObserver()
engine.subscribe(observer)
engine.reset()
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
observer.events.size shouldBe 1
test("GameEngine processes sequence of moves"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
observer.events.size shouldBe 2
engine.turn shouldBe Color.White
test("GameEngine is thread-safe for synchronized operations"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
val t = new Thread(() => engine.processUserInput("e2e4"))
t.start()
t.join()
observer.events.size shouldBe 1
test("GameEngine canUndo returns false initially"):
val engine = new GameEngine()
engine.canUndo shouldBe false
test("GameEngine canUndo returns true after move"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.canUndo shouldBe true
test("GameEngine canRedo returns false initially"):
val engine = new GameEngine()
engine.canRedo shouldBe false
test("GameEngine undo restores previous state"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
val boardAfterMove = engine.board
engine.undo()
engine.board shouldBe Board.initial
engine.turn shouldBe Color.White
test("GameEngine undo notifies observers"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
val observer = new MockObserver()
engine.subscribe(observer)
observer.events.clear()
engine.undo()
observer.events.size shouldBe 1
observer.events.head shouldBe a[BoardResetEvent]
test("GameEngine redo replays undone move"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
val boardAfterMove = engine.board
engine.undo()
engine.redo()
engine.board shouldBe boardAfterMove
engine.turn shouldBe Color.Black
test("GameEngine canUndo false when nothing to undo"):
val engine = new GameEngine()
engine.canUndo shouldBe false
engine.processUserInput("e2e4")
engine.undo()
engine.canUndo shouldBe false
test("GameEngine canRedo true after undo"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.undo()
engine.canRedo shouldBe true
test("GameEngine canRedo false after redo"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.undo()
engine.redo()
engine.canRedo shouldBe false
test("GameEngine undo on empty history sends invalid event"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.undo()
observer.events.size shouldBe 1
observer.events.head shouldBe a[InvalidMoveEvent]
test("GameEngine redo on empty redo sends invalid event"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.redo()
observer.events.size shouldBe 1
observer.events.head shouldBe a[InvalidMoveEvent]
test("GameEngine undo via processUserInput"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
val boardAfterMove = engine.board
engine.processUserInput("undo")
engine.board shouldBe Board.initial
test("GameEngine redo via processUserInput"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
val boardAfterMove = engine.board
engine.processUserInput("undo")
engine.processUserInput("redo")
engine.board shouldBe boardAfterMove
test("GameEngine handles empty input"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("")
observer.events.size shouldBe 1
observer.events.head shouldBe a[InvalidMoveEvent]
test("GameEngine multiple undo/redo sequence"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("g1f3")
engine.turn shouldBe Color.Black
engine.undo()
engine.turn shouldBe Color.White
engine.undo()
engine.turn shouldBe Color.Black
engine.undo()
engine.turn shouldBe Color.White
engine.board shouldBe Board.initial
test("GameEngine redo after multiple undos"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.processUserInput("g1f3")
engine.undo()
engine.undo()
engine.undo()
engine.redo()
engine.turn shouldBe Color.Black
engine.redo()
engine.turn shouldBe Color.White
engine.redo()
engine.turn shouldBe Color.Black
test("GameEngine new move after undo clears redo history"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
engine.undo()
engine.canRedo shouldBe true
engine.processUserInput("e7e6") // Different move
engine.canRedo shouldBe false
test("GameEngine command history tracking"):
val engine = new GameEngine()
engine.commandHistory.size shouldBe 0
engine.processUserInput("e2e4")
engine.commandHistory.size shouldBe 1
engine.processUserInput("e7e5")
engine.commandHistory.size shouldBe 2
test("GameEngine quit input"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
val initialEvents = observer.events.size
engine.processUserInput("quit")
// quit should not produce an event
observer.events.size shouldBe initialEvents
test("GameEngine quit via q"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
val initialEvents = observer.events.size
engine.processUserInput("q")
observer.events.size shouldBe initialEvents
test("GameEngine undo notifies with BoardResetEvent after successful undo"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
val observer = new MockObserver()
engine.subscribe(observer)
observer.events.clear()
engine.undo()
// Should have received a BoardResetEvent on undo
observer.events.size should be > 0
observer.events.exists(_.isInstanceOf[BoardResetEvent]) shouldBe true
test("GameEngine redo notifies with MoveExecutedEvent after successful redo"):
val engine = new GameEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
val boardAfterSecondMove = engine.board
engine.undo()
val observer = new MockObserver()
engine.subscribe(observer)
observer.events.clear()
engine.redo()
// Should have received a MoveExecutedEvent for the redo
observer.events.size shouldBe 1
observer.events.head shouldBe a[MoveExecutedEvent]
engine.board shouldBe boardAfterSecondMove
engine.turn shouldBe Color.White
// Mock Observer for testing
private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -0,0 +1,110 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveCommandDefaultsTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
// Tests for MoveCommand with default parameter values
test("MoveCommand with no moveResult defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.moveResult shouldBe None
cmd.execute() shouldBe false
test("MoveCommand with no previousBoard defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.previousBoard shouldBe None
cmd.undo() shouldBe false
test("MoveCommand with no previousHistory defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.previousHistory shouldBe None
cmd.undo() shouldBe false
test("MoveCommand with no previousTurn defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.previousTurn shouldBe None
cmd.undo() shouldBe false
test("MoveCommand description is always returned"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.description shouldBe "Move from e2 to e4"
test("MoveCommand execute returns false when moveResult is None"):
val cmd = MoveCommand(
from = sq(File.A, Rank.R1),
to = sq(File.B, Rank.R3)
)
cmd.execute() shouldBe false
test("MoveCommand undo returns false when any previous state is None"):
// Missing previousBoard
val cmd1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = None,
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
cmd1.undo() shouldBe false
// Missing previousHistory
val cmd2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = None,
previousTurn = Some(Color.White)
)
cmd2.undo() shouldBe false
// Missing previousTurn
val cmd3 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = None
)
cmd3.undo() shouldBe false
test("MoveCommand execute returns true when moveResult is defined"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
)
cmd.execute() shouldBe true
test("MoveCommand undo returns true when all previous states are defined"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
cmd.undo() shouldBe true
@@ -0,0 +1,101 @@
package de.nowchess.chess.logic
import de.nowchess.api.board.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class EnPassantCalculatorTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def board(entries: (Square, Piece)*): Board = Board(entries.toMap)
// ──── enPassantTarget ────────────────────────────────────────────────
test("enPassantTarget returns None for empty history"):
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
EnPassantCalculator.enPassantTarget(b, GameHistory.empty) shouldBe None
test("enPassantTarget returns None when last move was a single pawn push"):
val b = board(sq(File.E, Rank.R3) -> Piece.WhitePawn)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R3))
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
test("enPassantTarget returns None when last move was not a pawn"):
val b = board(sq(File.E, Rank.R4) -> Piece.WhiteRook)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
EnPassantCalculator.enPassantTarget(b, h) shouldBe None
test("enPassantTarget returns e3 after white pawn double push e2-e4"):
val b = board(sq(File.E, Rank.R4) -> Piece.WhitePawn)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R3))
test("enPassantTarget returns e6 after black pawn double push e7-e5"):
val b = board(sq(File.E, Rank.R5) -> Piece.BlackPawn)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.E, Rank.R6))
test("enPassantTarget returns d3 after white pawn double push d2-d4"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhitePawn)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
EnPassantCalculator.enPassantTarget(b, h) shouldBe Some(sq(File.D, Rank.R3))
// ──── capturedPawnSquare ─────────────────────────────────────────────
test("capturedPawnSquare for white capturing on e6 returns e5"):
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R6), Color.White) shouldBe sq(File.E, Rank.R5)
test("capturedPawnSquare for black capturing on e3 returns e4"):
EnPassantCalculator.capturedPawnSquare(sq(File.E, Rank.R3), Color.Black) shouldBe sq(File.E, Rank.R4)
test("capturedPawnSquare for white capturing on d6 returns d5"):
EnPassantCalculator.capturedPawnSquare(sq(File.D, Rank.R6), Color.White) shouldBe sq(File.D, Rank.R5)
// ──── isEnPassant ────────────────────────────────────────────────────
test("isEnPassant returns true for valid white en passant capture"):
// White pawn on e5, black pawn just double-pushed to d5 (ep target = d6)
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe true
test("isEnPassant returns true for valid black en passant capture"):
// Black pawn on d4, white pawn just double-pushed to e4 (ep target = e3)
val b = board(
sq(File.D, Rank.R4) -> Piece.BlackPawn,
sq(File.E, Rank.R4) -> Piece.WhitePawn
)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
EnPassantCalculator.isEnPassant(b, h, sq(File.D, Rank.R4), sq(File.E, Rank.R3)) shouldBe true
test("isEnPassant returns false when no en passant target in history"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
test("isEnPassant returns false when piece at from is not a pawn"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhiteRook,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
test("isEnPassant returns false when to does not match ep target"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.E, Rank.R6)) shouldBe false
test("isEnPassant returns false when from square is empty"):
val b = board(sq(File.D, Rank.R5) -> Piece.BlackPawn)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
EnPassantCalculator.isEnPassant(b, h, sq(File.E, Rank.R5), sq(File.D, Rank.R6)) shouldBe false
@@ -211,3 +211,47 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
sq(File.E, Rank.R4) -> Piece.BlackRook
)
MoveValidator.legalTargets(b, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R4))
// ──── Pawn en passant targets ──────────────────────────────────────
test("white pawn includes ep target in legal moves after black double push"):
// Black pawn just double-pushed to d5 (ep target = d6); white pawn on e5
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should contain(sq(File.D, Rank.R6))
test("white pawn does not include ep target without a preceding double push"):
val b = board(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R6), sq(File.D, Rank.R5)) // single push
MoveValidator.legalTargets(b, h, sq(File.E, Rank.R5)) should not contain sq(File.D, Rank.R6)
test("black pawn includes ep target in legal moves after white double push"):
// White pawn just double-pushed to e4 (ep target = e3); black pawn on d4
val b = board(
sq(File.D, Rank.R4) -> Piece.BlackPawn,
sq(File.E, Rank.R4) -> Piece.WhitePawn
)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) should contain(sq(File.E, Rank.R3))
test("pawn on wrong file does not get ep target from adjacent double push"):
// White pawn on a5, black pawn double-pushed to d5 — a5 is not adjacent to d5
val b = board(
sq(File.A, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R5) -> Piece.BlackPawn
)
val h = GameHistory.empty.addMove(sq(File.D, Rank.R7), sq(File.D, Rank.R5))
MoveValidator.legalTargets(b, h, sq(File.A, Rank.R5)) should not contain sq(File.D, Rank.R6)
// ──── History-aware legalTargets fallback for non-pawn non-king pieces ─────
test("legalTargets with history delegates to geometry-only for non-pawn non-king pieces"):
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook)
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
@@ -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)
@@ -0,0 +1,69 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class FenExporterTest extends AnyFunSuite with Matchers:
test("export initial position to FEN"):
val gameState = GameState.initial
val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
test("export position after e4"):
val gameState = GameState(
piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR",
activeColor = Color.Black,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = Some(Square(File.E, Rank.R3)),
halfMoveClock = 0,
fullMoveNumber = 1,
status = GameStatus.InProgress
)
val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
test("export position with no castling"):
val gameState = GameState(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights.None,
castlingBlack = CastlingRights.None,
enPassantTarget = None,
halfMoveClock = 0,
fullMoveNumber = 1,
status = GameStatus.InProgress
)
val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
test("export position with partial castling"):
val gameState = GameState(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights(kingSide = true, queenSide = false),
castlingBlack = CastlingRights(kingSide = false, queenSide = true),
enPassantTarget = None,
halfMoveClock = 5,
fullMoveNumber = 3,
status = GameStatus.InProgress
)
val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
test("export position with en passant and move counts"):
val gameState = GameState(
piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = Some(Square(File.C, Rank.R6)),
halfMoveClock = 2,
fullMoveNumber = 3,
status = GameStatus.InProgress
)
val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
@@ -0,0 +1,134 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class FenParserTest extends AnyFunSuite with Matchers:
test("parseBoard: initial position places pieces on correct squares"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val board = FenParser.parseBoard(fen)
board.map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
board.map(_.pieceAt(Square(File.E, Rank.R7))) shouldBe Some(Some(Piece.BlackPawn))
board.map(_.pieceAt(Square(File.E, Rank.R1))) shouldBe Some(Some(Piece.WhiteKing))
board.map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
test("parseBoard: empty board has no pieces"):
val fen = "8/8/8/8/8/8/8/8"
val board = FenParser.parseBoard(fen)
board shouldBe defined
board.get.pieces.size shouldBe 0
test("parseBoard: returns None for missing rank (only 7 ranks)"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP"
val board = FenParser.parseBoard(fen)
board shouldBe empty
test("parseBoard: returns None for invalid piece character"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNX"
val board = FenParser.parseBoard(fen)
board shouldBe empty
test("parseBoard: partial position with two kings placed correctly"):
val fen = "8/8/4k3/8/4K3/8/8/8"
val board = FenParser.parseBoard(fen)
board.map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
board.map(_.pieceAt(Square(File.E, Rank.R4))) shouldBe Some(Some(Piece.WhiteKing))
test("testRoundTripInitialPosition"):
val originalFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val board = FenParser.parseBoard(originalFen)
val exportedFen = board.map(FenExporter.boardToFen)
exportedFen shouldBe Some(originalFen)
test("testRoundTripEmptyBoard"):
val originalFen = "8/8/8/8/8/8/8/8"
val board = FenParser.parseBoard(originalFen)
val exportedFen = board.map(FenExporter.boardToFen)
exportedFen shouldBe Some(originalFen)
test("testRoundTripPartialPosition"):
val originalFen = "8/8/4k3/8/4K3/8/8/8"
val board = FenParser.parseBoard(originalFen)
val exportedFen = board.map(FenExporter.boardToFen)
exportedFen shouldBe Some(originalFen)
test("parse full FEN - initial position"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val gameState = FenParser.parseFen(fen)
gameState.isDefined shouldBe true
gameState.get.activeColor shouldBe Color.White
gameState.get.castlingWhite.kingSide shouldBe true
gameState.get.castlingWhite.queenSide shouldBe true
gameState.get.castlingBlack.kingSide shouldBe true
gameState.get.castlingBlack.queenSide shouldBe true
gameState.get.enPassantTarget shouldBe None
gameState.get.halfMoveClock shouldBe 0
gameState.get.fullMoveNumber shouldBe 1
test("parse full FEN - after e4"):
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
val gameState = FenParser.parseFen(fen)
gameState.get.activeColor shouldBe Color.Black
gameState.get.enPassantTarget shouldBe Some(Square(File.E, Rank.R3))
test("parse full FEN - invalid parts count"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq"
val gameState = FenParser.parseFen(fen)
gameState.isDefined shouldBe false
test("parse full FEN - invalid color"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1"
val gameState = FenParser.parseFen(fen)
gameState.isDefined shouldBe false
test("parse full FEN - invalid castling"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1"
val gameState = FenParser.parseFen(fen)
gameState.isDefined shouldBe false
test("parseFen: castling '-' produces CastlingRights.None for both sides"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
val gameState = FenParser.parseFen(fen)
gameState.isDefined shouldBe true
gameState.get.castlingWhite.kingSide shouldBe false
gameState.get.castlingWhite.queenSide shouldBe false
gameState.get.castlingBlack.kingSide shouldBe false
gameState.get.castlingBlack.queenSide shouldBe false
test("parseBoard: returns None when a rank has too many files (overflow beyond 8)"):
// "9" alone would advance fileIdx to 9, exceeding 8 → None
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBN9"
val board = FenParser.parseBoard(fen)
board shouldBe empty
test("parseBoard: returns None when a rank fails to parse (invalid middle rank)"):
// Invalid character 'X' in rank 4 should cause failure
val fen = "rnbqkbnr/pppppppp/8/8/XXXXXXXX/8/PPPPPPPP/RNBQKBNR"
val board = FenParser.parseBoard(fen)
board shouldBe empty
test("parseBoard: returns None when a rank has 9 piece characters (fileIdx > 7)"):
// 9 pawns in one rank triggers fileIdx > 7 guard (line 78)
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/PPPPPPPPP"
val board = FenParser.parseBoard(fen)
board shouldBe empty
@@ -0,0 +1,65 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PgnExporterTest extends AnyFunSuite with Matchers:
test("export empty game") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val history = GameHistory.empty
val pgn = PgnExporter.exportGame(headers, history)
pgn.contains("[Event \"Test\"]") shouldBe true
pgn.contains("[White \"A\"]") shouldBe true
pgn.contains("[Black \"B\"]") shouldBe true
}
test("export single move") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(headers, history)
pgn.contains("1. e2e4") shouldBe true
}
test("export castling") {
val headers = Map("Event" -> "Test")
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.G, Rank.R1), Some(CastleSide.Kingside)))
val pgn = PgnExporter.exportGame(headers, history)
pgn.contains("O-O") shouldBe true
}
test("export game sequence") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B", "Result" -> "1-0")
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
.addMove(HistoryMove(Square(File.C, Rank.R7), Square(File.C, Rank.R5), None))
.addMove(HistoryMove(Square(File.G, Rank.R1), Square(File.F, Rank.R3), None))
val pgn = PgnExporter.exportGame(headers, history)
pgn.contains("1. e2e4 c7c5") shouldBe true
pgn.contains("2. g1f3") shouldBe true
}
test("export game with no headers returns only move text") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn shouldBe "1. e2e4 *"
}
test("export queenside castling") {
val headers = Map("Event" -> "Test")
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R1), Square(File.C, Rank.R1), Some(CastleSide.Queenside)))
val pgn = PgnExporter.exportGame(headers, history)
pgn.contains("O-O-O") shouldBe true
}
@@ -0,0 +1,334 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PgnParserTest extends AnyFunSuite with Matchers:
test("parse PGN headers only") {
val pgn = """[Event "Test Game"]
[Site "Earth"]
[Date "2026.03.28"]
[White "Alice"]
[Black "Bob"]
[Result "1-0"]"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.headers("Event") shouldBe "Test Game"
game.get.headers("White") shouldBe "Alice"
game.get.headers("Result") shouldBe "1-0"
game.get.moves shouldBe List()
}
test("parse PGN simple game") {
val pgn = """[Event "Test"]
[Site "?"]
[Date "2026.03.28"]
[White "A"]
[Black "B"]
[Result "*"]
1. e4 e5 2. Nf3 Nc6 3. Bb5 a6
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 6
// e4: e2-e4
game.get.moves(0).from shouldBe Square(File.E, Rank.R2)
game.get.moves(0).to shouldBe Square(File.E, Rank.R4)
}
test("parse PGN move with capture") {
val pgn = """[Event "Test"]
[White "A"]
[Black "B"]
1. e4 e5 2. Nxe5
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 3
// Nxe5: knight captures on e5
game.get.moves(2).to shouldBe Square(File.E, Rank.R5)
}
test("parse PGN castling") {
val pgn = """[Event "Test"]
[White "A"]
[Black "B"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
// O-O is kingside castling: king e1-g1
val lastMove = game.get.moves.last
lastMove.from shouldBe Square(File.E, Rank.R1)
lastMove.to shouldBe Square(File.G, Rank.R1)
lastMove.castleSide.isDefined shouldBe true
}
test("parse PGN empty moves") {
val pgn = """[Event "Test"]
[White "A"]
[Black "B"]
[Result "1-0"]
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 0
}
test("parse PGN black kingside castling O-O") {
// After e4 e5 Nf3 Nc6 Bc4 Bc5, black can castle kingside
val pgn = """[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val blackCastle = game.get.moves.last
blackCastle.castleSide shouldBe Some(CastleSide.Kingside)
blackCastle.from shouldBe Square(File.E, Rank.R8)
blackCastle.to shouldBe Square(File.G, Rank.R8)
}
test("parse PGN result tokens are skipped") {
// Result tokens like 1-0, 0-1, 1/2-1/2, * should be silently skipped
val pgn = """[Event "Test"]
1. e4 e5 1-0
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 2
}
test("parseAlgebraicMove: unrecognised token returns None and is skipped") {
val board = Board.initial
val history = GameHistory.empty
// "zzz" is not valid algebraic notation
val result = PgnParser.parseAlgebraicMove("zzz", board, history, Color.White)
result shouldBe None
}
test("parseAlgebraicMove: piece moves use charToPieceType for N B R Q K") {
// Test that piece type characters are recognised
val board = Board.initial
val history = GameHistory.empty
// Nf3 - knight move
val nMove = PgnParser.parseAlgebraicMove("Nf3", board, history, Color.White)
nMove.isDefined shouldBe true
nMove.get.to shouldBe Square(File.F, Rank.R3)
}
test("parseAlgebraicMove: single char that is too short returns None") {
val board = Board.initial
val history = GameHistory.empty
// Single char that is not castling and cleaned length < 2
val result = PgnParser.parseAlgebraicMove("e", board, history, Color.White)
result shouldBe None
}
test("parse PGN with file disambiguation hint") {
// Use a position where two rooks can reach the same square to test file hint
// Rooks on a1 and h1, destination d1 - "Rad1" uses file 'a' to disambiguate
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
val pieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val board = Board(pieces)
val history = GameHistory.empty
val result = PgnParser.parseAlgebraicMove("Rad1", board, history, Color.White)
result.isDefined shouldBe true
result.get.from shouldBe Square(File.A, Rank.R1)
result.get.to shouldBe Square(File.D, Rank.R1)
}
test("parse PGN with rank disambiguation hint") {
// Two rooks on a1 and a4 can reach a3 - "R1a3" uses rank '1' to disambiguate
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
val pieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val board = Board(pieces)
val history = GameHistory.empty
val result = PgnParser.parseAlgebraicMove("R1a3", board, history, Color.White)
result.isDefined shouldBe true
result.get.from shouldBe Square(File.A, Rank.R1)
result.get.to shouldBe Square(File.A, Rank.R3)
}
test("parseAlgebraicMove: charToPieceType covers all piece letters including B R Q K") {
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
// Bishop move
val piecesForBishop: Map[Square, Piece] = Map(
Square(File.C, Rank.R1) -> Piece(Color.White, PieceType.Bishop),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val boardBishop = Board(piecesForBishop)
val bResult = PgnParser.parseAlgebraicMove("Bd2", boardBishop, GameHistory.empty, Color.White)
bResult.isDefined shouldBe true
// Rook move
val piecesForRook: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val boardRook = Board(piecesForRook)
val rResult = PgnParser.parseAlgebraicMove("Ra4", boardRook, GameHistory.empty, Color.White)
rResult.isDefined shouldBe true
// Queen move
val piecesForQueen: Map[Square, Piece] = Map(
Square(File.D, Rank.R1) -> Piece(Color.White, PieceType.Queen),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val boardQueen = Board(piecesForQueen)
val qResult = PgnParser.parseAlgebraicMove("Qd4", boardQueen, GameHistory.empty, Color.White)
qResult.isDefined shouldBe true
// King move
val piecesForKing: Map[Square, Piece] = Map(
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val boardKing = Board(piecesForKing)
val kResult = PgnParser.parseAlgebraicMove("Ke2", boardKing, GameHistory.empty, Color.White)
kResult.isDefined shouldBe true
}
test("parse PGN queenside castling O-O-O") {
val pgn = """[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.castleSide shouldBe Some(CastleSide.Queenside)
lastMove.from shouldBe Square(File.E, Rank.R1)
lastMove.to shouldBe Square(File.C, Rank.R1)
}
test("parse PGN black queenside castling O-O-O") {
// After sufficient moves, black castles queenside
val pgn = """[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.castleSide shouldBe Some(CastleSide.Queenside)
lastMove.from shouldBe Square(File.E, Rank.R8)
lastMove.to shouldBe Square(File.C, Rank.R8)
}
test("parse PGN with unrecognised token in move text is silently skipped") {
// "INVALID" is not valid PGN; it should be skipped and remaining moves parsed
val pgn = """[Event "Test"]
1. e4 INVALID e5
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
// e4 parsed, INVALID skipped, e5 parsed
game.get.moves.length shouldBe 2
}
test("parseAlgebraicMove: file+rank disambiguation with piece letter") {
// "Rae1" notation: piece R, disambig "a" -> hint is "a", piece letter is uppercase first char of disambig
// But since disambig="a" which is not uppercase, the piece letter comes from clean.head
// Test "Rae1" style: R is clean.head uppercase, disambig "a" is the hint
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
val pieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
Square(File.H, Rank.R4) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val board = Board(pieces)
val history = GameHistory.empty
// "Rae4" - Rook from a-file to e4; disambig = "a", clean.head = 'R' uppercase
val result = PgnParser.parseAlgebraicMove("Rae4", board, history, Color.White)
result.isDefined shouldBe true
result.get.from shouldBe Square(File.A, Rank.R4)
result.get.to shouldBe Square(File.E, Rank.R4)
}
test("parseAlgebraicMove: charToPieceType returns None for unknown character") {
// 'Z' is not a valid piece letter - the regex clean should return None
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
val board = Board.initial
val history = GameHistory.empty
// "Ze4" - Z is not a valid piece, charToPieceType('Z') returns None
// The result will be None because requiredPieceType is None and filtering by None.forall = true
// so it finds any piece that can reach e4, but since clean="Ze4" -> destStr="e4", disambig="Z"
// disambig.head.isUpper so charToPieceType('Z') is called
val result = PgnParser.parseAlgebraicMove("Ze4", board, history, Color.White)
// With None piece type, forall(pt => ...) is vacuously true so any piece reaching e4 is candidate
// But there's no piece named Z so requiredPieceType=None, meaning any piece can match
// This tests that charToPieceType('Z') returns None without crashing
result shouldBe defined // will find a pawn or whatever reaches e4
}
test("parseAlgebraicMove: uppercase dest-only notation hits clean.head.isUpper and charToPieceType unknown char") {
// "E4" - clean = "E4", disambig = "", clean.head = 'E' is upper, charToPieceType('E') returns None
// This exercises line 97 (else if clean.head.isUpper) and line 152 (case _ => None)
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
val board = Board.initial
val history = GameHistory.empty
// 'E' is not a valid piece type but we still get a result since requiredPieceType is None
val result = PgnParser.parseAlgebraicMove("E4", board, history, Color.White)
// Result may be defined (pawn that can reach e4) or None; main goal is no crash and line coverage
result should not be null // just verifies code path executes without exception
}
test("parseAlgebraicMove: rank disambiguation with digit outside 1-8 hits matchesHint else-true branch") {
// Build a board with a Rook that can be targeted with a disambiguation hint containing '9'
// hint = "9" → c = '9', not in a-h, not in 1-8, triggers else true
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
val pieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val board = Board(pieces)
val history = GameHistory.empty
// "R9d1" - clean = "R9d1", destStr = "d1", disambig = "R9"
// disambig.head = 'R' is upper -> charToPieceType('R') = Rook, hint = "9"
// matchesHint called with hint "9" -> '9' not in a-h, not in 1-8 -> else true
val result = PgnParser.parseAlgebraicMove("R9d1", board, history, Color.White)
// Should find a rook (hint "9" matches everything)
result.isDefined shouldBe true
result.get.to shouldBe Square(File.D, Rank.R1)
}
@@ -0,0 +1,168 @@
package de.nowchess.chess.observer
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scala.collection.mutable
class ObservableThreadSafetyTest extends AnyFunSuite with Matchers:
private class TestObservable extends Observable:
def testNotifyObservers(event: GameEvent): Unit =
notifyObservers(event)
private class CountingObserver extends Observer:
@volatile private var eventCount = 0
@volatile private var lastEvent: Option[GameEvent] = None
def onGameEvent(event: GameEvent): Unit =
eventCount += 1
lastEvent = Some(event)
private def createTestEvent(): GameEvent =
BoardResetEvent(
board = Board.initial,
history = GameHistory.empty,
turn = Color.White
)
test("Observable is thread-safe for concurrent subscribe and notify"):
val observable = new TestObservable()
val testEvent = createTestEvent()
@volatile var raceConditionCaught = false
// Thread 1: repeatedly notifies observers with long iteration
val notifierThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500000 do
observable.testNotifyObservers(testEvent)
} catch {
case _: java.util.ConcurrentModificationException =>
raceConditionCaught = true
}
}
})
// Thread 2: rapidly subscribes/unsubscribes observers during notify
val subscriberThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500000 do
val obs = new CountingObserver()
observable.subscribe(obs)
observable.unsubscribe(obs)
} catch {
case _: java.util.ConcurrentModificationException =>
raceConditionCaught = true
}
}
})
notifierThread.start()
subscriberThread.start()
notifierThread.join()
subscriberThread.join()
raceConditionCaught shouldBe false
test("Observable is thread-safe for concurrent subscribe, unsubscribe, and notify"):
val observable = new TestObservable()
val testEvent = createTestEvent()
val exceptions = mutable.ListBuffer[Exception]()
val observers = mutable.ListBuffer[CountingObserver]()
// Pre-subscribe some observers
for _ <- 1 to 10 do
val obs = new CountingObserver()
observers += obs
observable.subscribe(obs)
// Thread 1: notifies observers
val notifierThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 5000 do
observable.testNotifyObservers(testEvent)
} catch {
case e: Exception => exceptions += e
}
}
})
// Thread 2: subscribes new observers
val subscriberThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 5000 do
val obs = new CountingObserver()
observable.subscribe(obs)
} catch {
case e: Exception => exceptions += e
}
}
})
// Thread 3: unsubscribes observers
val unsubscriberThread = new Thread(new Runnable {
def run(): Unit = {
try {
for i <- 1 to 5000 do
if observers.nonEmpty then
val obs = observers(i % observers.size)
observable.unsubscribe(obs)
} catch {
case e: Exception => exceptions += e
}
}
})
notifierThread.start()
subscriberThread.start()
unsubscriberThread.start()
notifierThread.join()
subscriberThread.join()
unsubscriberThread.join()
exceptions.isEmpty shouldBe true
test("Observable.observerCount is thread-safe during concurrent modifications"):
val observable = new TestObservable()
val exceptions = mutable.ListBuffer[Exception]()
val countResults = mutable.ListBuffer[Int]()
// Thread 1: subscribes observers
val subscriberThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
observable.subscribe(new CountingObserver())
} catch {
case e: Exception => exceptions += e
}
}
})
// Thread 2: reads observer count
val readerThread = new Thread(new Runnable {
def run(): Unit = {
try {
for _ <- 1 to 500 do
val count = observable.observerCount
countResults += count
} catch {
case e: Exception => exceptions += e
}
}
})
subscriberThread.start()
readerThread.start()
subscriberThread.join()
readerThread.join()
exceptions.isEmpty shouldBe true
// Count should never go backwards
for i <- 1 until countResults.size do
countResults(i) >= countResults(i - 1) shouldBe true
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=2
MINOR=4
PATCH=0
+73
View File
@@ -0,0 +1,73 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
application
}
group = "de.nowchess"
version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
application {
mainClass.set("de.nowchess.ui.Main")
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
tasks.named<JavaExec>("run") {
jvmArgs("-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8")
standardInput = System.`in`
}
dependencies {
implementation("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation("org.scala-lang:scala3-library_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation(project(":modules:core"))
implementation(project(":modules:api"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
events("passed", "skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
@@ -0,0 +1,15 @@
package de.nowchess.ui
import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI
/** Application entry point - starts the Terminal UI for the chess game. */
object Main:
def main(args: Array[String]): Unit =
// Create the core game engine (single source of truth)
val engine = new GameEngine()
// Create and start the terminal UI
val tui = new TerminalUI(engine)
tui.start()
@@ -0,0 +1,76 @@
package de.nowchess.ui.terminal
import scala.io.StdIn
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.{Observer, GameEvent, *}
import de.nowchess.chess.view.Renderer
/** Terminal UI that implements Observer pattern.
* Subscribes to GameEngine and receives state change events.
* Handles all I/O and user interaction in the terminal.
*/
class TerminalUI(engine: GameEngine) extends Observer:
private var running = true
/** Called by GameEngine whenever a game event occurs. */
override def onGameEvent(event: GameEvent): Unit =
event match
case e: MoveExecutedEvent =>
println()
print(Renderer.render(e.board))
e.capturedPiece.foreach: cap =>
println(s"Captured: $cap on ${e.toSquare}")
printPrompt(e.turn)
case e: CheckDetectedEvent =>
println(s"${e.turn.label} is in check!")
case e: CheckmateEvent =>
println(s"Checkmate! ${e.winner.label} wins.")
println()
print(Renderer.render(e.board))
case e: StalemateEvent =>
println("Stalemate! The game is a draw.")
println()
print(Renderer.render(e.board))
case e: InvalidMoveEvent =>
println(s"⚠️ ${e.reason}")
case e: BoardResetEvent =>
println("Board has been reset to initial position.")
println()
print(Renderer.render(e.board))
printPrompt(e.turn)
/** Start the terminal UI game loop. */
def start(): Unit =
// Register as observer
engine.subscribe(this)
// Show initial board
println()
print(Renderer.render(engine.board))
printPrompt(engine.turn)
// Game loop
while running do
val input = Option(StdIn.readLine()).getOrElse("quit").trim
input.toLowerCase match
case "quit" | "q" =>
running = false
println("Game over. Goodbye!")
case "" =>
printPrompt(engine.turn)
case _ =>
engine.processUserInput(input)
// Unsubscribe when done
engine.unsubscribe(this)
private def printPrompt(turn: de.nowchess.api.board.Color): Unit =
val undoHint = if engine.canUndo then " [undo]" else ""
val redoHint = if engine.canRedo then " [redo]" else ""
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
@@ -0,0 +1,22 @@
package de.nowchess.ui
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
class MainTest extends AnyFunSuite with Matchers {
test("main should execute and quit immediately when fed 'quit'") {
val in = new ByteArrayInputStream("quit\n".getBytes)
val out = new ByteArrayOutputStream()
Console.withIn(in) {
Console.withOut(out) {
Main.main(Array.empty)
}
}
val output = out.toString
output should include ("Game over. Goodbye!")
}
}
@@ -0,0 +1,189 @@
package de.nowchess.ui.terminal
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.logic.GameHistory
class TerminalUITest extends AnyFunSuite with Matchers {
test("TerminalUI should start, print initial state, and correctly respond to 'q'") {
val in = new ByteArrayInputStream("q\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
}
test("TerminalUI should ignore empty inputs and re-print prompt") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// Prompt appears three times: Initial, after empty, on exit.
output.split("White's turn.").length should be > 2
}
test("TerminalUI should explicitly handle empty input by re-prompting") {
val in = new ByteArrayInputStream("\n\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// With two empty inputs, prompt should appear at least 4 times:
// 1. Initial board display
// 2. After first empty input
// 3. After second empty input
// 4. Before quit
val promptCount = output.split("White's turn.").length
promptCount should be >= 4
output should include("Game over. Goodbye!")
}
test("TerminalUI printPrompt should include undo and redo hints if engine returns true") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine() {
// Stub engine to force undo/redo to true
override def canUndo: Boolean = true
override def canRedo: Boolean = true
}
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("[undo]")
output should include("[redo]")
}
test("TerminalUI onGameEvent should properly format InvalidMoveEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(InvalidMoveEvent(Board(Map.empty), GameHistory(), Color.Black, "Invalid move format"))
}
out.toString should include("⚠️")
out.toString should include("Invalid move format")
}
test("TerminalUI onGameEvent should properly format CheckDetectedEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckDetectedEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Black is in check!")
}
test("TerminalUI onGameEvent should properly format CheckmateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckmateEvent(Board(Map.empty), GameHistory(), Color.Black, Color.White))
}
val ostr = out.toString
ostr should include("Checkmate! White wins.")
}
test("TerminalUI onGameEvent should properly format StalemateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(StalemateEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Stalemate! The game is a draw.")
}
test("TerminalUI onGameEvent should properly format BoardResetEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(BoardResetEvent(Board(Map.empty), GameHistory(), Color.White))
}
out.toString should include("Board has been reset to initial position.")
}
test("TerminalUI onGameEvent should properly format MoveExecutedEvent with capturing piece") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(MoveExecutedEvent(Board(Map.empty), GameHistory(), Color.Black, "A1", "A8", Some("Knight(White)")))
}
out.toString should include("Captured: Knight(White) on A8") // Depending on how piece/coord serialize
}
test("TerminalUI processes valid move input via processUserInput") {
val in = new ByteArrayInputStream("e2e4\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
// The move should have been processed and the board displayed
engine.turn shouldBe Color.Black
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
rootProject.name = "NowChessSystems"
include("modules:core", "modules:api")
include("modules:core", "modules:api", "modules:ui")