refactor(tests): enhance test coverage for move application and piece movement logic

This commit is contained in:
2026-04-05 22:03:40 +02:00
parent 4cf39e3e97
commit 3cec5b8898
14 changed files with 773 additions and 97 deletions
@@ -158,7 +158,7 @@ class GameEngine(
else
replayMoves(ctx.moves, savedContext)
private def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
var error: Option[String] = None
moves.foreach: move =>
if error.isEmpty then
@@ -170,7 +170,6 @@ class GameEngine(
else
error = Some(s"Promotion required for move ${move.from}${move.to}")
case _ => ()
error match
case Some(err) =>
currentContext = savedContext
@@ -256,7 +255,7 @@ class GameEngine(
case PromotionPiece.Knight => "N"
s"${move.to}=$ppChar"
private def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
private[engine] def normalMoveNotation(move: Move, boardBefore: Board, isCapture: Boolean): String =
boardBefore.pieceAt(move.from).map(_.pieceType) match
case Some(PieceType.Pawn) =>
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
@@ -264,11 +263,9 @@ class GameEngine(
case Some(pt) =>
val letter = pieceNotation(pt)
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
// $COVERAGE-OFF$ unreachable: executeMove is only called after a piece existence check
case None => move.to.toString
// $COVERAGE-ON$
private def pieceNotation(pieceType: PieceType): String =
private[engine] def pieceNotation(pieceType: PieceType): String =
pieceType match
case PieceType.Knight => "N"
case PieceType.Bishop => "B"
@@ -0,0 +1,174 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, MoveRedoneEvent, Observer}
import de.nowchess.io.GameContextImport
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineCoverageRegressionTest extends AnyFunSuite with Matchers:
private def sq(alg: String): Square =
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
val events = collection.mutable.ListBuffer[GameEvent]()
engine.subscribe(new Observer { def onGameEvent(event: GameEvent): Unit = events += event })
events
test("accessors expose redo availability and command history"):
val engine = new GameEngine()
engine.canRedo shouldBe false
engine.commandHistory shouldBe empty
engine.processUserInput("e2e4")
engine.commandHistory.nonEmpty shouldBe true
test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("")
engine.processUserInput("oops")
engine.processUserInput("undo")
engine.processUserInput("redo")
events.count(_.isInstanceOf[InvalidMoveEvent]) should be >= 3
test("processUserInput emits Illegal move for syntactically valid but illegal target"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("e2e5")
events.exists {
case InvalidMoveEvent(_, reason) => reason.contains("Illegal move")
case _ => false
} shouldBe true
test("loadGame returns Left when importer fails"):
val engine = new GameEngine()
val failingImporter = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine()
val events = captureEvents(engine)
engine.processUserInput("e2e4")
val target = GameContext.initial.withTurn(Color.Black)
engine.loadPosition(target)
engine.context shouldBe target
engine.commandHistory shouldBe empty
events.lastOption.exists(_.isInstanceOf[de.nowchess.chess.observer.BoardResetEvent]) shouldBe true
test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine()
val events = captureEvents(engine)
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
events.clear()
engine.processUserInput("a1h1")
engine.processUserInput("undo")
engine.processUserInput("redo")
val redo = events.collectFirst { case e: MoveRedoneEvent => e }
redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
test("loadGame replay handles promotion moves when pending promotion exists"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
val permissiveRules = new RuleSet:
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 applyMove(context: GameContext, move: Move): GameContext = DefaultRules.applyMove(context, move)
val engine = new GameEngine(ruleSet = permissiveRules)
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
engine.loadGame(importer, "ignored") shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(promotionMove)
test("loadGame replay restores previous context when promotion cannot be completed"):
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
val engine = new GameEngine(ruleSet = noLegalMoves)
engine.processUserInput("e2e4")
val saved = engine.context
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
val result = engine.loadGame(importer, "ignored")
result.isLeft shouldBe true
result.left.toOption.get should include("Promotion required")
engine.context shouldBe saved
test("loadGame replay executes non-promotion moves through default replay branch"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
val engine = new GameEngine()
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove)
test("loadGame replay will stop on errors"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal())
val engine = new GameEngine()
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove)
test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine()
val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
result shouldBe "e4"
test("pieceNotation default branch returns empty string"):
val engine = new GameEngine()
val result = engine.pieceNotation(PieceType.Pawn)
result shouldBe ""
test("observerCount reflects subscribe and unsubscribe operations"):
val engine = new GameEngine()
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = ()
engine.observerCount shouldBe 0
engine.subscribe(observer)
engine.observerCount shouldBe 1
engine.unsubscribe(observer)
engine.observerCount shouldBe 0