Compare commits

...

7 Commits

Author SHA1 Message Date
lq64 412ed986a9 feat: NCS-11 50-move rule (#9)
Build & Test (NowChessSystems) TeamCity build finished
Summary

  - Implements the FIDE 50-move draw rule: a player may claim a draw if no pawn move or capture has occurred in the last
   50 full moves (100 half-moves)
  - Draw is not automatic — the eligible player must claim it via a TUI menu shown at the start of their turn
  - halfMoveClock: Int is threaded through processMove and gameLoop; resets on pawn move, capture, or en passant;
  increments on all other moves

  Changes

  - GameController.scala: extended MoveResult.Moved and MoveResult.MovedInCheck with newHalfMoveClock: Int; added
  MoveResult.DrawClaimed; added halfMoveClock parameter to processMove and gameLoop; TUI menu shown when clock ≥ 100
  - Main.scala: initial gameLoop call passes halfMoveClock = 0
  - GameControllerTest.scala: updated all existing pattern matches; added 10 new tests covering clock reset, clock
  increment, draw claim, and TUI menu behaviour

  Test plan

  - processMove: 'draw' with halfMoveClock = 100 → DrawClaimed
  - processMove: 'draw' with halfMoveClock = 99 → InvalidFormat
  - Pawn move / capture / en passant → clock resets to 0
  - Quiet piece move → clock increments by 1
  - MovedInCheck carries updated clock
  - TUI menu appears when clock ≥ 100; option 1 claims draw, option 2 continues
  - No TUI menu when clock < 100
  - All 197 tests passing

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-04-01 10:36:24 +02:00
TeamCity 8bbeead702 ci: bump version with Build-24 2026-04-01 07:17:44 +00:00
Janis e5e20c566e fix: update move validation to check for king safety (#13)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #13
2026-04-01 09:07:06 +02:00
Janis 13bfc16cfe feat: NCS-10 Implement Pawn Promotion (#12)
Build & Test (NowChessSystems) TeamCity build finished
Reviewed-on: #12
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis-e@gmx.de>
Co-committed-by: Janis <janis-e@gmx.de>
2026-03-31 22:18:14 +02:00
TeamCity 85cbf95c18 ci: bump version with Build-22 2026-03-31 08:35:28 +00:00
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
50 changed files with 3755 additions and 238 deletions
+2 -1
View File
@@ -2,9 +2,10 @@
name: scala-implementer name: scala-implementer
description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence" description: "Implements Scala 3 + Quarkus REST services, domain logic, and persistence"
tools: Read, Write, Edit, Bash, Glob tools: Read, Write, Edit, Bash, Glob
model: sonnet model: inherit
color: pink color: pink
--- ---
You do not have permissions to write tests, just source code. You do not have permissions to write tests, just source code.
You are a Scala 3 expert specialising in Quarkus microservices. You are a Scala 3 expert specialising in Quarkus microservices.
Always read the relevant /docs/api/ file before implementing. Always read the relevant /docs/api/ file before implementing.
+2 -2
View File
@@ -2,9 +2,10 @@
name: test-writer name: test-writer
description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished." description: "Writes QuarkusTest unit and integration tests for a service. Invoke after scala-implementer has finished."
tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch, NotebookEdit
model: sonnet model: haiku
color: purple color: purple
--- ---
You do not have permissions to modify the source code, just write tests. You do not have permissions to modify the source code, just write tests.
You write tests for Scala 3 + Quarkus services. You write tests for Scala 3 + Quarkus services.
@@ -19,5 +20,4 @@ When invoked BEFORE scala-implementer (no implementation exists yet):
When invoked AFTER scala-implementer (implementation exists): When invoked AFTER scala-implementer (implementation exists):
Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent Run python3 jacoco-reporter/jacoco_coverage_gaps.py modules/{service-name}/build/reports/jacoco/test/jacocoTestReport.xml --output agent
Use the jacoco-coverage-gaps skill — close coverage gaps revealed by the report.
To regenerate the report run the tests first. To regenerate the report run the tests first.
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+1
View File
@@ -12,6 +12,7 @@
<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>
-1
View File
@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="FrameworkDetectionExcludesConfiguration"> <component name="FrameworkDetectionExcludesConfiguration">
+1 -1
View File
@@ -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"> <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="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
<parameters> <parameters>
Generated
+10
View File
@@ -6,6 +6,16 @@
<inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" /> <inspection_tool class="CommitNamingConvention" enabled="true" level="WARNING" enabled_by_default="true" />
</profile> </profile>
</component> </component>
<component name="IssueNavigationConfiguration">
<option name="links">
<list>
<IssueNavigationLink>
<option name="issueRegexp" value="(?x)\b(CORE|NCWF|BAC|FRO|K8S|ORG|NCI|NCS)-\d+\b#YouTrack" />
<option name="linkRegexp" value="https://knockoutwhist.youtrack.cloud/issue/$0" />
</IssueNavigationLink>
</list>
</option>
</component>
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
</component> </component>
+20
View File
@@ -0,0 +1,20 @@
## [2026-03-31] Unreachable code blocking 100% statement coverage
**Requirement/Bug:** Reach 100% statement coverage in core module.
**Root Cause:** 4 remaining uncovered statements (99.6% coverage) are unreachable code:
1. **PgnParser.scala:160** (`case _ => None` in extractPromotion) - Regex `=([QRBN])` only matches those 4 characters; fallback case can never execute
2. **GameHistory.scala:29** (`addMove$default$4` compiler-generated method) - Method overload 3 without defaults shadows the 4-param version, making promotionPiece default accessor unreachable
3. **GameEngine.scala:201-202** (`case _` in completePromotion) - GameController.completePromotion always returns one of 4 expected MoveResult types; catch-all is defensive code
**Attempted Fixes:**
1. Added comprehensive PGN parsing tests (all 4 promotion types) - PgnParser improved from 95.8% to 99.4%
2. Added GameHistory tests using named parameters - hit `addMove$default$3` (castleSide) but not `$default$4` (promotionPiece)
3. Named parameter approach: `addMove(from=..., to=..., promotionPiece=...)` triggers 4-param with castleSide default ✓
4. Positional approach: `addMove(f, t, None, None)` requires all 4 args (explicit, no defaults used) - doesn't hit $default$4
5. Root issue: Scala's overload resolution prefers more-specific non-default overloads (2-param, 3-param) over the 4-param with defaults
**Recommendation:** 99.6% (1029/1033) is maximum achievable without refactoring method overloads. Unreachable code design patterns:
- **Pattern 1 (unreachable regex fallback):** Defensive pattern match against exhaustive regex
- **Pattern 2 (overshadowed defaults):** Method overloads shadow default parameters in parent signature
- **Pattern 3 (defensive catch-all):** Error handling for impossible external API returns
+3
View File
@@ -1,3 +1,6 @@
## (2026-03-27) ## (2026-03-27)
## (2026-03-28) ## (2026-03-28)
## (2026-03-28) ## (2026-03-28)
## (2026-03-29)
## (2026-03-31)
## (2026-04-01)
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=0 MINOR=0
PATCH=3 PATCH=6
+55
View File
@@ -44,3 +44,58 @@
* 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))
## (2026-03-31)
### 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-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* 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))
## (2026-04-01)
### 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-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
* 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))
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
@@ -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,8 @@
package de.nowchess.chess.controller package de.nowchess.chess.controller
import scala.io.StdIn import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.* import de.nowchess.chess.logic.*
import de.nowchess.chess.view.Renderer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Result ADT returned by the pure processMove function // Result ADT returned by the pure processMove function
@@ -16,6 +15,14 @@ object MoveResult:
case object NoPiece extends MoveResult case object NoPiece extends MoveResult
case object WrongColor extends MoveResult case object WrongColor extends MoveResult
case object IllegalMove extends MoveResult case object IllegalMove extends MoveResult
case class PromotionRequired(
from: Square,
to: Square,
boardBefore: Board,
historyBefore: GameHistory,
captured: Option[Piece],
turn: Color
) extends MoveResult
case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult case class Moved(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult case class MovedInCheck(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], newTurn: Color) extends MoveResult
case class Checkmate(winner: Color) extends MoveResult case class Checkmate(winner: Color) extends MoveResult
@@ -32,80 +39,67 @@ object GameController:
*/ */
def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
raw.trim match raw.trim match
case "quit" | "q" => case "quit" | "q" => MoveResult.Quit
MoveResult.Quit
case trimmed => case trimmed =>
Parser.parseMove(trimmed) match Parser.parseMove(trimmed) match
case None => case None => MoveResult.InvalidFormat(trimmed)
MoveResult.InvalidFormat(trimmed) case Some((from, to)) => validateAndApply(board, history, turn, from, to)
case Some((from, to)) =>
board.pieceAt(from) match
case None =>
MoveResult.NoPiece
case Some(piece) if piece.color != turn =>
MoveResult.WrongColor
case Some(_) =>
if !MoveValidator.isLegal(board, history, from, to) then
MoveResult.IllegalMove
else
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 =>
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, /** Apply a previously detected promotion move with the chosen piece.
* prints the outcome, and recurses until the game ends. * Called after processMove returned PromotionRequired.
*/ */
def gameLoop(board: Board, history: GameHistory, turn: Color): Unit = def completePromotion(
println() board: Board,
print(Renderer.render(board)) history: GameHistory,
println(s"${turn.label}'s turn. Enter move: ") from: Square,
val input = Option(StdIn.readLine()).getOrElse("quit").trim to: Square,
processMove(board, history, turn, input) match piece: PromotionPiece,
case MoveResult.Quit => turn: Color
println("Game over. Goodbye!") ): MoveResult =
case MoveResult.InvalidFormat(raw) => val (boardAfterMove, captured) = board.withMove(from, to)
println(s"Invalid move format '$raw'. Use coordinate notation, e.g. e2e4.") val promotedPieceType = piece match
gameLoop(board, history, turn) case PromotionPiece.Queen => PieceType.Queen
case MoveResult.NoPiece => case PromotionPiece.Rook => PieceType.Rook
println(s"No piece on ${Parser.parseMove(input).map(_._1).fold("?")(_.toString)}.") case PromotionPiece.Bishop => PieceType.Bishop
gameLoop(board, history, turn) case PromotionPiece.Knight => PieceType.Knight
case MoveResult.WrongColor => val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
println(s"That is not your piece.") // Promotion is always a pawn move → clock resets
gameLoop(board, history, turn) val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
case MoveResult.IllegalMove => toMoveResult(newBoard, newHistory, captured, turn)
println(s"Illegal move.")
gameLoop(board, history, turn) // ---------------------------------------------------------------------------
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => // Private helpers
val prevTurn = newTurn.opposite // ---------------------------------------------------------------------------
captured.foreach: cap =>
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) private def validateAndApply(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") board.pieceAt(from) match
gameLoop(newBoard, newHistory, newTurn) case None => MoveResult.NoPiece
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) => case Some(piece) if piece.color != turn => MoveResult.WrongColor
val prevTurn = newTurn.opposite case Some(_) =>
captured.foreach: cap => if !GameRules.legalMoves(board, history, turn).contains(from -> to) then MoveResult.IllegalMove
val toSq = Parser.parseMove(input).map(_._2).fold("?")(_.toString) else if MoveValidator.isPromotionMove(board, from, to) then
println(s"${prevTurn.label} captures ${cap.color.label} ${cap.pieceType.label} on $toSq") MoveResult.PromotionRequired(from, to, board, history, board.pieceAt(to), turn)
println(s"${newTurn.label} is in check!") else applyNormalMove(board, history, turn, from, to)
gameLoop(newBoard, newHistory, newTurn)
case MoveResult.Checkmate(winner) => private def applyNormalMove(board: Board, history: GameHistory, turn: Color, from: Square, to: Square): MoveResult =
println(s"Checkmate! ${winner.label} wins.") val castleOpt = Option.when(MoveValidator.isCastle(board, from, to))(MoveValidator.castleSide(from, to))
gameLoop(Board.initial, GameHistory.empty, Color.White) val isEP = EnPassantCalculator.isEnPassant(board, history, from, to)
case MoveResult.Stalemate => val (newBoard, captured) = castleOpt match
println("Stalemate! The game is a draw.") case Some(side) => (board.withCastle(turn, side), None)
gameLoop(Board.initial, GameHistory.empty, Color.White) 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 wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
val wasCapture = captured.isDefined
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
toMoveResult(newBoard, newHistory, captured, turn)
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
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
@@ -0,0 +1,294 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, Square}
import de.nowchess.api.move.PromotionPiece
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(
initialBoard: Board = Board.initial,
initialHistory: GameHistory = GameHistory.empty,
initialTurn: Color = Color.White,
completePromotionFn: (Board, GameHistory, Square, Square, PromotionPiece, Color) => MoveResult =
GameController.completePromotion
) extends Observable:
private var currentBoard: Board = initialBoard
private var currentHistory: GameHistory = initialHistory
private var currentTurn: Color = initialTurn
private val invoker = new CommandInvoker()
/** Inner class for tracking pending promotion state */
private case class PendingPromotion(
from: Square, to: Square,
boardBefore: Board, historyBefore: GameHistory,
turn: Color
)
/** Current pending promotion, if any */
private var pendingPromotion: Option[PendingPromotion] = None
/** True if a pawn promotion move is pending and needs a piece choice. */
def isPendingPromotion: Boolean = synchronized { pendingPromotion.isDefined }
// Synchronized accessors for current state
def board: Board = synchronized { currentBoard }
def history: GameHistory = synchronized { currentHistory }
def turn: Color = synchronized { currentTurn }
/** 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 "draw" =>
if currentHistory.halfMoveClock >= 100 then
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
invoker.clear()
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
else
notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
case "" =>
val event = InvalidMoveEvent(
currentBoard,
currentHistory,
currentTurn,
"Please enter a valid move or command."
)
notifyObservers(event)
case moveInput =>
Parser.parseMove(moveInput) match
case None =>
notifyObservers(InvalidMoveEvent(
currentBoard, currentHistory, currentTurn,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
))
case Some((from, to)) =>
handleParsedMove(from, to, moveInput)
}
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
val cmd = MoveCommand(
from = from,
to = to,
previousBoard = Some(currentBoard),
previousHistory = Some(currentHistory),
previousTurn = Some(currentTurn)
)
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
handleFailedMove(moveInput)
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(from.toString, to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
if currentHistory.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
/** Undo the last move. */
def undo(): Unit = synchronized {
performUndo()
}
/** Redo the last undone move. */
def redo(): Unit = synchronized {
performRedo()
}
/** Apply a player's promotion piece choice.
* Must only be called when isPendingPromotion is true.
*/
def completePromotion(piece: PromotionPiece): Unit = synchronized {
pendingPromotion match
case None =>
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "No promotion pending."))
case Some(pending) =>
pendingPromotion = None
val cmd = MoveCommand(
from = pending.from,
to = pending.to,
previousBoard = Some(pending.boardBefore),
previousHistory = Some(pending.historyBefore),
previousTurn = Some(pending.turn)
)
completePromotionFn(
pending.boardBefore, pending.historyBefore,
pending.from, pending.to, piece, pending.turn
) match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
invoker.execute(updatedCmd)
updateGameState(newBoard, newHistory, newTurn)
emitMoveEvent(pending.from.toString, pending.to.toString, captured, newTurn)
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
case MoveResult.Checkmate(winner) =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
case MoveResult.Stalemate =>
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
invoker.execute(updatedCmd)
currentBoard = Board.initial
currentHistory = GameHistory.empty
currentTurn = Color.White
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
case _ =>
notifyObservers(InvalidMoveEvent(currentBoard, currentHistory, currentTurn, "Error completing promotion."))
}
/** 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."
))
@@ -1,24 +1,46 @@
package de.nowchess.chess.logic package de.nowchess.chess.logic
import de.nowchess.api.board.Square import de.nowchess.api.board.Square
import de.nowchess.api.move.PromotionPiece
/** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */ /** A single move recorded in the game history. Distinct from api.move.Move which represents user intent. */
case class HistoryMove( case class HistoryMove(
from: Square, from: Square,
to: Square, to: Square,
castleSide: Option[CastleSide] castleSide: Option[CastleSide],
promotionPiece: Option[PromotionPiece] = None
) )
/** Complete game history: ordered list of moves. */ /** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
case class GameHistory(moves: List[HistoryMove] = List.empty): *
* @param moves moves played so far, oldest first
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
*/
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
/** Add a raw HistoryMove record. Clock increments by 1.
* Use the coordinate overload when you know whether the move is a pawn move or capture.
*/
def addMove(move: HistoryMove): GameHistory = def addMove(move: HistoryMove): GameHistory =
GameHistory(moves :+ move) GameHistory(moves :+ move, halfMoveClock + 1)
def addMove(from: Square, to: Square): GameHistory = /** Add a move by coordinates.
addMove(HistoryMove(from, to, None)) *
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
def addMove(from: Square, to: Square, castleSide: Option[CastleSide]): GameHistory = * @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
addMove(HistoryMove(from, to, castleSide)) *
* If neither flag is set the clock increments by 1.
*/
def addMove(
from: Square,
to: Square,
castleSide: Option[CastleSide] = None,
promotionPiece: Option[PromotionPiece] = None,
wasPawnMove: Boolean = false,
wasCapture: Boolean = false
): GameHistory =
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
object GameHistory: object GameHistory:
val empty: GameHistory = GameHistory() val empty: GameHistory = GameHistory()
@@ -173,3 +173,11 @@ object MoveValidator:
def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean = def isLegal(board: Board, history: GameHistory, from: Square, to: Square): Boolean =
legalTargets(board, history, from).contains(to) legalTargets(board, history, from).contains(to)
/** Returns true if the piece on `from` is a pawn moving to its back rank (promotion). */
def isPromotionMove(board: Board, from: Square, to: Square): Boolean =
board.pieceAt(from) match
case Some(Piece(_, PieceType.Pawn)) =>
(from.rank == Rank.R7 && to.rank == Rank.R8) ||
(from.rank == Rank.R2 && to.rank == Rank.R1)
case _ => false
@@ -1,6 +1,7 @@
package de.nowchess.chess.notation package de.nowchess.chess.notation
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove} import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove}
object PgnExporter: object PgnExporter:
@@ -21,7 +22,8 @@ object PgnExporter:
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr" if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
else s"$moveNum. $whiteMoveStr $blackMoveStr" else s"$moveNum. $whiteMoveStr $blackMoveStr"
moveLines.mkString(" ") + " *" val termination = headers.getOrElse("Result", "*")
moveLines.mkString(" ") + s" $termination"
if headerLines.isEmpty then moveText if headerLines.isEmpty then moveText
else if moveText.isEmpty then headerLines else if moveText.isEmpty then headerLines
@@ -32,4 +34,11 @@ object PgnExporter:
move.castleSide match move.castleSide match
case Some(CastleSide.Kingside) => "O-O" case Some(CastleSide.Kingside) => "O-O"
case Some(CastleSide.Queenside) => "O-O-O" case Some(CastleSide.Queenside) => "O-O-O"
case None => s"${move.from}${move.to}" case None =>
val base = s"${move.from}${move.to}"
move.promotionPiece match
case Some(PromotionPiece.Queen) => s"$base=Q"
case Some(PromotionPiece.Rook) => s"$base=R"
case Some(PromotionPiece.Bishop) => s"$base=B"
case Some(PromotionPiece.Knight) => s"$base=N"
case None => base
@@ -1,6 +1,7 @@
package de.nowchess.chess.notation package de.nowchess.chess.notation
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle} import de.nowchess.chess.logic.{CastleSide, GameHistory, HistoryMove, GameRules, MoveValidator, withCastle}
/** A parsed PGN game containing headers and the resolved move list. */ /** A parsed PGN game containing headers and the resolved move list. */
@@ -41,16 +42,30 @@ object PgnParser:
if isMoveNumberOrResult(token) then state if isMoveNumberOrResult(token) then state
else else
parseAlgebraicMove(token, board, history, color) match parseAlgebraicMove(token, board, history, color) match
case None => state // unrecognised token — skip silently case None => state // unrecognised token — skip silently
case Some(move) => case Some(move) =>
val newBoard = move.castleSide match val newBoard = applyMoveToBoard(board, move, color)
case Some(side) => board.withCastle(color, side)
case None => board.withMove(move.from, move.to)._1
val newHistory = history.addMove(move) val newHistory = history.addMove(move)
(newBoard, newHistory, color.opposite, acc :+ move) (newBoard, newHistory, color.opposite, acc :+ move)
moves moves
/** Apply a single HistoryMove to a Board, handling castling and promotion. */
private def applyMoveToBoard(board: Board, move: HistoryMove, color: Color): Board =
move.castleSide match
case Some(side) => board.withCastle(color, side)
case None =>
val (boardAfterMove, _) = board.withMove(move.from, move.to)
move.promotionPiece match
case Some(pp) =>
val pieceType = pp match
case PromotionPiece.Queen => PieceType.Queen
case PromotionPiece.Rook => PieceType.Rook
case PromotionPiece.Bishop => PieceType.Bishop
case PromotionPiece.Knight => PieceType.Knight
boardAfterMove.updated(move.to, Piece(color, pieceType))
case None => boardAfterMove
/** True for move-number tokens ("1.", "12.") and PGN result tokens. */ /** True for move-number tokens ("1.", "12.") and PGN result tokens. */
private def isMoveNumberOrResult(token: String): Boolean = private def isMoveNumberOrResult(token: String): Boolean =
token.matches("""\d+\.""") || token.matches("""\d+\.""") ||
@@ -128,16 +143,26 @@ object PgnParser:
if hint.isEmpty then byPiece if hint.isEmpty then byPiece
else byPiece.filter(from => matchesHint(from, hint)) else byPiece.filter(from => matchesHint(from, hint))
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None)) val promotion = extractPromotion(notation)
disambiguated.headOption.map(from => HistoryMove(from, toSquare, None, promotion))
/** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */ /** True if `sq` matches a disambiguation hint (file letter, rank digit, or both). */
private def matchesHint(sq: Square, hint: String): Boolean = private def matchesHint(sq: Square, hint: String): Boolean =
hint.foldLeft(true): (ok, c) => hint.forall(c => if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString)
ok && ( else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
if c >= 'a' && c <= 'h' then sq.file.toString.equalsIgnoreCase(c.toString) else true)
else if c >= '1' && c <= '8' then sq.rank.ordinal == (c - '1')
else true /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
) private[notation] def extractPromotion(notation: String): Option[PromotionPiece] =
val promotionPattern = """=([A-Z])""".r
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
m.group(1) match
case "Q" => Some(PromotionPiece.Queen)
case "R" => Some(PromotionPiece.Rook)
case "B" => Some(PromotionPiece.Bishop)
case "N" => Some(PromotionPiece.Knight)
case _ => None
}
/** Convert a piece-letter character to a PieceType. */ /** Convert a piece-letter character to a PieceType. */
private def charToPieceType(c: Char): Option[PieceType] = private def charToPieceType(c: Char): Option[PieceType] =
@@ -0,0 +1,110 @@
package de.nowchess.chess.observer
import de.nowchess.api.board.{Board, Color, Square}
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 a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent(
board: Board,
history: GameHistory,
turn: Color,
from: Square,
to: Square
) extends GameEvent
/** Fired when the board is reset. */
case class BoardResetEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent(
board: Board,
history: GameHistory,
turn: Color
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
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
@@ -2,21 +2,18 @@ package de.nowchess.chess.controller
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.CastlingRights import de.nowchess.api.game.CastlingRights
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{CastleSide, GameHistory} import de.nowchess.chess.logic.{CastleSide, GameHistory}
import de.nowchess.chess.notation.FenParser
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import java.io.ByteArrayInputStream
class GameControllerTest extends AnyFunSuite with Matchers: class GameControllerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r) private def sq(f: File, r: Rank): Square = Square(f, r)
private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult = private def processMove(board: Board, history: GameHistory, turn: Color, raw: String): MoveResult =
GameController.processMove(board, history, turn, raw) 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 = private def castlingRights(history: GameHistory, color: Color): CastlingRights =
de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color) de.nowchess.chess.logic.CastlingRightsCalculator.deriveCastlingRights(history, color)
@@ -46,6 +43,30 @@ class GameControllerTest extends AnyFunSuite with Matchers:
// White pawn at E2 cannot jump three squares to E5 // White pawn at E2 cannot jump three squares to E5
processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove processMove(Board.initial, GameHistory.empty, Color.White, "e2e5") shouldBe MoveResult.IllegalMove
test("processMove: move that leaves own king in check returns IllegalMove"):
// White King E1 is in check from Black Rook E8. Moving the D2 pawn is
// geometrically legal but does not resolve the check — must be rejected.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.D, Rank.R2) -> Piece.WhitePawn,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "d2d4") shouldBe MoveResult.IllegalMove
test("processMove: move that resolves check is allowed"):
// White King E1 is in check from Black Rook E8 along the E-file.
// White Rook A5 interposes at E5 — resolves the check, no new check on Black King A8.
val b = Board(Map(
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.A, Rank.R5) -> Piece.WhiteRook,
sq(File.E, Rank.R8) -> Piece.BlackRook,
sq(File.A, Rank.R8) -> Piece.BlackKing
))
processMove(b, GameHistory.empty, Color.White, "a5e5") match
case _: MoveResult.Moved => succeed
case other => fail(s"Expected Moved, got $other")
test("processMove: legal pawn move returns Moved with updated board and flipped turn"): test("processMove: legal pawn move returns Moved with updated board and flipped turn"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) => case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
@@ -69,59 +90,6 @@ class GameControllerTest extends AnyFunSuite with Matchers:
newTurn shouldBe Color.Black newTurn shouldBe Color.Black
case other => fail(s"Expected Moved, got $other") 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 ───────────────────── // ──── processMove: check / checkmate / stalemate ─────────────────────
test("processMove: legal move that delivers check returns MovedInCheck"): test("processMove: legal move that delivers check returns MovedInCheck"):
@@ -161,56 +129,6 @@ class GameControllerTest extends AnyFunSuite with Matchers:
case MoveResult.Stalemate => succeed case MoveResult.Stalemate => succeed
case other => fail(s"Expected Stalemate, got $other") 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 ───────────────────────────────────────────── // ──── castling execution ─────────────────────────────────────────────
test("processMove: e1g1 returns Moved with king on g1 and rook on f1"): test("processMove: e1g1 returns Moved with king on g1 and rook on f1"):
@@ -401,3 +319,208 @@ class GameControllerTest extends AnyFunSuite with Matchers:
newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed newBoard.pieceAt(Square(File.E, Rank.R3)) shouldBe Some(Piece.BlackPawn) // capturing pawn placed
captured shouldBe Some(Piece.WhitePawn) captured shouldBe Some(Piece.WhitePawn)
case other => fail(s"Expected Moved but got $other") case other => fail(s"Expected Moved but got $other")
// ──── pawn promotion detection ───────────────────────────────────────────
test("processMove detects white pawn reaching R8 and returns PromotionRequired"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7e8")
result should matchPattern { case _: MoveResult.PromotionRequired => }
result match
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
from should be (sq(File.E, Rank.R7))
to should be (sq(File.E, Rank.R8))
turn should be (Color.White)
case _ => fail("Expected PromotionRequired")
test("processMove detects black pawn reaching R1 and returns PromotionRequired"):
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
val result = GameController.processMove(board, GameHistory.empty, Color.Black, "e2e1")
result should matchPattern { case _: MoveResult.PromotionRequired => }
result match
case MoveResult.PromotionRequired(from, to, _, _, _, turn) =>
from should be (sq(File.E, Rank.R2))
to should be (sq(File.E, Rank.R1))
turn should be (Color.Black)
case _ => fail("Expected PromotionRequired")
test("processMove detects pawn capturing to back rank as PromotionRequired with captured piece"):
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
val result = GameController.processMove(board, GameHistory.empty, Color.White, "e7d8")
result should matchPattern { case _: MoveResult.PromotionRequired => }
result match
case MoveResult.PromotionRequired(_, _, _, _, captured, _) =>
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
case _ => fail("Expected PromotionRequired")
// ──── completePromotion ──────────────────────────────────────────────────
test("completePromotion applies move and places queen"):
// Black king on h1: not attacked by queen on e8 (different file, rank, and diagonals)
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Queen, Color.White
)
result should matchPattern { case _: MoveResult.Moved => }
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
newBoard.pieceAt(sq(File.E, Rank.R7)) should be (None)
newHistory.moves should have length 1
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
case _ => fail("Expected Moved")
test("completePromotion with rook underpromotion"):
// Black king on h1: not attacked by rook on e8 (different file and rank)
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Rook, Color.White
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
case _ => fail("Expected Moved with Rook")
test("completePromotion with bishop underpromotion"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Bishop, Color.White
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Bishop)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Bishop))
case _ => fail("Expected Moved with Bishop")
test("completePromotion with knight underpromotion"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Knight, Color.White
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Knight)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
case _ => fail("Expected Moved with Knight")
test("completePromotion captures opponent piece"):
// Black king on h1: after white queen captures d8 queen, h1 king is safe (queen on d8 does not attack h1)
val board = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/7k").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.D, Rank.R8),
PromotionPiece.Queen, Color.White
)
result match
case MoveResult.Moved(newBoard, _, captured, _) =>
newBoard.pieceAt(sq(File.D, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
captured should be (Some(Piece(Color.Black, PieceType.Queen)))
case _ => fail("Expected Moved with captured piece")
test("completePromotion for black pawn to R1"):
val board = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R2), sq(File.E, Rank.R1),
PromotionPiece.Knight, Color.Black
)
result match
case MoveResult.Moved(newBoard, newHistory, _, _) =>
newBoard.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Knight)))
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Knight))
case _ => fail("Expected Moved")
test("completePromotion evaluates check after promotion"):
val board = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.E, Rank.R7), sq(File.E, Rank.R8),
PromotionPiece.Queen, Color.White
)
result should matchPattern { case _: MoveResult.MovedInCheck => }
test("completePromotion full round-trip via processMove then completePromotion"):
// Black king on h1: not attacked by queen on e8
val board = FenParser.parseBoard("8/4P3/8/8/8/8/8/7k").get
GameController.processMove(board, GameHistory.empty, Color.White, "e7e8") match
case MoveResult.PromotionRequired(from, to, boardBefore, histBefore, _, turn) =>
val result = GameController.completePromotion(boardBefore, histBefore, from, to, PromotionPiece.Queen, turn)
result should matchPattern { case _: MoveResult.Moved => }
result match
case MoveResult.Moved(finalBoard, finalHistory, _, _) =>
finalBoard.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
finalHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
case _ => fail("Expected Moved")
case _ => fail("Expected PromotionRequired")
test("completePromotion results in checkmate when promotion delivers checkmate"):
// Black king a8, white pawn h7, white king b6.
// After h7→h8=Q: Qh8 attacks rank 8 putting Ka8 in check;
// a7 covered by Kb6, b7 covered by Kb6, b8 covered by Qh8 — no escape.
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.H, Rank.R7), sq(File.H, Rank.R8),
PromotionPiece.Queen, Color.White
)
result should matchPattern { case MoveResult.Checkmate(_) => }
result match
case MoveResult.Checkmate(winner) => winner should be (Color.White)
case _ => fail("Expected Checkmate")
test("completePromotion results in stalemate when promotion stalemates opponent"):
// Black king a8, white pawn b7, white bishop c7, white king b6.
// After b7→b8=N: knight on b8 (doesn't check a8); a7 and b7 covered by Kb6;
// b8 defended by Bc7 so Ka8xb8 would walk into bishop — no legal moves.
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
val result = GameController.completePromotion(
board, GameHistory.empty,
sq(File.B, Rank.R7), sq(File.B, Rank.R8),
PromotionPiece.Knight, Color.White
)
result should be (MoveResult.Stalemate)
// ──── half-move clock propagation ────────────────────────────────────
test("processMove: non-pawn non-capture increments halfMoveClock"):
// g1f3 is a knight move — not a pawn, not a capture
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 1
case other => fail(s"Expected Moved, got $other")
test("processMove: pawn move resets halfMoveClock to 0"):
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: capture resets halfMoveClock to 0"):
// White pawn on e5, Black pawn on d6 — exd6 is a capture
val board = Board(Map(
sq(File.E, Rank.R5) -> Piece.WhitePawn,
sq(File.D, Rank.R6) -> Piece.BlackPawn,
sq(File.E, Rank.R1) -> Piece.WhiteKing,
sq(File.E, Rank.R8) -> Piece.BlackKing
))
val history = GameHistory(halfMoveClock = 10)
processMove(board, history, Color.White, "e5d6") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 0
case other => fail(s"Expected Moved, got $other")
test("processMove: clock carries from previous history on non-pawn non-capture"):
val history = GameHistory(halfMoveClock = 5)
processMove(Board.initial, history, Color.White, "g1f3") match
case MoveResult.Moved(_, newHistory, _, _) =>
newHistory.halfMoveClock shouldBe 6
case other => fail(s"Expected Moved, got $other")
@@ -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,167 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.notation.FenParser
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEnginePromotionTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
val events = collection.mutable.ListBuffer[GameEvent]()
engine.subscribe(new Observer { def onGameEvent(e: GameEvent): Unit = events += e })
events
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = new GameEngine(initialBoard = promotionBoard)
val events = captureEvents(engine)
engine.processUserInput("e7e8")
events.exists(_.isInstanceOf[PromotionRequiredEvent]) should be (true)
events.collect { case e: PromotionRequiredEvent => e }.head.from should be (sq(File.E, Rank.R7))
}
test("isPendingPromotion is true after PromotionRequired input") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = new GameEngine(initialBoard = promotionBoard)
captureEvents(engine)
engine.processUserInput("e7e8")
engine.isPendingPromotion should be (true)
}
test("isPendingPromotion is false before any promotion input") {
val engine = new GameEngine()
engine.isPendingPromotion should be (false)
}
test("completePromotion fires MoveExecutedEvent with promoted piece") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = new GameEngine(initialBoard = promotionBoard)
val events = captureEvents(engine)
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false)
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
engine.board.pieceAt(sq(File.E, Rank.R7)) should be (None)
engine.history.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
}
test("completePromotion with rook underpromotion") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = new GameEngine(initialBoard = promotionBoard)
captureEvents(engine)
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Rook)
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Rook)))
}
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
val engine = new GameEngine()
val events = captureEvents(engine)
engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
engine.isPendingPromotion should be (false)
}
test("completePromotion fires CheckDetectedEvent when promotion gives check") {
val promotionBoard = FenParser.parseBoard("3k4/4P3/8/8/8/8/8/8").get
val engine = new GameEngine(initialBoard = promotionBoard)
val events = captureEvents(engine)
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
}
test("completePromotion results in Moved when promotion doesn't give check") {
// White pawn on e7, black king on a2 (far away, not in check after promotion)
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
val engine = new GameEngine(initialBoard = board)
val events = captureEvents(engine)
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false)
engine.board.pieceAt(sq(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
}
test("completePromotion results in Checkmate when promotion delivers checkmate") {
// Black king on a8, white king on b6, white pawn on h7
// h7->h8=Q delivers checkmate
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
val engine = new GameEngine(initialBoard = board)
val events = captureEvents(engine)
engine.processUserInput("h7h8")
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false)
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
}
test("completePromotion results in Stalemate when promotion creates stalemate") {
// Black king on a8, white pawn on b7, white bishop on c7, white king on b6
// b7->b8=N: no check; Ka8 has no legal moves -> stalemate
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
val engine = new GameEngine(initialBoard = board)
val events = captureEvents(engine)
engine.processUserInput("b7b8")
engine.completePromotion(PromotionPiece.Knight)
engine.isPendingPromotion should be (false)
events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
}
test("completePromotion with black pawn promotion results in Moved") {
// Black pawn e2, white king h3 (not on rank 1 or file e), black king a8
// e2->e1=Q: queen on e1 does not attack h3 -> normal Moved
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
val engine = new GameEngine(initialBoard = board, initialTurn = Color.Black)
val events = captureEvents(engine)
engine.processUserInput("e2e1")
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false)
engine.board.pieceAt(sq(File.E, Rank.R1)) should be (Some(Piece(Color.Black, PieceType.Queen)))
events.filter(_.isInstanceOf[MoveExecutedEvent]) should not be empty
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (false)
}
test("completePromotion catch-all fires InvalidMoveEvent for unexpected MoveResult") {
// Inject a function that returns an unexpected MoveResult to hit the catch-all case
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val stubFn: (de.nowchess.api.board.Board, de.nowchess.chess.logic.GameHistory, Square, Square, PromotionPiece, Color) => de.nowchess.chess.controller.MoveResult =
(_, _, _, _, _, _) => de.nowchess.chess.controller.MoveResult.NoPiece
val engine = new GameEngine(initialBoard = promotionBoard, completePromotionFn = stubFn)
val events = captureEvents(engine)
engine.processUserInput("e7e8")
engine.isPendingPromotion should be (true)
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
}
@@ -0,0 +1,351 @@
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, FiftyMoveRuleAvailableEvent, DrawClaimedEvent}
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
// ──── 50-move rule ───────────────────────────────────────────────────
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
val engine = new GameEngine()
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("draw")
observer.events.size shouldBe 1
observer.events.head shouldBe a[InvalidMoveEvent]
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("draw")
observer.events.size shouldBe 1
observer.events.head shouldBe a[DrawClaimedEvent]
test("GameEngine: state resets to initial after draw claimed"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
engine.processUserInput("draw")
engine.board shouldBe Board.initial
engine.history shouldBe GameHistory.empty
engine.turn shouldBe Color.White
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
// Start at clock 99; a knight move (non-pawn, non-capture) increments to 100
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3") // knight move on initial board
// Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5))
val observer = new MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3")
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
// Mock Observer for testing
private class MockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -0,0 +1,110 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank, Board, Color}
import de.nowchess.chess.logic.GameHistory
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveCommandDefaultsTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
// Tests for MoveCommand with default parameter values
test("MoveCommand with no moveResult defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.moveResult shouldBe None
cmd.execute() shouldBe false
test("MoveCommand with no previousBoard defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.previousBoard shouldBe None
cmd.undo() shouldBe false
test("MoveCommand with no previousHistory defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.previousHistory shouldBe None
cmd.undo() shouldBe false
test("MoveCommand with no previousTurn defaults to None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.previousTurn shouldBe None
cmd.undo() shouldBe false
test("MoveCommand description is always returned"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4)
)
cmd.description shouldBe "Move from e2 to e4"
test("MoveCommand execute returns false when moveResult is None"):
val cmd = MoveCommand(
from = sq(File.A, Rank.R1),
to = sq(File.B, Rank.R3)
)
cmd.execute() shouldBe false
test("MoveCommand undo returns false when any previous state is None"):
// Missing previousBoard
val cmd1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = None,
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
cmd1.undo() shouldBe false
// Missing previousHistory
val cmd2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = None,
previousTurn = Some(Color.White)
)
cmd2.undo() shouldBe false
// Missing previousTurn
val cmd3 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = None
)
cmd3.undo() shouldBe false
test("MoveCommand execute returns true when moveResult is defined"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None))
)
cmd.execute() shouldBe true
test("MoveCommand undo returns true when all previous states are defined"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)),
previousBoard = Some(Board.initial),
previousHistory = Some(GameHistory.empty),
previousTurn = Some(Color.White)
)
cmd.undo() shouldBe true
@@ -1,6 +1,7 @@
package de.nowchess.chess.logic package de.nowchess.chess.logic
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -39,3 +40,65 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val history = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
history.moves should have length 1 history.moves should have length 1
history.moves.head.castleSide shouldBe None history.moves.head.castleSide shouldBe None
test("Move with promotion records the promotion piece"):
val move = HistoryMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Queen))
move.promotionPiece should be (Some(PromotionPiece.Queen))
test("Normal move has no promotion piece"):
val move = HistoryMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), None, None)
move.promotionPiece should be (None)
test("addMove with promotion stores promotionPiece"):
val history = GameHistory.empty
val newHistory = history.addMove(sq(File.E, Rank.R7), sq(File.E, Rank.R8), None, Some(PromotionPiece.Rook))
newHistory.moves should have length 1
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Rook))
test("addMove with castleSide only uses promotionPiece default (None)"):
val history = GameHistory.empty
// With overload 3 removed, this uses the 4-param version and triggers addMove$default$4
val newHistory = history.addMove(sq(File.E, Rank.R1), sq(File.G, Rank.R1), Some(CastleSide.Kingside))
newHistory.moves should have length 1
newHistory.moves.head.castleSide should be (Some(CastleSide.Kingside))
newHistory.moves.head.promotionPiece should be (None)
test("addMove using named parameters with only promotion, using castleSide default"):
val history = GameHistory.empty
val newHistory = history.addMove(from = sq(File.E, Rank.R7), to = sq(File.E, Rank.R8), promotionPiece = Some(PromotionPiece.Queen))
newHistory.moves should have length 1
newHistory.moves.head.castleSide should be (None)
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
// ──── half-move clock ────────────────────────────────────────────────
test("halfMoveClock starts at 0"):
GameHistory.empty.halfMoveClock shouldBe 0
test("halfMoveClock increments on a non-pawn non-capture move"):
val h = GameHistory.empty.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
h.halfMoveClock shouldBe 1
test("halfMoveClock resets to 0 on a pawn move"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock resets to 0 on a capture"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasCapture = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock resets to 0 when both wasPawnMove and wasCapture are true"):
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasPawnMove = true, wasCapture = true)
h.halfMoveClock shouldBe 0
test("halfMoveClock carries across multiple moves"):
val h = GameHistory.empty
.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) // +1 → 1
.addMove(sq(File.G, Rank.R8), sq(File.F, Rank.R6)) // +1 → 2
.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true) // reset → 0
.addMove(sq(File.B, Rank.R1), sq(File.C, Rank.R3)) // +1 → 1
h.halfMoveClock shouldBe 1
test("GameHistory can be initialised with a non-zero halfMoveClock"):
val h = GameHistory(halfMoveClock = 42)
h.halfMoveClock shouldBe 42
@@ -3,6 +3,7 @@ package de.nowchess.chess.logic
import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square} import de.nowchess.api.board.{Board, Color, File, Piece, Rank, Square}
import de.nowchess.api.game.CastlingRights import de.nowchess.api.game.CastlingRights
import de.nowchess.chess.logic.{CastleSide, GameHistory} import de.nowchess.chess.logic.{CastleSide, GameHistory}
import de.nowchess.chess.notation.FenParser
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -255,3 +256,25 @@ class MoveValidatorTest extends AnyFunSuite with Matchers:
val b = board(sq(File.D, Rank.R4) -> Piece.WhiteRook) 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)) 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)) MoveValidator.legalTargets(b, h, sq(File.D, Rank.R4)) shouldBe MoveValidator.legalTargets(b, sq(File.D, Rank.R4))
// ──── isPromotionMove ────────────────────────────────────────────────
test("White pawn reaching R8 is a promotion move"):
val b = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.E, Rank.R8)) should be (true)
test("Black pawn reaching R1 is a promotion move"):
val b = FenParser.parseBoard("8/8/8/8/4K3/8/4p3/8").get
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R2), Square(File.E, Rank.R1)) should be (true)
test("Pawn capturing to back rank is a promotion move"):
val b = FenParser.parseBoard("3q4/4P3/8/8/8/8/8/8").get
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R7), Square(File.D, Rank.R8)) should be (true)
test("Pawn not reaching back rank is not a promotion move"):
val b = FenParser.parseBoard("8/8/8/4P3/8/8/8/8").get
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R6)) should be (false)
test("Non-pawn piece is never a promotion move"):
val b = FenParser.parseBoard("8/8/8/4Q3/8/8/8/8").get
MoveValidator.isPromotionMove(b, Square(File.E, Rank.R5), Square(File.E, Rank.R8)) should be (false)
@@ -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)
@@ -67,3 +67,22 @@ class FenExporterTest extends AnyFunSuite with Matchers:
) )
val fen = FenExporter.gameStateToFen(gameState) val fen = FenExporter.gameStateToFen(gameState)
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
test("halfMoveClock round-trips through FEN export and import"):
import de.nowchess.chess.logic.GameHistory
import de.nowchess.chess.notation.FenParser
val history = GameHistory(halfMoveClock = 42)
val gameState = GameState(
piecePlacement = FenExporter.boardToFen(de.nowchess.api.board.Board.initial),
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = None,
halfMoveClock = history.halfMoveClock,
fullMoveNumber = 1,
status = GameStatus.InProgress
)
val fen = FenExporter.gameStateToFen(gameState)
FenParser.parseFen(fen) match
case Some(gs) => gs.halfMoveClock shouldBe 42
case None => fail("FEN parsing failed")
@@ -1,6 +1,7 @@
package de.nowchess.chess.notation package de.nowchess.chess.notation
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -63,3 +64,51 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
pgn.contains("O-O-O") shouldBe true pgn.contains("O-O-O") shouldBe true
} }
test("exportGame encodes promotion to Queen as =Q suffix") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Queen)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=Q")
}
test("exportGame encodes promotion to Rook as =R suffix") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Rook)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=R")
}
test("exportGame encodes promotion to Bishop as =B suffix") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Bishop)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=B")
}
test("exportGame encodes promotion to Knight as =N suffix") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R7), Square(File.E, Rank.R8), None, Some(PromotionPiece.Knight)))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e7e8=N")
}
test("exportGame does not add suffix for normal moves") {
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None, None))
val pgn = PgnExporter.exportGame(Map.empty, history)
pgn should include ("e2e4")
pgn should not include ("=")
}
test("exportGame uses Result header as termination marker"):
val history = GameHistory()
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), history)
pgn should endWith("1/2-1/2")
test("exportGame with no Result header still uses * as default"):
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 *"
@@ -1,7 +1,9 @@
package de.nowchess.chess.notation package de.nowchess.chess.notation
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide} import de.nowchess.chess.logic.{GameHistory, HistoryMove, CastleSide}
import de.nowchess.chess.notation.FenParser
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -332,3 +334,118 @@ class PgnParserTest extends AnyFunSuite with Matchers:
result.isDefined shouldBe true result.isDefined shouldBe true
result.get.to shouldBe Square(File.D, Rank.R1) result.get.to shouldBe Square(File.D, Rank.R1)
} }
test("parseAlgebraicMove preserves promotion to Queen in HistoryMove") {
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
result.isDefined should be (true)
result.get.promotionPiece should be (Some(PromotionPiece.Queen))
result.get.to should be (Square(File.E, Rank.R8))
}
test("parseAlgebraicMove preserves promotion to Rook") {
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=R", board, GameHistory.empty, Color.White)
result.get.promotionPiece should be (Some(PromotionPiece.Rook))
}
test("parseAlgebraicMove preserves promotion to Bishop") {
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=B", board, GameHistory.empty, Color.White)
result.get.promotionPiece should be (Some(PromotionPiece.Bishop))
}
test("parseAlgebraicMove preserves promotion to Knight") {
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=N", board, GameHistory.empty, Color.White)
result.get.promotionPiece should be (Some(PromotionPiece.Knight))
}
test("parsePgn applies promoted piece to board for subsequent moves") {
// Build a board with a white pawn on e7 plus the two kings
import de.nowchess.api.board.{Board, Square, File, Rank, Piece, Color, PieceType}
val pieces: Map[Square, Piece] = Map(
Square(File.E, Rank.R7) -> Piece(Color.White, PieceType.Pawn),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.H, Rank.R1) -> Piece(Color.Black, PieceType.King)
)
val board = Board(pieces)
val move = PgnParser.parseAlgebraicMove("e7e8=Q", board, GameHistory.empty, Color.White)
move.isDefined should be (true)
move.get.promotionPiece should be (Some(PromotionPiece.Queen))
// After applying the promotion the square e8 should hold a White Queen
val (boardAfterPawnMove, _) = board.withMove(move.get.from, move.get.to)
val promotedBoard = boardAfterPawnMove.updated(move.get.to, Piece(Color.White, PieceType.Queen))
promotedBoard.pieceAt(Square(File.E, Rank.R8)) should be (Some(Piece(Color.White, PieceType.Queen)))
}
test("parsePgn with all four promotion piece types (Queen, Rook, Bishop, Knight) in sequence") {
// This test exercises lines 53-58 in PgnParser.parseMovesText which contain
// the pattern match over PromotionPiece for Queen, Rook, Bishop, Knight
val pgn = """[Event "Promotion Test"]
[White "A"]
[Black "B"]
1. a2a3 h7h5 2. a3a4 h5h4 3. a4a5 h4h3 4. a5a6 h3h2 5. a6a7 h2h1=Q 6. a7a8=R 1-0
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
// Move 10 is h2h1=Q (black pawn promotes to queen)
val blackPromotionToQ = game.get.moves(9) // 0-indexed
blackPromotionToQ.promotionPiece shouldBe Some(PromotionPiece.Queen)
// Move 11 is a7a8=R (white pawn promotes to rook)
val whitePromotionToR = game.get.moves(10)
whitePromotionToR.promotionPiece shouldBe Some(PromotionPiece.Rook)
}
test("parseAlgebraicMove promotion with Rook through full PGN parse") {
val pgn = """[Event "Test"]
[White "A"]
[Black "B"]
1. a2a3 h7h6 2. a3a4 h6h5 3. a4a5 h5h4 4. a5a6 h4h3 5. a6a7 h3h2 6. a7a8=R
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.promotionPiece shouldBe Some(PromotionPiece.Rook)
}
test("parseAlgebraicMove promotion with Bishop through full PGN parse") {
val pgn = """[Event "Test"]
[White "A"]
[Black "B"]
1. b2b3 h7h6 2. b3b4 h6h5 3. b4b5 h5h4 4. b5b6 h4h3 5. b6b7 h3h2 6. b7b8=B
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.promotionPiece shouldBe Some(PromotionPiece.Bishop)
}
test("parseAlgebraicMove promotion with Knight through full PGN parse") {
val pgn = """[Event "Test"]
[White "A"]
[Black "B"]
1. c2c3 h7h6 2. c3c4 h6h5 3. c4c5 h5h4 4. c5c6 h4h3 5. c6c7 h3h2 6. c7c8=N
"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.promotionPiece shouldBe Some(PromotionPiece.Knight)
}
test("extractPromotion returns None for invalid promotion letter") {
// Regex =([A-Z]) now captures any uppercase letter, so =X is matched but case _ => None fires
val result = PgnParser.extractPromotion("e7e8=X")
result shouldBe None
}
test("extractPromotion returns None when no promotion in notation") {
val result = PgnParser.extractPromotion("e7e8")
result shouldBe None
}
@@ -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 MAJOR=0
MINOR=3 MINOR=6
PATCH=0 PATCH=0
+6
View File
@@ -0,0 +1,6 @@
## (2026-04-01)
### Features
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
+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,93 @@
package de.nowchess.ui.terminal
import scala.io.StdIn
import de.nowchess.api.move.PromotionPiece
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
private var awaitingPromotion = false
/** 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)
case _: PromotionRequiredEvent =>
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
synchronized { awaitingPromotion = true }
/** 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
synchronized {
if awaitingPromotion then
input.toLowerCase match
case "q" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Queen)
case "r" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Rook)
case "b" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Bishop)
case "n" => awaitingPromotion = false; engine.completePromotion(PromotionPiece.Knight)
case _ =>
println("Invalid choice. Enter q, r, b, or n.")
println("Promote to: q=Queen, r=Rook, b=Bishop, n=Knight")
else
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,327 @@
package de.nowchess.ui.terminal
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.io.{ByteArrayInputStream, ByteArrayOutputStream}
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.observer.*
import de.nowchess.api.board.{Board, Color, File, Rank, Square}
import de.nowchess.chess.logic.GameHistory
class TerminalUITest extends AnyFunSuite with Matchers {
test("TerminalUI should start, print initial state, and correctly respond to 'q'") {
val in = new ByteArrayInputStream("q\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
}
test("TerminalUI should ignore empty inputs and re-print prompt") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// Prompt appears three times: Initial, after empty, on exit.
output.split("White's turn.").length should be > 2
}
test("TerminalUI should explicitly handle empty input by re-prompting") {
val in = new ByteArrayInputStream("\n\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
// With two empty inputs, prompt should appear at least 4 times:
// 1. Initial board display
// 2. After first empty input
// 3. After second empty input
// 4. Before quit
val promptCount = output.split("White's turn.").length
promptCount should be >= 4
output should include("Game over. Goodbye!")
}
test("TerminalUI printPrompt should include undo and redo hints if engine returns true") {
val in = new ByteArrayInputStream("\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine() {
// Stub engine to force undo/redo to true
override def canUndo: Boolean = true
override def canRedo: Boolean = true
}
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("[undo]")
output should include("[redo]")
}
test("TerminalUI onGameEvent should properly format InvalidMoveEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(InvalidMoveEvent(Board(Map.empty), GameHistory(), Color.Black, "Invalid move format"))
}
out.toString should include("⚠️")
out.toString should include("Invalid move format")
}
test("TerminalUI onGameEvent should properly format CheckDetectedEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckDetectedEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Black is in check!")
}
test("TerminalUI onGameEvent should properly format CheckmateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(CheckmateEvent(Board(Map.empty), GameHistory(), Color.Black, Color.White))
}
val ostr = out.toString
ostr should include("Checkmate! White wins.")
}
test("TerminalUI onGameEvent should properly format StalemateEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(StalemateEvent(Board(Map.empty), GameHistory(), Color.Black))
}
out.toString should include("Stalemate! The game is a draw.")
}
test("TerminalUI onGameEvent should properly format BoardResetEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(BoardResetEvent(Board(Map.empty), GameHistory(), Color.White))
}
out.toString should include("Board has been reset to initial position.")
}
test("TerminalUI onGameEvent should properly format MoveExecutedEvent with capturing piece") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(MoveExecutedEvent(Board(Map.empty), GameHistory(), Color.Black, "A1", "A8", Some("Knight(White)")))
}
out.toString should include("Captured: Knight(White) on A8") // Depending on how piece/coord serialize
}
test("TerminalUI processes valid move input via processUserInput") {
val in = new ByteArrayInputStream("e2e4\nq\n".getBytes)
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
val output = out.toString
output should include("White's turn.")
output should include("Game over. Goodbye!")
// The move should have been processed and the board displayed
engine.turn shouldBe Color.Black
}
test("TerminalUI shows promotion prompt on PromotionRequiredEvent") {
val out = new ByteArrayOutputStream()
val engine = new GameEngine()
val ui = new TerminalUI(engine)
Console.withOut(out) {
ui.onGameEvent(PromotionRequiredEvent(
Board(Map.empty), GameHistory(), Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
}
out.toString should include("Promote to")
}
test("TerminalUI routes promotion choice to engine.completePromotion") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
val in = new ByteArrayInputStream("e7e8\nq\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Queen))
out.toString should include("Promote to")
}
test("TerminalUI re-prompts on invalid promotion choice") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
// "x" is invalid, then "r" for rook
val in = new ByteArrayInputStream("e7e8\nx\nr\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Rook))
out.toString should include("Invalid")
}
test("TerminalUI routes Bishop promotion choice to engine.completePromotion") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
val in = new ByteArrayInputStream("e7e8\nb\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Bishop))
}
test("TerminalUI routes Knight promotion choice to engine.completePromotion") {
import de.nowchess.api.move.PromotionPiece
var capturedPiece: Option[PromotionPiece] = None
val engine = new GameEngine() {
override def processUserInput(rawInput: String): Unit =
if rawInput.trim == "e7e8" then
notifyObservers(PromotionRequiredEvent(
Board(Map.empty), GameHistory.empty, Color.White,
Square(File.E, Rank.R7), Square(File.E, Rank.R8)
))
override def completePromotion(piece: PromotionPiece): Unit =
capturedPiece = Some(piece)
notifyObservers(MoveExecutedEvent(Board(Map.empty), GameHistory.empty, Color.Black, "e7", "e8", None))
}
val in = new ByteArrayInputStream("e7e8\nn\nquit\n".getBytes)
val out = new ByteArrayOutputStream()
val ui = new TerminalUI(engine)
Console.withIn(in) {
Console.withOut(out) {
ui.start()
}
}
capturedPiece should be(Some(PromotionPiece.Knight))
}
}
+3
View File
@@ -0,0 +1,3 @@
MAJOR=0
MINOR=1
PATCH=0
+1 -1
View File
@@ -1,2 +1,2 @@
rootProject.name = "NowChessSystems" rootProject.name = "NowChessSystems"
include("modules:core", "modules:api") include("modules:core", "modules:api", "modules:ui")