refactor(tests): enhance test coverage for move application and piece movement logic
This commit is contained in:
@@ -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"
|
||||
|
||||
+174
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user