feat: NCS-25 Add linters to keep quality up (#27)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #27
Reviewed-by: Leon Hermann <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
This commit was merged in pull request #27.
This commit is contained in:
2026-04-12 20:58:39 +02:00
committed by Janis
parent 3cb3160731
commit fd4e67d4f7
79 changed files with 1671 additions and 1457 deletions
@@ -1,11 +1,11 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, Piece}
import de.nowchess.api.board.{Piece, Square}
import de.nowchess.api.game.GameContext
/** Marker trait for all commands that can be executed and undone.
* Commands encapsulate user actions and game state transitions.
*/
/** 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
@@ -16,15 +16,14 @@ trait Command:
/** 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.
*/
/** 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,
previousContext: Option[GameContext] = None,
notation: String = ""
from: Square,
to: Square,
moveResult: Option[MoveResult] = None,
previousContext: Option[GameContext] = None,
notation: String = "",
) extends Command:
override def execute(): Boolean =
@@ -39,18 +38,18 @@ case class MoveCommand(
sealed trait MoveResult
object MoveResult:
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
case object InvalidFormat extends MoveResult
case object InvalidMove 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 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(
previousContext: Option[GameContext] = None
previousContext: Option[GameContext] = None,
) extends Command:
override def execute(): Boolean = true
@@ -3,21 +3,19 @@ package de.nowchess.chess.command
/** Manages command execution and history for undo/redo support. */
class CommandInvoker:
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentIndex = -1
/** Execute a command and add it to history.
* Discards any redo history if not at the end of the stack.
*/
/** 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)
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
executedCommands += command
currentIndex += 1
true
else
false
else false
}
/** Undo the last executed command if possible. */
@@ -27,10 +25,8 @@ class CommandInvoker:
if command.undo() then
currentIndex -= 1
true
else
false
else
false
else false
else false
}
/** Redo the next command in history if available. */
@@ -40,10 +36,8 @@ class CommandInvoker:
if command.execute() then
currentIndex += 1
true
else
false
else
false
else false
else false
}
/** Get the history of all executed commands. */
@@ -4,21 +4,25 @@ import de.nowchess.api.board.{File, Rank, Square}
object Parser:
/** Parses coordinate notation such as "e2e4" or "g1f3".
* Returns None for any input that does not match the expected format.
*/
/** Parses coordinate notation such as "e2e4" or "g1f3". Returns None for any input that does not match the expected
* format.
*/
def parseMove(input: String): Option[(Square, Square)] =
val trimmed = input.trim.toLowerCase
Option.when(trimmed.length == 4)(trimmed).flatMap: s =>
for
from <- parseSquare(s.substring(0, 2))
to <- parseSquare(s.substring(2, 4))
yield (from, to)
Option
.when(trimmed.length == 4)(trimmed)
.flatMap: s =>
for
from <- parseSquare(s.substring(0, 2))
to <- parseSquare(s.substring(2, 4))
yield (from, to)
private def parseSquare(s: String): Option[Square] =
Option.when(s.length == 2)(s).flatMap: sq =>
val fileIdx = sq(0) - 'a'
val rankIdx = sq(1) - '1'
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx))
)
Option
.when(s.length == 2)(s)
.flatMap: sq =>
val fileIdx = sq(0) - 'a'
val rankIdx = sq(1) - '1'
Option.when(fileIdx >= 0 && fileIdx <= 7 && rankIdx >= 0 && rankIdx <= 7)(
Square(File.values(fileIdx), Rank.values(rankIdx)),
)
@@ -6,45 +6,46 @@ import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.io.{GameContextImport, GameContextExport}
import de.nowchess.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
/** Pure game engine that manages game state and notifies observers of state changes.
* All rule queries delegate to the injected RuleSet.
* All user interactions go through Commands; state changes are broadcast via GameEvents.
*/
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
*/
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules,
) extends Observable:
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker()
private val invoker = new CommandInvoker()
/** Pending promotion: the Move that triggered it (from/to only, moveType filled in later). */
private case class PendingPromotion(from: Square, to: Square, contextBefore: GameContext)
@SuppressWarnings(Array("DisableSyntax.var"))
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 }
def isPendingPromotion: Boolean = synchronized(pendingPromotion.isDefined)
// Synchronized accessors for current state
def board: Board = synchronized { currentContext.board }
def turn: Color = synchronized { currentContext.turn }
def context: GameContext = synchronized { currentContext }
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
def context: GameContext = synchronized(currentContext)
/** Check if undo is available. */
def canUndo: Boolean = synchronized { invoker.canUndo }
def canUndo: Boolean = synchronized(invoker.canUndo)
/** Check if redo is available. */
def canRedo: Boolean = synchronized { invoker.canRedo }
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 }
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.
*/
/** 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
@@ -62,10 +63,12 @@ class GameEngine(
invoker.clear()
notifyObservers(DrawClaimedEvent(currentContext))
else
notifyObservers(InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered."
))
notifyObservers(
InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: the 50-move rule has not been triggered.",
),
)
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
@@ -73,10 +76,12 @@ class GameEngine(
case moveInput =>
Parser.parseMove(moveInput) match
case None =>
notifyObservers(InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
))
notifyObservers(
InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
),
)
case Some((from, to)) =>
handleParsedMove(from, to)
}
@@ -108,9 +113,8 @@ class GameEngine(
to.rank.ordinal == promoRank
}
/** Apply a player's promotion piece choice.
* Must only be called when isPendingPromotion is true.
*/
/** 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 =>
@@ -120,23 +124,19 @@ class GameEngine(
val move = Move(pending.from, pending.to, MoveType.Promotion(piece))
// Verify it's actually legal
val legal = ruleSet.legalMoves(currentContext)(pending.from)
if legal.contains(move) then
executeMove(move)
else
notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
if legal.contains(move) then executeMove(move)
else notifyObservers(InvalidMoveEvent(currentContext, "Error completing promotion."))
}
/** Undo the last move. */
def undo(): Unit = synchronized { performUndo() }
def undo(): Unit = synchronized(performUndo())
/** Redo the last undone move. */
def redo(): Unit = synchronized { performRedo() }
def redo(): Unit = synchronized(performRedo())
/** Load a game using the provided importer.
* If the imported context has moves, they are replayed through the command system.
* Otherwise, the position is set directly.
* Notifies observers with PgnLoadedEvent on success.
*/
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
*/
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
importer.importGameContext(input) match
case Left(err) => Left(err)
@@ -155,29 +155,24 @@ class GameEngine(
if ctx.moves.isEmpty then
currentContext = ctx
Right(())
else
replayMoves(ctx.moves, savedContext)
else replayMoves(ctx.moves, savedContext)
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
var error: Option[String] = None
moves.foreach: move =>
if error.isEmpty then
handleParsedMove(move.from, move.to)
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
acc.flatMap(_ => applyReplayMove(move))
}
result.left.foreach(_ => currentContext = savedContext)
result
move.moveType match {
case MoveType.Promotion(pp) =>
if pendingPromotion.isDefined then
completePromotion(pp)
else
error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
}
error match
case Some(err) =>
currentContext = savedContext
Left(err)
case None =>
private def applyReplayMove(move: Move): Either[String, Unit] =
handleParsedMove(move.from, move.to)
move.moveType match
case MoveType.Promotion(pp) if pendingPromotion.isDefined =>
completePromotion(pp)
Right(())
case MoveType.Promotion(_) =>
Left(s"Promotion required for move ${move.from}${move.to}")
case _ => Right(())
/** Export the current game context using the provided exporter. */
def exportGame(exporter: GameContextExport): String = synchronized {
@@ -203,25 +198,27 @@ class GameEngine(
private def executeMove(move: Move): Unit =
val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move)
val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move)
val cmd = MoveCommand(
from = move.from,
to = move.to,
moveResult = Some(MoveResult.Successful(nextContext, captured)),
previousContext = Some(contextBefore),
notation = translateMoveToNotation(move, contextBefore.board)
notation = translateMoveToNotation(move, contextBefore.board),
)
invoker.execute(cmd)
currentContext = nextContext
notifyObservers(MoveExecutedEvent(
currentContext,
move.from.toString,
move.to.toString,
captured.map(c => s"${c.color.label} ${c.pieceType.label}")
))
notifyObservers(
MoveExecutedEvent(
currentContext,
move.from.toString,
move.to.toString,
captured.map(c => s"${c.color.label} ${c.pieceType.label}"),
),
)
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
@@ -232,18 +229,16 @@ class GameEngine(
notifyObservers(StalemateEvent(currentContext))
invoker.clear()
currentContext = GameContext.initial
else if ruleSet.isCheck(currentContext) then
notifyObservers(CheckDetectedEvent(currentContext))
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then
notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => enPassantNotation(move)
case MoveType.Promotion(pp) => promotionNotation(move, pp)
case MoveType.CastleKingside => "O-O"
case MoveType.CastleQueenside => "O-O-O"
case MoveType.EnPassant => enPassantNotation(move)
case MoveType.Promotion(pp) => promotionNotation(move, pp)
case MoveType.Normal(isCapture) => normalMoveNotation(move, boardBefore, isCapture)
private def enPassantNotation(move: Move): String =
@@ -295,8 +290,7 @@ class GameEngine(
moveCmd.previousContext.foreach(currentContext = _)
invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
private def performRedo(): Unit =
if invoker.canRedo then
@@ -307,12 +301,13 @@ class GameEngine(
currentContext = nextCtx
invoker.redo()
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
notifyObservers(MoveRedoneEvent(
currentContext,
moveCmd.notation,
moveCmd.from.toString,
moveCmd.to.toString,
capturedDesc
))
else
notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
notifyObservers(
MoveRedoneEvent(
currentContext,
moveCmd.notation,
moveCmd.from.toString,
moveCmd.to.toString,
capturedDesc,
),
)
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
@@ -3,82 +3,81 @@ package de.nowchess.chess.observer
import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.game.GameContext
/** Base trait for all game state events.
* Events are immutable snapshots of game state changes.
*/
/** Base trait for all game state events. Events are immutable snapshots of game state changes.
*/
sealed trait GameEvent:
def context: GameContext
/** Fired when a move is successfully executed. */
case class MoveExecutedEvent(
context: GameContext,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
context: GameContext,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String],
) extends GameEvent
/** Fired when the current player is in check. */
case class CheckDetectedEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when the game reaches checkmate. */
case class CheckmateEvent(
context: GameContext,
winner: Color
context: GameContext,
winner: Color,
) extends GameEvent
/** Fired when the game reaches stalemate. */
case class StalemateEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when a move is invalid. */
case class InvalidMoveEvent(
context: GameContext,
reason: String
context: GameContext,
reason: String,
) extends GameEvent
/** Fired when a pawn reaches the back rank and the player must choose a promotion piece. */
case class PromotionRequiredEvent(
context: GameContext,
from: Square,
to: Square
context: GameContext,
from: Square,
to: Square,
) extends GameEvent
/** Fired when the board is reset. */
case class BoardResetEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
case class FiftyMoveRuleAvailableEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when a player successfully claims a draw under the 50-move rule. */
case class DrawClaimedEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Fired when a move is undone, carrying PGN notation of the reversed move. */
case class MoveUndoneEvent(
context: GameContext,
pgnNotation: String
context: GameContext,
pgnNotation: String,
) extends GameEvent
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
case class MoveRedoneEvent(
context: GameContext,
pgnNotation: String,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String]
context: GameContext,
pgnNotation: String,
fromSquare: String,
toSquare: String,
capturedPiece: Option[String],
) extends GameEvent
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent(
context: GameContext
context: GameContext,
) extends GameEvent
/** Observer trait: implement to receive game state updates. */
@@ -1,6 +1,6 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -10,13 +10,16 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private case class FailingCommand() extends Command:
override def execute(): Boolean = false
override def undo(): Boolean = false
override def execute(): Boolean = false
override def undo(): Boolean = false
override def description: String = "Failing command"
private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command:
override def execute(): Boolean = !shouldFailOnExecute
override def undo(): Boolean = !shouldFailOnUndo
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 =
@@ -24,12 +27,12 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
from = from,
to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
previousContext = Some(GameContext.initial)
previousContext = Some(GameContext.initial),
)
test("execute rejects failing commands and keeps history unchanged"):
val invoker = new CommandInvoker()
val cmd = FailingCommand()
val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
@@ -52,8 +55,8 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{
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 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)
invoker.undo()
@@ -62,7 +65,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
}
{
val invoker = new CommandInvoker()
val invoker = new CommandInvoker()
val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
invoker.execute(failingUndoCmd) shouldBe true
invoker.canUndo shouldBe true
@@ -71,7 +74,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
}
{
val invoker = new CommandInvoker()
val invoker = new CommandInvoker()
val successUndoCmd = ConditionalFailCommand()
invoker.execute(successUndoCmd) shouldBe true
invoker.undo() shouldBe true
@@ -85,15 +88,15 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand()
invoker.execute(cmd1)
invoker.execute(redoFailCmd)
@@ -106,7 +109,7 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
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
@@ -115,9 +118,9 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{
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))
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()
@@ -130,10 +133,10 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
{
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))
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)
@@ -1,6 +1,6 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -14,12 +14,12 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
from = from,
to = to,
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial)
previousContext = Some(GameContext.initial),
)
test("execute appends commands and updates index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
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
@@ -31,7 +31,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
test("undo and redo update index and availability flags"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.canUndo shouldBe false
invoker.execute(cmd)
invoker.canUndo shouldBe true
@@ -43,7 +43,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
test("clear removes full history and resets index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.clear()
invoker.history.size shouldBe 0
@@ -51,9 +51,9 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
test("execute after undo discards redo 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))
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()
@@ -1,6 +1,6 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{Square, File, Rank}
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -21,7 +21,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
val executable = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None))
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
)
executable.execute() shouldBe true
@@ -29,7 +29,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial)
previousContext = Some(GameContext.initial),
)
undoable.undo() shouldBe true
@@ -39,7 +39,7 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
val result = MoveResult.Successful(GameContext.initial, None)
val cmd2 = cmd1.copy(
moveResult = Some(result),
previousContext = Some(GameContext.initial)
previousContext = Some(GameContext.initial),
)
cmd1.moveResult shouldBe None
@@ -52,14 +52,14 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None
previousContext = None,
)
val eq2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None
previousContext = None,
)
eq1 shouldBe eq2
@@ -27,7 +27,7 @@ object EngineTestHelpers:
private val _events = mutable.ListBuffer[GameEvent]()
def events: mutable.ListBuffer[GameEvent] = _events
def eventCount: Int = _events.length
def eventCount: Int = _events.length
def hasEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Boolean =
_events.exists(ct.runtimeClass.isInstance(_))
def getEvent[T <: GameEvent](implicit ct: scala.reflect.ClassTag[T]): Option[T] =
@@ -2,7 +2,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.chess.observer.{Observer, GameEvent, CheckDetectedEvent, CheckmateEvent, StalemateEvent}
import de.nowchess.chess.observer.{CheckDetectedEvent, CheckmateEvent, GameEvent, Observer, StalemateEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -10,82 +10,91 @@ import org.scalatest.matchers.should.Matchers
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
test("GameEngine handles Checkmate (Fool's Mate)"):
val engine = new GameEngine()
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 (engine also fires MoveExecutedEvent before CheckmateEvent)
observer.events.last shouldBe a[CheckmateEvent]
val event = observer.events.last.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 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.context.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 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"
"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
events += event
@@ -92,12 +92,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def candidateMoves(context: GameContext)(square: Square): List[Move] = legalMoves(context)(square)
def legalMoves(context: GameContext)(square: Square): List[Move] =
if square == sq("e2") then List(promotionMove) else List.empty
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def allLegalMoves(context: GameContext): List[Move] = List(promotionMove)
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
val engine = new GameEngine(ruleSet = permissiveRules)
@@ -112,14 +112,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val noLegalMoves = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = List.empty
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
def allLegalMoves(context: GameContext): List[Move] = List.empty
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
def legalMoves(context: GameContext)(square: Square): List[Move] = List.empty
def allLegalMoves(context: GameContext): List[Move] = List.empty
def isCheck(context: GameContext): Boolean = false
def isCheckmate(context: GameContext): Boolean = false
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val engine = new GameEngine(ruleSet = noLegalMoves)
engine.processUserInput("e2e4")
@@ -137,21 +137,20 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
test("loadGame replay executes non-promotion moves through default replay branch"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
val engine = new GameEngine()
val engine = new GameEngine()
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove)
test("replayMoves skips later moves after the first move triggers an error"):
val engine = new GameEngine()
val saved = engine.context
val engine = new GameEngine()
val saved = engine.context
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4"))
val trailingMove = Move(sq("e2"), sq("e4"))
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine()
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
@@ -174,5 +173,3 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.observerCount shouldBe 1
engine.unsubscribe(observer)
engine.observerCount shouldBe 0
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.{Observer, GameEvent, PgnLoadedEvent}
import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent}
import de.nowchess.io.pgn.PgnParser
import de.nowchess.io.fen.FenParser
import de.nowchess.io.pgn.PgnExporter
@@ -15,7 +15,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"):
val engine = new GameEngine()
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
val pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
val result = engine.loadGame(PgnParser, pgn)
result shouldBe Right(())
engine.context.moves.size shouldBe 2
@@ -23,7 +23,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
test("loadGame with FenParser: loads position without replaying moves"):
val engine = new GameEngine()
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
val result = engine.loadGame(FenParser, fen)
result shouldBe Right(())
engine.context.moves.isEmpty shouldBe true
@@ -9,11 +9,11 @@ import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
/** Tests that exercise moveToPgn branches not covered by other test files:
* - CastleQueenside (line 223)
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
* - Promotion(Bishop) notation (line 230)
* - King normal move notation (line 246)
*/
* - CastleQueenside (line 223)
* - EnPassant notation (lines 224-225) and computeCaptured EnPassant (lines 254-255)
* - Promotion(Bishop) notation (line 230)
* - King normal move notation (line 246)
*/
class GameEngineNotationTest extends AnyFunSuite with Matchers:
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
@@ -28,10 +28,10 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
val board = FenParser.parseBoard("k7/8/8/8/8/8/8/R3K3").get
// Castling rights: white queen-side only (no king-side rook present)
val castlingRights = de.nowchess.api.board.CastlingRights(
whiteKingSide = false,
whiteKingSide = false,
whiteQueenSide = true,
blackKingSide = false,
blackQueenSide = false
blackKingSide = false,
blackQueenSide = false,
)
val ctx = GameContext.initial
.withBoard(board)
@@ -43,7 +43,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White castles queenside: e1c1
engine.processUserInput("e1c1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.clear()
engine.undo()
@@ -55,7 +55,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
test("undo after en passant emits MoveUndoneEvent with file-x-destination notation"):
// White pawn on e5, black pawn on d5 (just double-pushed), en passant square d6
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
val board = FenParser.parseBoard("k7/8/8/3pP3/8/8/8/7K").get
val epSquare = Square.fromAlgebraic("d6")
val ctx = GameContext.initial
.withBoard(board)
@@ -68,12 +68,12 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// White pawn on e5 captures en passant to d6
engine.processUserInput("e5d6")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
// Verify the captured pawn was found (computeCaptured EnPassant branch)
val moveEvt = events.collect { case e: MoveExecutedEvent => e }.head
moveEvt.capturedPiece shouldBe defined
moveEvt.capturedPiece.get should include ("Black")
moveEvt.capturedPiece.get should include("Black")
events.clear()
engine.undo()
@@ -117,11 +117,11 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
// King moves e1 -> f1
engine.processUserInput("e1f1")
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be(true)
events.clear()
engine.undo()
val evt = events.collect { case e: MoveUndoneEvent => e }.head
evt.pgnNotation should startWith ("K")
evt.pgnNotation should include ("f1")
evt.pgnNotation should startWith("K")
evt.pgnNotation should include("f1")
@@ -10,7 +10,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Checkmate ───────────────────────────────────────────────────
test("checkmate ends game with CheckmateEvent"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -24,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckmateEvent] shouldBe true
test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -45,20 +45,29 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Stalemate ───────────────────────────────────────────────────
test("stalemate ends game with StalemateEvent"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
val moves = List(
"e2e3", "a7a5",
"d1h5", "a8a6",
"h5a5", "h7h5",
"h2h4", "a6h6",
"a5c7", "f7f6",
"c7d7", "e8f7",
"d7b7", "d8d3",
"b7b8", "d3h7",
"b8c8", "f7g6"
"e2e3",
"a7a5",
"d1h5",
"a8a6",
"h5a5",
"h7h5",
"h2h4",
"a6h6",
"a5c7",
"f7f6",
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
)
moves.foreach(engine.processUserInput)
observer.clear()
@@ -68,21 +77,30 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[StalemateEvent] shouldBe true
test("stalemate when king has no moves and no pieces"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
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"
"e2e3",
"a7a5",
"d1h5",
"a8a6",
"h5a5",
"h7h5",
"h2h4",
"a6h6",
"a5c7",
"f7f6",
"c7d7",
"e8f7",
"d7b7",
"d8d3",
"b7b8",
"d3h7",
"b8c8",
"f7g6",
"c8e6",
)
moves.foreach(engine.processUserInput)
@@ -93,7 +111,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Check detection ────────────────────────────────────────────
test("check detected after move puts king in check"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -108,7 +126,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckDetectedEvent] shouldBe true
test("check by knight"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -122,7 +140,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Fifty-move rule ────────────────────────────────────────────
test("fifty-move rule triggers when half-move clock reaches 100"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -155,7 +173,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
// ── Draw claim ────────────────────────────────────────────────
test("draw can be claimed when fifty-move rule is available"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -167,7 +185,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
observer.hasEvent[DrawClaimedEvent] shouldBe true
test("draw cannot be claimed when not available"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -24,54 +24,54 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
test("processUserInput fires PromotionRequiredEvent when pawn reaches back rank") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val engine = engineWith(promotionBoard)
val events = captureEvents(engine)
val engine = engineWith(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))
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 = engineWith(promotionBoard)
val engine = engineWith(promotionBoard)
captureEvents(engine)
engine.processUserInput("e7e8")
engine.isPendingPromotion should be (true)
engine.isPendingPromotion should be(true)
}
test("isPendingPromotion is false before any promotion input") {
val engine = new GameEngine()
engine.isPendingPromotion should be (false)
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 = engineWith(promotionBoard)
val events = captureEvents(engine)
val engine = engineWith(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.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.context.moves.last.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[MoveExecutedEvent]) should be (true)
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 = engineWith(promotionBoard)
val engine = engineWith(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)))
engine.board.pieceAt(sq(File.E, Rank.R8)) should be(Some(Piece(Color.White, PieceType.Rook)))
}
test("completePromotion with no pending promotion fires InvalidMoveEvent") {
@@ -80,71 +80,71 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
engine.isPendingPromotion should be (false)
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 = engineWith(promotionBoard)
val events = captureEvents(engine)
val engine = engineWith(promotionBoard)
val events = captureEvents(engine)
engine.processUserInput("e7e8")
engine.completePromotion(PromotionPiece.Queen)
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be (true)
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(true)
}
test("completePromotion results in Moved when promotion doesn't give check") {
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
val board = FenParser.parseBoard("8/4P3/8/8/8/8/k7/8").get
val engine = engineWith(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)))
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)
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
}
test("completePromotion results in Checkmate when promotion delivers checkmate") {
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
val board = FenParser.parseBoard("k7/7P/1K6/8/8/8/8/8").get
val engine = engineWith(board)
val events = captureEvents(engine)
engine.processUserInput("h7h8")
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false)
events.exists(_.isInstanceOf[CheckmateEvent]) should be (true)
engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[CheckmateEvent]) should be(true)
}
test("completePromotion results in Stalemate when promotion creates stalemate") {
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
val board = FenParser.parseBoard("k7/1PB5/1K6/8/8/8/8/8").get
val engine = engineWith(board)
val events = captureEvents(engine)
engine.processUserInput("b7b8")
engine.completePromotion(PromotionPiece.Knight)
engine.isPendingPromotion should be (false)
events.exists(_.isInstanceOf[StalemateEvent]) should be (true)
engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[StalemateEvent]) should be(true)
}
test("completePromotion with black pawn promotion results in Moved") {
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
val board = FenParser.parseBoard("k7/8/8/8/8/7K/4p3/8").get
val engine = engineWith(board, 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)))
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)
events.exists(_.isInstanceOf[CheckDetectedEvent]) should be(false)
}
test("completePromotion fires InvalidMoveEvent when legalMoves returns only Normal moves to back rank") {
@@ -177,21 +177,21 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
DefaultRules.applyMove(context)(move)
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
val engine = new GameEngine(initialCtx, delegatingRuleSet)
val events = captureEvents(engine)
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
val engine = new GameEngine(initialCtx, delegatingRuleSet)
val events = captureEvents(engine)
// isPromotionMove will fire because pawn is on rank 7 heading to rank 8,
// and legalMoves returns Normal candidates (still non-empty) — sets pendingPromotion
engine.processUserInput("e7e8")
engine.isPendingPromotion should be (true)
engine.isPendingPromotion should be(true)
// completePromotion looks for Move(e7, e8, Promotion(Queen)) in legalMoves,
// but only Normal moves exist → fires InvalidMoveEvent
engine.completePromotion(PromotionPiece.Queen)
engine.isPendingPromotion should be (false)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be (true)
engine.isPendingPromotion should be(false)
events.exists(_.isInstanceOf[InvalidMoveEvent]) should be(true)
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
invalidEvt.reason should include ("Error completing promotion")
invalidEvt.reason should include("Error completing promotion")
}
@@ -1,6 +1,6 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Color, File, Rank, Square, Piece}
import de.nowchess.api.board.{Color, File, Piece, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.chess.observer.*
import de.nowchess.io.fen.FenParser
@@ -13,7 +13,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Observer wiring ────────────────────────────────────────────
test("observer subscribe and unsubscribe behavior"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
@@ -56,28 +56,28 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Invalid moves (minimal) ────────────────────────────────────
test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("h3h4")
observer.hasEvent[InvalidMoveEvent] shouldBe true
engine.turn shouldBe Color.White // turn unchanged
engine.turn shouldBe Color.White // turn unchanged
engine.processUserInput("e7e5") // try to move black pawn on white's turn
engine.processUserInput("e7e5") // try to move black pawn on white's turn
observer.hasEvent[InvalidMoveEvent] shouldBe true
engine.processUserInput("e2e4")
engine.processUserInput("e5e4") // pawn backward
engine.processUserInput("e5e4") // pawn backward
observer.hasEvent[InvalidMoveEvent] shouldBe true
// ── Undo/Redo ────────────────────────────────────────────────
test("undo redo success and empty-history failures"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -103,7 +103,7 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Fifty-move rule ────────────────────────────────────────────
test("fifty-move event and draw claim success/failure"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -11,7 +11,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
// ── Castling ────────────────────────────────────────────────────
test("kingside castling executes successfully"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -25,7 +25,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
engine.turn shouldBe Color.Black
test("queenside castling executes successfully"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -39,7 +39,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
engine.turn shouldBe Color.Black
test("undo castling emits PGN notation"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -57,7 +57,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
// ── En passant ──────────────────────────────────────────────────
test("en passant capture executes successfully"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -69,10 +69,10 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
observer.hasEvent[MoveExecutedEvent] shouldBe true
val moveEvt = observer.getEvent[MoveExecutedEvent]
moveEvt.get.capturedPiece shouldBe defined // pawn was captured
moveEvt.get.capturedPiece shouldBe defined // pawn was captured
test("undo en passant emits file-x-destination notation"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -90,7 +90,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
// ── Pawn promotion ─────────────────────────────────────────────
test("pawn reaching back rank requires promotion"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -143,7 +143,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
engine.turn shouldBe Color.Black
test("promotion to Queen with discovered check emits CheckDetectedEvent"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -157,7 +157,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckDetectedEvent] shouldBe true
test("promotion to Queen with checkmate emits CheckmateEvent"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
@@ -171,7 +171,7 @@ class GameEngineSpecialMovesTest extends AnyFunSuite with Matchers:
observer.hasEvent[CheckmateEvent] shouldBe true
test("undo promotion emits notation with piece suffix"):
val engine = EngineTestHelpers.makeEngine()
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)