Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0cd46f132 | |||
| 9c1acfc9db | |||
| d2f294294f | |||
| 7d60c4bb29 | |||
| 60b02d8f20 | |||
| 9bc1ef550f | |||
| 98896535ed | |||
| 18c712d5c9 | |||
| ade9d14ddc | |||
| 5db1405066 |
Generated
-1
@@ -12,7 +12,6 @@
|
|||||||
<option value="$PROJECT_DIR$/modules" />
|
<option value="$PROJECT_DIR$/modules" />
|
||||||
<option value="$PROJECT_DIR$/modules/api" />
|
<option value="$PROJECT_DIR$/modules/api" />
|
||||||
<option value="$PROJECT_DIR$/modules/core" />
|
<option value="$PROJECT_DIR$/modules/core" />
|
||||||
<option value="$PROJECT_DIR$/modules/ui" />
|
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
|
|||||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</profile>
|
||||||
<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">
|
<profile name="Gradle 2" modules="NowChessSystems.modules.core.main,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test">
|
||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
<parameters>
|
<parameters>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#FileLock
|
||||||
|
#Sun Mar 29 15:06:23 CEST 2026
|
||||||
|
hostName=localhost
|
||||||
|
id=19d39612ed6c322b6ba3c2fc0853ca12997433c4dd8
|
||||||
|
method=file
|
||||||
|
server=localhost\:46585
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 🔒 CODE FREEZE NOTICE
|
||||||
|
|
||||||
|
## Date: March 29, 2026
|
||||||
|
## Duration: Core Separation Refactor
|
||||||
|
|
||||||
|
### Reason
|
||||||
|
Implementing Command Pattern and Observer Pattern to decouple UI and logic interfaces.
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
This refactor will:
|
||||||
|
1. Extract TUI code from `core` module into standalone UI module
|
||||||
|
2. Implement Command Pattern for all user interactions
|
||||||
|
3. Implement Observer Pattern for state change notifications
|
||||||
|
4. Make `core` completely UI-agnostic
|
||||||
|
5. Enable multiple simultaneous UIs (TUI + future ScalaFX GUI)
|
||||||
|
|
||||||
|
### Module Structure (Target)
|
||||||
|
```
|
||||||
|
modules/
|
||||||
|
core/ # Pure game logic, Command, Observer traits, CommandInvoker
|
||||||
|
api/ # Data models (unchanged)
|
||||||
|
ui/ # TUI and GUI implementations (both depend only on core)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Expected Impact
|
||||||
|
- All regression tests must pass
|
||||||
|
- Build must succeed with new module structure
|
||||||
|
- Core contains zero UI references
|
||||||
|
- TUI and potential GUI can run independently or simultaneously
|
||||||
|
|
||||||
|
### Blocked Changes
|
||||||
|
Do not:
|
||||||
|
- Add new features to `core`
|
||||||
|
- Modify `core` API before Message & Observer traits are implemented
|
||||||
|
- Create direct dependencies between UI modules
|
||||||
|
- Add UI code to `core`
|
||||||
|
|
||||||
|
Keep developing in separate branches until refactor is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
Status: **IN PROGRESS** ✏️
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
## (2026-03-27)
|
## (2026-03-27)
|
||||||
## (2026-03-28)
|
## (2026-03-28)
|
||||||
## (2026-03-28)
|
## (2026-03-28)
|
||||||
## (2026-03-29)
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=0
|
MINOR=0
|
||||||
PATCH=4
|
PATCH=3
|
||||||
|
|||||||
@@ -44,20 +44,3 @@
|
|||||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
* 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))
|
* 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))
|
* 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))
|
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ trait Command:
|
|||||||
case class MoveCommand(
|
case class MoveCommand(
|
||||||
from: Square,
|
from: Square,
|
||||||
to: Square,
|
to: Square,
|
||||||
moveResult: Option[MoveResult] = None,
|
var moveResult: Option[MoveResult] = None,
|
||||||
previousBoard: Option[Board] = None,
|
var previousBoard: Option[Board] = None,
|
||||||
previousHistory: Option[GameHistory] = None,
|
var previousHistory: Option[GameHistory] = None,
|
||||||
previousTurn: Option[Color] = None
|
var previousTurn: Option[Color] = None
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean =
|
override def execute(): Boolean =
|
||||||
@@ -51,9 +51,9 @@ case class QuitCommand() extends Command:
|
|||||||
|
|
||||||
/** Command to reset the board to initial position. */
|
/** Command to reset the board to initial position. */
|
||||||
case class ResetCommand(
|
case class ResetCommand(
|
||||||
previousBoard: Option[Board] = None,
|
var previousBoard: Option[Board] = None,
|
||||||
previousHistory: Option[GameHistory] = None,
|
var previousHistory: Option[GameHistory] = None,
|
||||||
previousTurn: Option[Color] = None
|
var previousTurn: Option[Color] = None
|
||||||
) extends Command:
|
) extends Command:
|
||||||
|
|
||||||
override def execute(): Boolean = true
|
override def execute(): Boolean = true
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class CommandInvoker:
|
|||||||
/** Execute a command and add it to history.
|
/** Execute a command and add it to history.
|
||||||
* Discards any redo history if not at the end of the stack.
|
* Discards any redo history if not at the end of the stack.
|
||||||
*/
|
*/
|
||||||
def execute(command: Command): Boolean = synchronized {
|
def execute(command: Command): Boolean =
|
||||||
if command.execute() then
|
if command.execute() then
|
||||||
// Remove any commands after current index (redo stack is discarded)
|
// Remove any commands after current index (redo stack is discarded)
|
||||||
while currentIndex < executedCommands.size - 1 do
|
while currentIndex < executedCommands.size - 1 do
|
||||||
@@ -18,10 +18,9 @@ class CommandInvoker:
|
|||||||
true
|
true
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
}
|
|
||||||
|
|
||||||
/** Undo the last executed command if possible. */
|
/** Undo the last executed command if possible. */
|
||||||
def undo(): Boolean = synchronized {
|
def undo(): Boolean =
|
||||||
if currentIndex >= 0 && currentIndex < executedCommands.size then
|
if currentIndex >= 0 && currentIndex < executedCommands.size then
|
||||||
val command = executedCommands(currentIndex)
|
val command = executedCommands(currentIndex)
|
||||||
if command.undo() then
|
if command.undo() then
|
||||||
@@ -31,10 +30,9 @@ class CommandInvoker:
|
|||||||
false
|
false
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
}
|
|
||||||
|
|
||||||
/** Redo the next command in history if available. */
|
/** Redo the next command in history if available. */
|
||||||
def redo(): Boolean = synchronized {
|
def redo(): Boolean =
|
||||||
if currentIndex + 1 < executedCommands.size then
|
if currentIndex + 1 < executedCommands.size then
|
||||||
val command = executedCommands(currentIndex + 1)
|
val command = executedCommands(currentIndex + 1)
|
||||||
if command.execute() then
|
if command.execute() then
|
||||||
@@ -44,30 +42,20 @@ class CommandInvoker:
|
|||||||
false
|
false
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the history of all executed commands. */
|
/** Get the history of all executed commands. */
|
||||||
def history: List[Command] = synchronized {
|
def history: List[Command] = executedCommands.toList
|
||||||
executedCommands.toList
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the current position in command history. */
|
/** Get the current position in command history. */
|
||||||
def getCurrentIndex: Int = synchronized {
|
def getCurrentIndex: Int = currentIndex
|
||||||
currentIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear all command history. */
|
/** Clear all command history. */
|
||||||
def clear(): Unit = synchronized {
|
def clear(): Unit =
|
||||||
executedCommands.clear()
|
executedCommands.clear()
|
||||||
currentIndex = -1
|
currentIndex = -1
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if undo is available. */
|
/** Check if undo is available. */
|
||||||
def canUndo: Boolean = synchronized {
|
def canUndo: Boolean = currentIndex >= 0
|
||||||
currentIndex >= 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if redo is available. */
|
/** Check if redo is available. */
|
||||||
def canRedo: Boolean = synchronized {
|
def canRedo: Boolean = currentIndex + 1 < executedCommands.size
|
||||||
currentIndex + 1 < executedCommands.size
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -80,28 +80,33 @@ class GameEngine extends Observable:
|
|||||||
|
|
||||||
// Execute the move through GameController
|
// Execute the move through GameController
|
||||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
case MoveResult.Quit =>
|
||||||
|
// Should not happen via processUserInput, but handle it
|
||||||
|
()
|
||||||
|
|
||||||
|
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove =>
|
||||||
|
// Move failed, don't add to history
|
||||||
handleFailedMove(moveInput)
|
handleFailedMove(moveInput)
|
||||||
|
|
||||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||||
// Move succeeded - store result and execute through invoker
|
// Move succeeded - store result and execute through invoker
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))
|
||||||
invoker.execute(updatedCmd)
|
invoker.execute(cmd)
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
updateGameState(newBoard, newHistory, newTurn)
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||||
|
|
||||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||||
// Move succeeded with check
|
// Move succeeded with check
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured))
|
||||||
invoker.execute(updatedCmd)
|
invoker.execute(cmd)
|
||||||
updateGameState(newBoard, newHistory, newTurn)
|
updateGameState(newBoard, newHistory, newTurn)
|
||||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||||
|
|
||||||
case MoveResult.Checkmate(winner) =>
|
case MoveResult.Checkmate(winner) =>
|
||||||
// Move resulted in checkmate
|
// Move resulted in checkmate
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
|
||||||
invoker.execute(updatedCmd)
|
invoker.execute(cmd)
|
||||||
currentBoard = Board.initial
|
currentBoard = Board.initial
|
||||||
currentHistory = GameHistory.empty
|
currentHistory = GameHistory.empty
|
||||||
currentTurn = Color.White
|
currentTurn = Color.White
|
||||||
@@ -109,8 +114,8 @@ class GameEngine extends Observable:
|
|||||||
|
|
||||||
case MoveResult.Stalemate =>
|
case MoveResult.Stalemate =>
|
||||||
// Move resulted in stalemate
|
// Move resulted in stalemate
|
||||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
cmd.moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
|
||||||
invoker.execute(updatedCmd)
|
invoker.execute(cmd)
|
||||||
currentBoard = Board.initial
|
currentBoard = Board.initial
|
||||||
currentHistory = GameHistory.empty
|
currentHistory = GameHistory.empty
|
||||||
currentTurn = Color.White
|
currentTurn = Color.White
|
||||||
@@ -144,26 +149,48 @@ class GameEngine extends Observable:
|
|||||||
|
|
||||||
private def performUndo(): Unit =
|
private def performUndo(): Unit =
|
||||||
if invoker.canUndo then
|
if invoker.canUndo then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
val history = invoker.history
|
||||||
(cmd: @unchecked) match
|
val currentIdx = invoker.getCurrentIndex
|
||||||
case moveCmd: MoveCommand =>
|
if currentIdx >= 0 && currentIdx < history.size then
|
||||||
moveCmd.previousBoard.foreach(currentBoard = _)
|
val cmd = history(currentIdx)
|
||||||
moveCmd.previousHistory.foreach(currentHistory = _)
|
cmd match
|
||||||
moveCmd.previousTurn.foreach(currentTurn = _)
|
case moveCmd: MoveCommand =>
|
||||||
invoker.undo()
|
if moveCmd.undo() then
|
||||||
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
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, "Cannot undo this move."))
|
||||||
|
case _ =>
|
||||||
|
// Other command types - just revert the invoker
|
||||||
|
invoker.undo()
|
||||||
|
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to undo."))
|
||||||
|
|
||||||
private def performRedo(): Unit =
|
private def performRedo(): Unit =
|
||||||
if invoker.canRedo then
|
if invoker.canRedo then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
val history = invoker.history
|
||||||
(cmd: @unchecked) match
|
val nextIdx = invoker.getCurrentIndex + 1
|
||||||
case moveCmd: MoveCommand =>
|
if nextIdx >= 0 && nextIdx < history.size then
|
||||||
for case de.nowchess.chess.command.MoveResult.Successful(nb, nh, nt, cap) <- moveCmd.moveResult do
|
val cmd = history(nextIdx)
|
||||||
updateGameState(nb, nh, nt)
|
cmd match
|
||||||
|
case moveCmd: MoveCommand =>
|
||||||
|
if moveCmd.execute() then
|
||||||
|
moveCmd.moveResult.foreach {
|
||||||
|
case de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured) =>
|
||||||
|
updateGameState(newBoard, newHistory, newTurn)
|
||||||
|
invoker.redo()
|
||||||
|
emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, captured, newTurn)
|
||||||
|
case _ => ()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Cannot redo this move."))
|
||||||
|
case _ =>
|
||||||
invoker.redo()
|
invoker.redo()
|
||||||
emitMoveEvent(moveCmd.from.toString, moveCmd.to.toString, cap, nt)
|
notifyObservers(BoardResetEvent(currentBoard, currentHistory, currentTurn))
|
||||||
else
|
else
|
||||||
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
|
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Nothing to redo."))
|
||||||
|
|
||||||
@@ -184,7 +211,14 @@ class GameEngine extends Observable:
|
|||||||
))
|
))
|
||||||
|
|
||||||
private def handleFailedMove(moveInput: String): Unit =
|
private def handleFailedMove(moveInput: String): Unit =
|
||||||
(GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput): @unchecked) match
|
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||||
|
case MoveResult.InvalidFormat(raw) =>
|
||||||
|
notifyObservers(InvalidMoveEvent(
|
||||||
|
currentBoard,
|
||||||
|
currentHistory,
|
||||||
|
currentTurn,
|
||||||
|
s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4."
|
||||||
|
))
|
||||||
case MoveResult.NoPiece =>
|
case MoveResult.NoPiece =>
|
||||||
notifyObservers(InvalidMoveEvent(
|
notifyObservers(InvalidMoveEvent(
|
||||||
currentBoard,
|
currentBoard,
|
||||||
@@ -206,4 +240,6 @@ class GameEngine extends Observable:
|
|||||||
currentTurn,
|
currentTurn,
|
||||||
"Illegal move."
|
"Illegal move."
|
||||||
))
|
))
|
||||||
|
case _ => ()
|
||||||
|
|
||||||
|
end GameEngine
|
||||||
|
|||||||
@@ -67,21 +67,16 @@ trait Observable:
|
|||||||
private val observers = scala.collection.mutable.Set[Observer]()
|
private val observers = scala.collection.mutable.Set[Observer]()
|
||||||
|
|
||||||
/** Register an observer to receive game events. */
|
/** Register an observer to receive game events. */
|
||||||
def subscribe(observer: Observer): Unit = synchronized {
|
def subscribe(observer: Observer): Unit =
|
||||||
observers += observer
|
observers += observer
|
||||||
}
|
|
||||||
|
|
||||||
/** Unregister an observer. */
|
/** Unregister an observer. */
|
||||||
def unsubscribe(observer: Observer): Unit = synchronized {
|
def unsubscribe(observer: Observer): Unit =
|
||||||
observers -= observer
|
observers -= observer
|
||||||
}
|
|
||||||
|
|
||||||
/** Notify all observers of a game event. */
|
/** Notify all observers of a game event. */
|
||||||
protected def notifyObservers(event: GameEvent): Unit = synchronized {
|
protected def notifyObservers(event: GameEvent): Unit =
|
||||||
observers.foreach(_.onGameEvent(event))
|
observers.foreach(_.onGameEvent(event))
|
||||||
}
|
|
||||||
|
|
||||||
/** Return current list of observers (for testing). */
|
/** Return current list of observers (for testing). */
|
||||||
def observerCount: Int = synchronized {
|
def observerCount: Int = observers.size
|
||||||
observers.size
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -214,3 +214,4 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.execute(cmd3) // While loop condition should be false, no iterations
|
invoker.execute(cmd3) // While loop condition should be false, no iterations
|
||||||
invoker.history.size shouldBe 3
|
invoker.history.size shouldBe 3
|
||||||
|
|
||||||
|
end CommandInvokerBranchTest
|
||||||
|
|||||||
@@ -121,3 +121,4 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
|
|||||||
invoker.history(1) shouldBe cmd3
|
invoker.history(1) shouldBe cmd3
|
||||||
invoker.getCurrentIndex shouldBe 1
|
invoker.getCurrentIndex shouldBe 1
|
||||||
|
|
||||||
|
end CommandInvokerTest
|
||||||
|
|||||||
-131
@@ -1,131 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
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
|
|
||||||
@@ -50,3 +50,4 @@ class CommandTest extends AnyFunSuite with Matchers:
|
|||||||
val cmd = ResetCommand()
|
val cmd = ResetCommand()
|
||||||
cmd.description shouldBe "Reset board"
|
cmd.description shouldBe "Reset board"
|
||||||
|
|
||||||
|
end CommandTest
|
||||||
|
|||||||
-65
@@ -1,65 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class 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
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package de.nowchess.chess.engine
|
|
||||||
|
|
||||||
import scala.collection.mutable
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
/** Tests for GameEngine check/checkmate/stalemate paths */
|
|
||||||
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
test("GameEngine handles Checkmate (Fool's Mate)"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new EndingMockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
// Play Fool's mate
|
|
||||||
engine.processUserInput("f2f3")
|
|
||||||
engine.processUserInput("e7e5")
|
|
||||||
engine.processUserInput("g2g4")
|
|
||||||
|
|
||||||
observer.events.clear()
|
|
||||||
engine.processUserInput("d8h4")
|
|
||||||
|
|
||||||
// Verify CheckmateEvent
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe a[CheckmateEvent]
|
|
||||||
|
|
||||||
val event = observer.events.head.asInstanceOf[CheckmateEvent]
|
|
||||||
event.winner shouldBe Color.Black
|
|
||||||
|
|
||||||
// Board should be reset after checkmate
|
|
||||||
engine.board shouldBe Board.initial
|
|
||||||
engine.turn shouldBe Color.White
|
|
||||||
|
|
||||||
test("GameEngine handles check detection"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new EndingMockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
// Play a simple check
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
engine.processUserInput("e7e5")
|
|
||||||
engine.processUserInput("f1c4")
|
|
||||||
engine.processUserInput("g8f6")
|
|
||||||
|
|
||||||
observer.events.clear()
|
|
||||||
engine.processUserInput("c4f7") // Check!
|
|
||||||
|
|
||||||
val checkEvents = observer.events.collect { case e: CheckDetectedEvent => e }
|
|
||||||
checkEvents.size shouldBe 1
|
|
||||||
checkEvents.head.turn shouldBe Color.Black // Black is now in check
|
|
||||||
|
|
||||||
// Shortest known stalemate is 19 moves. Here is a faster one:
|
|
||||||
// e3 a5 Qh5 Ra6 Qxa5 h5 h4 Rah6 Qxc7 f6 Qxd7+ Kf7 Qxb7 Qd3 Qxb8 Qh7 Qxc8 Kg6 Qe6
|
|
||||||
// Wait, let's just use Sam Loyd's 10-move stalemate:
|
|
||||||
// 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6
|
|
||||||
test("GameEngine handles Stalemate via 10-move known sequence"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new EndingMockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
val moves = List(
|
|
||||||
"e2e3", "a7a5",
|
|
||||||
"d1h5", "a8a6",
|
|
||||||
"h5a5", "h7h5",
|
|
||||||
"h2h4", "a6h6",
|
|
||||||
"a5c7", "f7f6",
|
|
||||||
"c7d7", "e8f7",
|
|
||||||
"d7b7", "d8d3",
|
|
||||||
"b7b8", "d3h7",
|
|
||||||
"b8c8", "f7g6",
|
|
||||||
"c8e6"
|
|
||||||
)
|
|
||||||
|
|
||||||
moves.dropRight(1).foreach(engine.processUserInput)
|
|
||||||
|
|
||||||
observer.events.clear()
|
|
||||||
engine.processUserInput(moves.last)
|
|
||||||
|
|
||||||
val stalemateEvents = observer.events.collect { case e: StalemateEvent => e }
|
|
||||||
stalemateEvents.size shouldBe 1
|
|
||||||
|
|
||||||
// Board should be reset after stalemate
|
|
||||||
engine.board shouldBe Board.initial
|
|
||||||
engine.turn shouldBe Color.White
|
|
||||||
|
|
||||||
private class EndingMockObserver extends Observer:
|
|
||||||
val events = mutable.ListBuffer[GameEvent]()
|
|
||||||
|
|
||||||
override def onGameEvent(event: GameEvent): Unit =
|
|
||||||
events += event
|
|
||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
package de.nowchess.chess.engine
|
|
||||||
|
|
||||||
import scala.collection.mutable
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import de.nowchess.chess.observer.{Observer, GameEvent, InvalidMoveEvent}
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
/** Tests to maximize handleFailedMove coverage */
|
|
||||||
class GameEngineHandleFailedMoveTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
test("GameEngine handles InvalidFormat error type"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("not_a_valid_move_format")
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val msg1 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
|
||||||
msg1 should include("Invalid move format")
|
|
||||||
|
|
||||||
test("GameEngine handles NoPiece error type"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("h3h4")
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val msg2 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
|
||||||
msg2 should include("No piece on that square")
|
|
||||||
|
|
||||||
test("GameEngine handles WrongColor error type"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("e2e4") // White move
|
|
||||||
observer.events.clear()
|
|
||||||
|
|
||||||
engine.processUserInput("a1b2") // Try to move black's rook position with white's move (wrong color)
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val msg3 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
|
||||||
msg3 should include("That is not your piece")
|
|
||||||
|
|
||||||
test("GameEngine handles IllegalMove error type"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("e2e1") // Try pawn backward
|
|
||||||
observer.events.size shouldBe 1
|
|
||||||
observer.events.head shouldBe an[InvalidMoveEvent]
|
|
||||||
val msg4 = observer.events.head.asInstanceOf[InvalidMoveEvent].reason
|
|
||||||
msg4 should include("Illegal move")
|
|
||||||
|
|
||||||
test("GameEngine invalid move message for InvalidFormat"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("xyz123")
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("coordinate notation")
|
|
||||||
|
|
||||||
test("GameEngine invalid move message for NoPiece"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("a3a4") // a3 is empty
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("No piece")
|
|
||||||
|
|
||||||
test("GameEngine invalid move message for WrongColor"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("e2e4")
|
|
||||||
observer.events.clear()
|
|
||||||
|
|
||||||
engine.processUserInput("e4e5") // e4 has white pawn, it's black's turn
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("not your piece")
|
|
||||||
|
|
||||||
test("GameEngine invalid move message for IllegalMove"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val observer = new MockObserver()
|
|
||||||
engine.subscribe(observer)
|
|
||||||
|
|
||||||
engine.processUserInput("e2e1") // Pawn can't move backward
|
|
||||||
val event = observer.events.head.asInstanceOf[InvalidMoveEvent]
|
|
||||||
event.reason should include("Illegal move")
|
|
||||||
|
|
||||||
test("GameEngine board unchanged after each type of invalid move"):
|
|
||||||
val engine = new GameEngine()
|
|
||||||
val initial = engine.board
|
|
||||||
|
|
||||||
engine.processUserInput("invalid")
|
|
||||||
engine.board shouldBe initial
|
|
||||||
|
|
||||||
engine.processUserInput("h3h4")
|
|
||||||
engine.board shouldBe initial
|
|
||||||
|
|
||||||
engine.processUserInput("e2e1")
|
|
||||||
engine.board shouldBe initial
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -268,39 +268,6 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
val initialEvents = observer.events.size
|
val initialEvents = observer.events.size
|
||||||
engine.processUserInput("q")
|
engine.processUserInput("q")
|
||||||
observer.events.size shouldBe initialEvents
|
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
|
// Mock Observer for testing
|
||||||
private class MockObserver extends Observer:
|
private class MockObserver extends Observer:
|
||||||
@@ -308,3 +275,4 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
|||||||
override def onGameEvent(event: GameEvent): Unit =
|
override def onGameEvent(event: GameEvent): Unit =
|
||||||
events += event
|
events += event
|
||||||
|
|
||||||
|
end GameEngineTest
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
package de.nowchess.chess.command
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
|
|
||||||
class MoveCommandDefaultsTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private def sq(f: File, r: Rank): Square = Square(f, r)
|
|
||||||
|
|
||||||
// Tests for MoveCommand with default parameter values
|
|
||||||
test("MoveCommand with no moveResult defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.moveResult shouldBe None
|
|
||||||
cmd.execute() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand with no previousBoard defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.previousBoard shouldBe None
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand with no previousHistory defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.previousHistory shouldBe None
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand with no previousTurn defaults to None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.previousTurn shouldBe None
|
|
||||||
cmd.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand description is always returned"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4)
|
|
||||||
)
|
|
||||||
cmd.description shouldBe "Move from e2 to e4"
|
|
||||||
|
|
||||||
test("MoveCommand execute returns false when moveResult is None"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.A, Rank.R1),
|
|
||||||
to = sq(File.B, Rank.R3)
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand undo returns false when any previous state is None"):
|
|
||||||
// Missing previousBoard
|
|
||||||
val cmd1 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = None,
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd1.undo() shouldBe false
|
|
||||||
|
|
||||||
// Missing previousHistory
|
|
||||||
val cmd2 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = None,
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd2.undo() shouldBe false
|
|
||||||
|
|
||||||
// Missing previousTurn
|
|
||||||
val cmd3 = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = None
|
|
||||||
)
|
|
||||||
cmd3.undo() shouldBe false
|
|
||||||
|
|
||||||
test("MoveCommand execute returns true when moveResult is defined"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
|
|
||||||
)
|
|
||||||
cmd.execute() shouldBe true
|
|
||||||
|
|
||||||
test("MoveCommand undo returns true when all previous states are defined"):
|
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = sq(File.E, Rank.R2),
|
|
||||||
to = sq(File.E, Rank.R4),
|
|
||||||
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
|
|
||||||
previousBoard = Some(Board.initial),
|
|
||||||
previousHistory = Some(GameHistory.empty),
|
|
||||||
previousTurn = Some(Color.White)
|
|
||||||
)
|
|
||||||
cmd.undo() shouldBe true
|
|
||||||
-168
@@ -1,168 +0,0 @@
|
|||||||
package de.nowchess.chess.observer
|
|
||||||
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
|
||||||
import de.nowchess.chess.logic.GameHistory
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
import scala.collection.mutable
|
|
||||||
|
|
||||||
class ObservableThreadSafetyTest extends AnyFunSuite with Matchers:
|
|
||||||
|
|
||||||
private class TestObservable extends Observable:
|
|
||||||
def testNotifyObservers(event: GameEvent): Unit =
|
|
||||||
notifyObservers(event)
|
|
||||||
|
|
||||||
private class CountingObserver extends Observer:
|
|
||||||
@volatile private var eventCount = 0
|
|
||||||
@volatile private var lastEvent: Option[GameEvent] = None
|
|
||||||
|
|
||||||
def onGameEvent(event: GameEvent): Unit =
|
|
||||||
eventCount += 1
|
|
||||||
lastEvent = Some(event)
|
|
||||||
|
|
||||||
private def createTestEvent(): GameEvent =
|
|
||||||
BoardResetEvent(
|
|
||||||
board = Board.initial,
|
|
||||||
history = GameHistory.empty,
|
|
||||||
turn = Color.White
|
|
||||||
)
|
|
||||||
|
|
||||||
test("Observable is thread-safe for concurrent subscribe and notify"):
|
|
||||||
val observable = new TestObservable()
|
|
||||||
val testEvent = createTestEvent()
|
|
||||||
@volatile var raceConditionCaught = false
|
|
||||||
|
|
||||||
// Thread 1: repeatedly notifies observers with long iteration
|
|
||||||
val notifierThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500000 do
|
|
||||||
observable.testNotifyObservers(testEvent)
|
|
||||||
} catch {
|
|
||||||
case _: java.util.ConcurrentModificationException =>
|
|
||||||
raceConditionCaught = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 2: rapidly subscribes/unsubscribes observers during notify
|
|
||||||
val subscriberThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500000 do
|
|
||||||
val obs = new CountingObserver()
|
|
||||||
observable.subscribe(obs)
|
|
||||||
observable.unsubscribe(obs)
|
|
||||||
} catch {
|
|
||||||
case _: java.util.ConcurrentModificationException =>
|
|
||||||
raceConditionCaught = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
notifierThread.start()
|
|
||||||
subscriberThread.start()
|
|
||||||
notifierThread.join()
|
|
||||||
subscriberThread.join()
|
|
||||||
|
|
||||||
raceConditionCaught shouldBe false
|
|
||||||
|
|
||||||
test("Observable is thread-safe for concurrent subscribe, unsubscribe, and notify"):
|
|
||||||
val observable = new TestObservable()
|
|
||||||
val testEvent = createTestEvent()
|
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
|
||||||
val observers = mutable.ListBuffer[CountingObserver]()
|
|
||||||
|
|
||||||
// Pre-subscribe some observers
|
|
||||||
for _ <- 1 to 10 do
|
|
||||||
val obs = new CountingObserver()
|
|
||||||
observers += obs
|
|
||||||
observable.subscribe(obs)
|
|
||||||
|
|
||||||
// Thread 1: notifies observers
|
|
||||||
val notifierThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 5000 do
|
|
||||||
observable.testNotifyObservers(testEvent)
|
|
||||||
} catch {
|
|
||||||
case e: Exception => exceptions += e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 2: subscribes new observers
|
|
||||||
val subscriberThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 5000 do
|
|
||||||
val obs = new CountingObserver()
|
|
||||||
observable.subscribe(obs)
|
|
||||||
} catch {
|
|
||||||
case e: Exception => exceptions += e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 3: unsubscribes observers
|
|
||||||
val unsubscriberThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for i <- 1 to 5000 do
|
|
||||||
if observers.nonEmpty then
|
|
||||||
val obs = observers(i % observers.size)
|
|
||||||
observable.unsubscribe(obs)
|
|
||||||
} catch {
|
|
||||||
case e: Exception => exceptions += e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
notifierThread.start()
|
|
||||||
subscriberThread.start()
|
|
||||||
unsubscriberThread.start()
|
|
||||||
notifierThread.join()
|
|
||||||
subscriberThread.join()
|
|
||||||
unsubscriberThread.join()
|
|
||||||
|
|
||||||
exceptions.isEmpty shouldBe true
|
|
||||||
|
|
||||||
test("Observable.observerCount is thread-safe during concurrent modifications"):
|
|
||||||
val observable = new TestObservable()
|
|
||||||
val exceptions = mutable.ListBuffer[Exception]()
|
|
||||||
val countResults = mutable.ListBuffer[Int]()
|
|
||||||
|
|
||||||
// Thread 1: subscribes observers
|
|
||||||
val subscriberThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
observable.subscribe(new CountingObserver())
|
|
||||||
} catch {
|
|
||||||
case e: Exception => exceptions += e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Thread 2: reads observer count
|
|
||||||
val readerThread = new Thread(new Runnable {
|
|
||||||
def run(): Unit = {
|
|
||||||
try {
|
|
||||||
for _ <- 1 to 500 do
|
|
||||||
val count = observable.observerCount
|
|
||||||
countResults += count
|
|
||||||
} catch {
|
|
||||||
case e: Exception => exceptions += e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
subscriberThread.start()
|
|
||||||
readerThread.start()
|
|
||||||
subscriberThread.join()
|
|
||||||
readerThread.join()
|
|
||||||
|
|
||||||
exceptions.isEmpty shouldBe true
|
|
||||||
// Count should never go backwards
|
|
||||||
for i <- 1 until countResults.size do
|
|
||||||
countResults(i) >= countResults(i - 1) shouldBe true
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=4
|
MINOR=3
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ object Main:
|
|||||||
val tui = new TerminalUI(engine)
|
val tui = new TerminalUI(engine)
|
||||||
tui.start()
|
tui.start()
|
||||||
|
|
||||||
|
end Main
|
||||||
|
|||||||
@@ -74,3 +74,4 @@ class TerminalUI(engine: GameEngine) extends Observer:
|
|||||||
val redoHint = if engine.canRedo then " [redo]" else ""
|
val redoHint = if engine.canRedo then " [redo]" else ""
|
||||||
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
print(s"${turn.label}'s turn. Enter move (or 'quit'/'q' to exit)$undoHint$redoHint: ")
|
||||||
|
|
||||||
|
end TerminalUI
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package de.nowchess.ui
|
|
||||||
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
|
||||||
|
|
||||||
class MainTest extends AnyFunSuite with Matchers {
|
|
||||||
|
|
||||||
test("main should execute and quit immediately when fed 'quit'") {
|
|
||||||
val in = new ByteArrayInputStream("quit\n".getBytes)
|
|
||||||
val out = new ByteArrayOutputStream()
|
|
||||||
|
|
||||||
Console.withIn(in) {
|
|
||||||
Console.withOut(out) {
|
|
||||||
Main.main(Array.empty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val output = out.toString
|
|
||||||
output should include ("Game over. Goodbye!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
package de.nowchess.ui.terminal
|
|
||||||
|
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
|
||||||
import org.scalatest.matchers.should.Matchers
|
|
||||||
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
|
|
||||||
import de.nowchess.chess.engine.GameEngine
|
|
||||||
import de.nowchess.chess.observer.*
|
|
||||||
import de.nowchess.api.board.{Board, Color}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user