refactor(tests): enhance test coverage for move application and piece movement logic
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
import glob,re,os
|
||||||
|
rows=[]
|
||||||
|
for f in glob.glob('modules/*/build/test-results/test/TEST-*.xml'):
|
||||||
|
txt=open(f,encoding='utf-8').read(500)
|
||||||
|
m1=re.search(r'name="([^"]+)"',txt)
|
||||||
|
m2=re.search(r'tests="(\d+)"',txt)
|
||||||
|
if m1 and m2:
|
||||||
|
rows.append((int(m2.group(1)),f,m1.group(1)))
|
||||||
|
for n,f,name in sorted(rows, reverse=True)[:20]:
|
||||||
|
print(f'{n:3} {name} ({f})')
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.nowchess.api.board
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
import org.scalatest.funsuite.AnyFunSuite
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
import org.scalatest.matchers.should.Matchers
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
@@ -120,3 +121,13 @@ class BoardTest extends AnyFunSuite with Matchers:
|
|||||||
removed.pieceAt(e2) shouldBe None
|
removed.pieceAt(e2) shouldBe None
|
||||||
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
removed.pieceAt(e4) shouldBe Some(Piece.WhiteKnight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("applyMove uses move.from and move.to to relocate a piece") {
|
||||||
|
val b = Board(Map(e2 -> Piece.WhitePawn))
|
||||||
|
|
||||||
|
val moved = b.applyMove(Move(e2, e4))
|
||||||
|
|
||||||
|
moved.pieceAt(e4) shouldBe Some(Piece.WhitePawn)
|
||||||
|
moved.pieceAt(e2) shouldBe None
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.nowchess.api.board
|
||||||
|
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class CastlingRightsTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
test("hasAnyRights and hasRights reflect current flags"):
|
||||||
|
val rights = CastlingRights(
|
||||||
|
whiteKingSide = true,
|
||||||
|
whiteQueenSide = false,
|
||||||
|
blackKingSide = false,
|
||||||
|
blackQueenSide = true
|
||||||
|
)
|
||||||
|
|
||||||
|
rights.hasAnyRights shouldBe true
|
||||||
|
rights.hasRights(Color.White) shouldBe true
|
||||||
|
rights.hasRights(Color.Black) shouldBe true
|
||||||
|
|
||||||
|
CastlingRights.None.hasAnyRights shouldBe false
|
||||||
|
CastlingRights.None.hasRights(Color.White) shouldBe false
|
||||||
|
CastlingRights.None.hasRights(Color.Black) shouldBe false
|
||||||
|
|
||||||
|
test("revokeColor clears both castling sides for selected color"):
|
||||||
|
val all = CastlingRights.All
|
||||||
|
|
||||||
|
val whiteRevoked = all.revokeColor(Color.White)
|
||||||
|
whiteRevoked.whiteKingSide shouldBe false
|
||||||
|
whiteRevoked.whiteQueenSide shouldBe false
|
||||||
|
whiteRevoked.blackKingSide shouldBe true
|
||||||
|
whiteRevoked.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
val blackRevoked = all.revokeColor(Color.Black)
|
||||||
|
blackRevoked.whiteKingSide shouldBe true
|
||||||
|
blackRevoked.whiteQueenSide shouldBe true
|
||||||
|
blackRevoked.blackKingSide shouldBe false
|
||||||
|
blackRevoked.blackQueenSide shouldBe false
|
||||||
|
|
||||||
|
test("revokeKingSide and revokeQueenSide disable only requested side"):
|
||||||
|
val all = CastlingRights.All
|
||||||
|
|
||||||
|
val whiteKingSideRevoked = all.revokeKingSide(Color.White)
|
||||||
|
whiteKingSideRevoked.whiteKingSide shouldBe false
|
||||||
|
whiteKingSideRevoked.whiteQueenSide shouldBe true
|
||||||
|
|
||||||
|
val whiteQueenSideRevoked = all.revokeQueenSide(Color.White)
|
||||||
|
whiteQueenSideRevoked.whiteKingSide shouldBe true
|
||||||
|
whiteQueenSideRevoked.whiteQueenSide shouldBe false
|
||||||
|
|
||||||
|
val blackKingSideRevoked = all.revokeKingSide(Color.Black)
|
||||||
|
blackKingSideRevoked.blackKingSide shouldBe false
|
||||||
|
blackKingSideRevoked.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
val blackQueenSideRevoked = all.revokeQueenSide(Color.Black)
|
||||||
|
blackQueenSideRevoked.blackKingSide shouldBe true
|
||||||
|
blackQueenSideRevoked.blackQueenSide shouldBe false
|
||||||
|
|
||||||
@@ -5,18 +5,13 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class ColorTest extends AnyFunSuite with Matchers:
|
class ColorTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("White.opposite returns Black") {
|
test("Color values expose opposite and label consistently"):
|
||||||
Color.White.opposite shouldBe Color.Black
|
val cases = List(
|
||||||
}
|
(Color.White, Color.Black, "White"),
|
||||||
|
(Color.Black, Color.White, "Black")
|
||||||
|
)
|
||||||
|
|
||||||
test("Black.opposite returns White") {
|
cases.foreach { (color, opposite, label) =>
|
||||||
Color.Black.opposite shouldBe Color.White
|
color.opposite shouldBe opposite
|
||||||
}
|
color.label shouldBe label
|
||||||
|
}
|
||||||
test("White.label returns 'White'") {
|
|
||||||
Color.White.label shouldBe "White"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Black.label returns 'Black'") {
|
|
||||||
Color.Black.label shouldBe "Black"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,26 +5,16 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class PieceTypeTest extends AnyFunSuite with Matchers:
|
class PieceTypeTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("Pawn.label returns 'Pawn'") {
|
test("PieceType values expose the expected labels"):
|
||||||
PieceType.Pawn.label shouldBe "Pawn"
|
val expectedLabels = List(
|
||||||
}
|
PieceType.Pawn -> "Pawn",
|
||||||
|
PieceType.Knight -> "Knight",
|
||||||
|
PieceType.Bishop -> "Bishop",
|
||||||
|
PieceType.Rook -> "Rook",
|
||||||
|
PieceType.Queen -> "Queen",
|
||||||
|
PieceType.King -> "King"
|
||||||
|
)
|
||||||
|
|
||||||
test("Knight.label returns 'Knight'") {
|
expectedLabels.foreach { (pieceType, expectedLabel) =>
|
||||||
PieceType.Knight.label shouldBe "Knight"
|
pieceType.label shouldBe expectedLabel
|
||||||
}
|
}
|
||||||
|
|
||||||
test("Bishop.label returns 'Bishop'") {
|
|
||||||
PieceType.Bishop.label shouldBe "Bishop"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Rook.label returns 'Rook'") {
|
|
||||||
PieceType.Rook.label shouldBe "Rook"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Queen.label returns 'Queen'") {
|
|
||||||
PieceType.Queen.label shouldBe "Queen"
|
|
||||||
}
|
|
||||||
|
|
||||||
test("King.label returns 'King'") {
|
|
||||||
PieceType.King.label shouldBe "King"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -60,3 +60,13 @@ class SquareTest extends AnyFunSuite with Matchers:
|
|||||||
test("fromAlgebraic returns None for rank 9") {
|
test("fromAlgebraic returns None for rank 9") {
|
||||||
Square.fromAlgebraic("e9") shouldBe None
|
Square.fromAlgebraic("e9") shouldBe None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("offset returns target square for in-bounds delta") {
|
||||||
|
Square(File.E, Rank.R4).offset(1, 2) shouldBe Some(Square(File.F, Rank.R6))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("offset returns None for out-of-bounds delta") {
|
||||||
|
Square(File.A, Rank.R1).offset(-1, 0) shouldBe None
|
||||||
|
Square(File.H, Rank.R8).offset(0, 1) shouldBe None
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,53 +9,26 @@ class MoveTest extends AnyFunSuite with Matchers:
|
|||||||
private val e2 = Square(File.E, Rank.R2)
|
private val e2 = Square(File.E, Rank.R2)
|
||||||
private val e4 = Square(File.E, Rank.R4)
|
private val e4 = Square(File.E, Rank.R4)
|
||||||
|
|
||||||
test("Move defaults moveType to Normal") {
|
test("Move defaults to Normal and keeps from/to squares") {
|
||||||
val m = Move(e2, e4)
|
val m = Move(e2, e4)
|
||||||
|
m.from shouldBe e2
|
||||||
|
m.to shouldBe e4
|
||||||
m.moveType shouldBe MoveType.Normal()
|
m.moveType shouldBe MoveType.Normal()
|
||||||
}
|
}
|
||||||
|
|
||||||
test("MoveType.Normal supports capture flag") {
|
test("Move accepts all supported move types") {
|
||||||
val m = Move(e2, e4, MoveType.Normal(isCapture = true))
|
val moveTypes = List(
|
||||||
m.moveType shouldBe MoveType.Normal(true)
|
MoveType.Normal(isCapture = true),
|
||||||
}
|
MoveType.CastleKingside,
|
||||||
|
MoveType.CastleQueenside,
|
||||||
|
MoveType.EnPassant,
|
||||||
|
MoveType.Promotion(PromotionPiece.Queen),
|
||||||
|
MoveType.Promotion(PromotionPiece.Rook),
|
||||||
|
MoveType.Promotion(PromotionPiece.Bishop),
|
||||||
|
MoveType.Promotion(PromotionPiece.Knight)
|
||||||
|
)
|
||||||
|
|
||||||
test("Move stores from and to squares") {
|
moveTypes.foreach { moveType =>
|
||||||
val m = Move(e2, e4)
|
Move(e2, e4, moveType).moveType shouldBe moveType
|
||||||
m.from shouldBe e2
|
}
|
||||||
m.to shouldBe e4
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with CastleKingside moveType") {
|
|
||||||
val m = Move(e2, e4, MoveType.CastleKingside)
|
|
||||||
m.moveType shouldBe MoveType.CastleKingside
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with CastleQueenside moveType") {
|
|
||||||
val m = Move(e2, e4, MoveType.CastleQueenside)
|
|
||||||
m.moveType shouldBe MoveType.CastleQueenside
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with EnPassant moveType") {
|
|
||||||
val m = Move(e2, e4, MoveType.EnPassant)
|
|
||||||
m.moveType shouldBe MoveType.EnPassant
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Queen") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Queen))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Knight") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Knight))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Bishop") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Bishop))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("Move with Promotion to Rook") {
|
|
||||||
val m = Move(e2, e4, MoveType.Promotion(PromotionPiece.Rook))
|
|
||||||
m.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,19 +5,14 @@ import org.scalatest.matchers.should.Matchers
|
|||||||
|
|
||||||
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
class PlayerInfoTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
test("PlayerId.apply wraps a string") {
|
test("PlayerId and PlayerInfo preserve constructor values") {
|
||||||
val id = PlayerId("player-123")
|
val raw = "player-123"
|
||||||
id.value shouldBe "player-123"
|
val id = PlayerId(raw)
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerId.value unwraps to original string") {
|
id.value shouldBe raw
|
||||||
val raw = "abc-456"
|
|
||||||
PlayerId(raw).value shouldBe raw
|
|
||||||
}
|
|
||||||
|
|
||||||
test("PlayerInfo holds id and displayName") {
|
val playerId = PlayerId("p1")
|
||||||
val id = PlayerId("p1")
|
val info = PlayerInfo(playerId, "Magnus")
|
||||||
val info = PlayerInfo(id, "Magnus")
|
|
||||||
info.id.value shouldBe "p1"
|
info.id.value shouldBe "p1"
|
||||||
info.displayName shouldBe "Magnus"
|
info.displayName shouldBe "Magnus"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ class GameEngine(
|
|||||||
else
|
else
|
||||||
replayMoves(ctx.moves, savedContext)
|
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
|
var error: Option[String] = None
|
||||||
moves.foreach: move =>
|
moves.foreach: move =>
|
||||||
if error.isEmpty then
|
if error.isEmpty then
|
||||||
@@ -170,7 +170,6 @@ class GameEngine(
|
|||||||
else
|
else
|
||||||
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
error = Some(s"Promotion required for move ${move.from}${move.to}")
|
||||||
case _ => ()
|
case _ => ()
|
||||||
|
|
||||||
error match
|
error match
|
||||||
case Some(err) =>
|
case Some(err) =>
|
||||||
currentContext = savedContext
|
currentContext = savedContext
|
||||||
@@ -256,7 +255,7 @@ class GameEngine(
|
|||||||
case PromotionPiece.Knight => "N"
|
case PromotionPiece.Knight => "N"
|
||||||
s"${move.to}=$ppChar"
|
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
|
boardBefore.pieceAt(move.from).map(_.pieceType) match
|
||||||
case Some(PieceType.Pawn) =>
|
case Some(PieceType.Pawn) =>
|
||||||
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
|
if isCapture then s"${move.from.file.toString.toLowerCase}x${move.to}"
|
||||||
@@ -264,11 +263,9 @@ class GameEngine(
|
|||||||
case Some(pt) =>
|
case Some(pt) =>
|
||||||
val letter = pieceNotation(pt)
|
val letter = pieceNotation(pt)
|
||||||
if isCapture then s"${letter}x${move.to}" else s"$letter${move.to}"
|
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
|
case None => move.to.toString
|
||||||
// $COVERAGE-ON$
|
|
||||||
|
|
||||||
private def pieceNotation(pieceType: PieceType): String =
|
private[engine] def pieceNotation(pieceType: PieceType): String =
|
||||||
pieceType match
|
pieceType match
|
||||||
case PieceType.Knight => "N"
|
case PieceType.Knight => "N"
|
||||||
case PieceType.Bishop => "B"
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package de.nowchess.io
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.io.fen.{FenExporter, FenParser}
|
||||||
|
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class IoCoverageRegressionTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private def sq(alg: String): Square =
|
||||||
|
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
|
||||||
|
|
||||||
|
test("FenParser rejects malformed board shapes and invalid piece symbols"):
|
||||||
|
FenParser.parseBoard("8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParser.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParser.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParser.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||||
|
FenParser.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||||
|
|
||||||
|
test("FenExporter exportGameContext forwards to gameContextToFen"):
|
||||||
|
val context = GameContext.initial
|
||||||
|
|
||||||
|
FenExporter.exportGameContext(context) shouldBe FenExporter.gameContextToFen(context)
|
||||||
|
|
||||||
|
test("PgnParser rejects too-short notation and invalid piece letters"):
|
||||||
|
val initial = GameContext.initial
|
||||||
|
|
||||||
|
PgnParser.parseAlgebraicMove("e", initial, Color.White) shouldBe None
|
||||||
|
PgnParser.parseAlgebraicMove("Xe5", initial, Color.White) shouldBe None
|
||||||
|
|
||||||
|
test("PgnParser rejects notation with invalid promotion piece"):
|
||||||
|
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").getOrElse(fail("valid board expected"))
|
||||||
|
val context = GameContext.initial.withBoard(board)
|
||||||
|
|
||||||
|
PgnParser.parseAlgebraicMove("e7e8=X", context, Color.White) shouldBe None
|
||||||
|
|
||||||
|
test("PgnExporter emits notation for all normal piece types and captures"):
|
||||||
|
val moves = List(
|
||||||
|
Move(sq("e2"), sq("e4")),
|
||||||
|
Move(sq("a7"), sq("a6")),
|
||||||
|
Move(sq("g1"), sq("f3")),
|
||||||
|
Move(sq("b7"), sq("b6")),
|
||||||
|
Move(sq("f1"), sq("b5"), MoveType.Normal(true)),
|
||||||
|
Move(sq("g8"), sq("f6")),
|
||||||
|
Move(sq("a1"), sq("a8"), MoveType.Normal(true)),
|
||||||
|
Move(sq("c7"), sq("c6")),
|
||||||
|
Move(sq("d1"), sq("d7"), MoveType.Normal(true)),
|
||||||
|
Move(sq("d8"), sq("d7"), MoveType.Normal(true)),
|
||||||
|
Move(sq("e1"), sq("e2"), MoveType.Normal(true))
|
||||||
|
)
|
||||||
|
|
||||||
|
val pgn = PgnExporter.exportGame(Map("Result" -> "*"), moves)
|
||||||
|
|
||||||
|
pgn should include("e4")
|
||||||
|
pgn should include("Nf3")
|
||||||
|
pgn should include("Bxb5")
|
||||||
|
pgn should include("Rxa8")
|
||||||
|
pgn should include("Qxd7")
|
||||||
|
pgn should include("Kxe2")
|
||||||
|
|
||||||
|
test("PgnExporter emits en-passant and promotion capture notation"):
|
||||||
|
val enPassant = Move(sq("e2"), sq("d3"), MoveType.EnPassant)
|
||||||
|
val promotionCapture = Move(sq("e7"), sq("f8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
|
val pawnCapture = Move(sq("e2"), sq("d3"), MoveType.Normal(isCapture = true))
|
||||||
|
val promotionQuietSetup = Move(sq("e8"), sq("e7"))
|
||||||
|
val promotionQuiet = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
|
||||||
|
|
||||||
|
val pgn = PgnExporter.exportGame(Map.empty, List(enPassant, promotionCapture))
|
||||||
|
val pawnCapturePgn = PgnExporter.exportGame(Map.empty, List(pawnCapture))
|
||||||
|
val quietPromotionPgn = PgnExporter.exportGame(Map.empty, List(promotionQuietSetup, promotionQuiet))
|
||||||
|
|
||||||
|
pgn should include("exd3")
|
||||||
|
pgn should include("exf8=Q")
|
||||||
|
pawnCapturePgn should include("exd3")
|
||||||
|
quietPromotionPgn should include("e8=Q")
|
||||||
|
|
||||||
|
test("PgnExporter emits all promotion suffixes"):
|
||||||
|
val promotions = List(
|
||||||
|
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)),
|
||||||
|
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Rook)),
|
||||||
|
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Bishop)),
|
||||||
|
Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Knight))
|
||||||
|
)
|
||||||
|
|
||||||
|
val pgn = PgnExporter.exportGame(Map.empty, promotions)
|
||||||
|
|
||||||
|
pgn should include("=Q")
|
||||||
|
pgn should include("=R")
|
||||||
|
pgn should include("=B")
|
||||||
|
pgn should include("=N")
|
||||||
|
|
||||||
|
test("PgnParser parsePgn silently skips unknown tokens"):
|
||||||
|
val parsed = PgnParser.parsePgn("1. e4 ??? e5")
|
||||||
|
|
||||||
|
parsed.map(_.moves.size) shouldBe Some(2)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -167,3 +167,28 @@ class PgnParserTest extends AnyFunSuite with Matchers:
|
|||||||
val result = PgnParser.importGameContext(invalidPgn)
|
val result = PgnParser.importGameContext(invalidPgn)
|
||||||
// Empty PGN is still valid (no moves), so check for reasonable parsing
|
// Empty PGN is still valid (no moves), so check for reasonable parsing
|
||||||
result.isRight shouldBe true
|
result.isRight shouldBe true
|
||||||
|
|
||||||
|
test("parseAlgebraicMove: uppercase file token still fails when destination is unreachable"):
|
||||||
|
val result = PgnParser.parseAlgebraicMove("E5", GameContext.initial, Color.White)
|
||||||
|
result shouldBe None
|
||||||
|
|
||||||
|
test("parseAlgebraicMove: non-file/rank hint characters are ignored"):
|
||||||
|
val result = PgnParser.parseAlgebraicMove("N?f3", GameContext.initial, Color.White)
|
||||||
|
result.isDefined shouldBe true
|
||||||
|
result.get.to shouldBe Square(File.F, Rank.R3)
|
||||||
|
|
||||||
|
test("extractPromotion returns None for unsupported promotion letter"):
|
||||||
|
PgnParser.extractPromotion("e7e8=X") shouldBe None
|
||||||
|
|
||||||
|
test("parseAlgebraicMove rejects promotion target without promotion suffix"):
|
||||||
|
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
|
||||||
|
val result = PgnParser.parseAlgebraicMove("e8", GameContext.initial.withBoard(board), Color.White)
|
||||||
|
result shouldBe None
|
||||||
|
|
||||||
|
test("parseAlgebraicMove: king notation resolves a legal king move"):
|
||||||
|
val board = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
|
||||||
|
val result = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(board), Color.White)
|
||||||
|
result.isDefined shouldBe true
|
||||||
|
result.get.from shouldBe Square(File.E, Rank.R1)
|
||||||
|
result.get.to shouldBe Square(File.E, Rank.R2)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package de.nowchess.rule
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{CastlingRights, Color, Piece, PieceType, Square}
|
||||||
|
import de.nowchess.api.game.GameContext
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.io.fen.FenParser
|
||||||
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private def contextFromFen(fen: String): GameContext =
|
||||||
|
FenParser.parseFen(fen).fold(err => fail(err), identity)
|
||||||
|
|
||||||
|
private def sq(alg: String): Square =
|
||||||
|
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
|
||||||
|
|
||||||
|
test("isCheckmate returns true for a known mate pattern"):
|
||||||
|
val context = contextFromFen("rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3")
|
||||||
|
|
||||||
|
DefaultRules.isCheck(context) shouldBe true
|
||||||
|
DefaultRules.isCheckmate(context) shouldBe true
|
||||||
|
DefaultRules.allLegalMoves(context) shouldBe empty
|
||||||
|
|
||||||
|
test("isStalemate returns true for a known stalemate pattern"):
|
||||||
|
val context = contextFromFen("7k/5K2/6Q1/8/8/8/8/8 b - - 0 1")
|
||||||
|
|
||||||
|
DefaultRules.isCheck(context) shouldBe false
|
||||||
|
DefaultRules.isStalemate(context) shouldBe true
|
||||||
|
DefaultRules.allLegalMoves(context) shouldBe empty
|
||||||
|
|
||||||
|
test("isInsufficientMaterial returns true for king versus king"):
|
||||||
|
val context = contextFromFen("8/8/8/8/8/8/4k3/4K3 w - - 0 1")
|
||||||
|
|
||||||
|
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||||
|
|
||||||
|
test("isInsufficientMaterial returns true for king and bishop versus king"):
|
||||||
|
val context = contextFromFen("8/8/8/8/8/8/4k3/3BK3 w - - 0 1")
|
||||||
|
|
||||||
|
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||||
|
|
||||||
|
test("isInsufficientMaterial returns false for king and rook versus king"):
|
||||||
|
val context = contextFromFen("8/8/8/8/8/8/4k3/3RK3 w - - 0 1")
|
||||||
|
|
||||||
|
DefaultRules.isInsufficientMaterial(context) shouldBe false
|
||||||
|
|
||||||
|
test("isFiftyMoveRule returns true when halfMoveClock is 100"):
|
||||||
|
val context = contextFromFen("8/8/8/8/8/8/4k3/4K3 w - - 100 1")
|
||||||
|
|
||||||
|
DefaultRules.isFiftyMoveRule(context) shouldBe true
|
||||||
|
|
||||||
|
test("applyMove toggles turn and records move"):
|
||||||
|
val move = Move(sq("e2"), sq("e4"))
|
||||||
|
val next = DefaultRules.applyMove(GameContext.initial, move)
|
||||||
|
|
||||||
|
next.turn shouldBe Color.Black
|
||||||
|
next.moves.lastOption shouldBe Some(move)
|
||||||
|
|
||||||
|
test("applyMove sets en passant square after double pawn push"):
|
||||||
|
val move = Move(sq("e2"), sq("e4"))
|
||||||
|
val next = DefaultRules.applyMove(GameContext.initial, move)
|
||||||
|
|
||||||
|
next.enPassantSquare shouldBe Some(sq("e3"))
|
||||||
|
|
||||||
|
test("applyMove clears en passant square for non double pawn push"):
|
||||||
|
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - d6 3 1")
|
||||||
|
val move = Move(sq("e2"), sq("e3"))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.enPassantSquare shouldBe None
|
||||||
|
|
||||||
|
test("applyMove resets halfMoveClock on pawn move"):
|
||||||
|
val context = contextFromFen("4k3/8/8/8/8/8/4P3/4K3 w - - 12 1")
|
||||||
|
val move = Move(sq("e2"), sq("e4"))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.halfMoveClock shouldBe 0
|
||||||
|
|
||||||
|
test("applyMove increments halfMoveClock on quiet non pawn move"):
|
||||||
|
val context = contextFromFen("4k3/8/8/8/8/8/8/4K1N1 w - - 7 1")
|
||||||
|
val move = Move(sq("g1"), sq("f3"))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.halfMoveClock shouldBe 8
|
||||||
|
|
||||||
|
test("applyMove resets halfMoveClock on capture"):
|
||||||
|
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 9 1")
|
||||||
|
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.halfMoveClock shouldBe 0
|
||||||
|
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||||
|
|
||||||
|
test("applyMove updates castling rights after king move"):
|
||||||
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||||
|
val move = Move(sq("e1"), sq("e2"))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.castlingRights.whiteKingSide shouldBe false
|
||||||
|
next.castlingRights.whiteQueenSide shouldBe false
|
||||||
|
next.castlingRights.blackKingSide shouldBe true
|
||||||
|
next.castlingRights.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
test("applyMove updates castling rights after rook move from h1"):
|
||||||
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K2R w KQkq - 0 1")
|
||||||
|
val move = Move(sq("h1"), sq("h2"))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.castlingRights.whiteKingSide shouldBe false
|
||||||
|
next.castlingRights.whiteQueenSide shouldBe true
|
||||||
|
|
||||||
|
test("applyMove revokes opponent castling right when rook on starting square is captured"):
|
||||||
|
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K3 w Qq - 2 1")
|
||||||
|
val move = Move(sq("a1"), sq("a8"), MoveType.Normal(isCapture = true))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.castlingRights.blackQueenSide shouldBe false
|
||||||
|
|
||||||
|
test("applyMove executes kingside castling and repositions king and rook"):
|
||||||
|
val context = contextFromFen("4k2r/8/8/8/8/8/8/R3K2R w KQk - 0 1")
|
||||||
|
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.board.pieceAt(sq("g1")) shouldBe Some(Piece(Color.White, PieceType.King))
|
||||||
|
next.board.pieceAt(sq("f1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||||
|
next.board.pieceAt(sq("e1")) shouldBe None
|
||||||
|
next.board.pieceAt(sq("h1")) shouldBe None
|
||||||
|
|
||||||
|
test("applyMove executes queenside castling and repositions king and rook"):
|
||||||
|
val context = contextFromFen("r3k3/8/8/8/8/8/8/R3K2R w KQq - 0 1")
|
||||||
|
val move = Move(sq("e1"), sq("c1"), MoveType.CastleQueenside)
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.board.pieceAt(sq("c1")) shouldBe Some(Piece(Color.White, PieceType.King))
|
||||||
|
next.board.pieceAt(sq("d1")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||||
|
next.board.pieceAt(sq("e1")) shouldBe None
|
||||||
|
next.board.pieceAt(sq("a1")) shouldBe None
|
||||||
|
|
||||||
|
test("applyMove executes en passant and removes captured pawn"):
|
||||||
|
val context = contextFromFen("k7/8/8/3pP3/8/8/8/7K w - d6 0 1")
|
||||||
|
val move = Move(sq("e5"), sq("d6"), MoveType.EnPassant)
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.board.pieceAt(sq("d6")) shouldBe Some(Piece(Color.White, PieceType.Pawn))
|
||||||
|
next.board.pieceAt(sq("d5")) shouldBe None
|
||||||
|
next.board.pieceAt(sq("e5")) shouldBe None
|
||||||
|
|
||||||
|
test("applyMove executes promotion with selected piece type"):
|
||||||
|
val context = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||||
|
val move = Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Knight))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Knight))
|
||||||
|
next.board.pieceAt(sq("a7")) shouldBe None
|
||||||
|
|
||||||
|
test("candidateMoves returns empty for opponent piece on selected square"):
|
||||||
|
val context = GameContext.initial.withTurn(Color.Black)
|
||||||
|
|
||||||
|
DefaultRules.candidateMoves(context, sq("e2")) shouldBe empty
|
||||||
|
|
||||||
|
test("legalMoves keeps king safe by filtering pinned bishop moves"):
|
||||||
|
val context = contextFromFen("8/8/8/8/8/8/r1B1K3/8 w - - 0 1")
|
||||||
|
|
||||||
|
val bishopMoves = DefaultRules.legalMoves(context, sq("c2"))
|
||||||
|
|
||||||
|
bishopMoves shouldBe empty
|
||||||
|
|
||||||
|
test("applyMove preserves black castling rights after white kingside castling"):
|
||||||
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
|
||||||
|
val move = Move(sq("e1"), sq("g1"), MoveType.CastleKingside)
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.castlingRights.whiteKingSide shouldBe false
|
||||||
|
next.castlingRights.whiteQueenSide shouldBe false
|
||||||
|
next.castlingRights.blackKingSide shouldBe true
|
||||||
|
next.castlingRights.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
test("applyMove can revoke both white castling rights when both rooks are captured"):
|
||||||
|
val context = GameContext(
|
||||||
|
board = contextFromFen("4k3/8/8/8/8/8/8/R3K2R w KQ - 0 1").board.updated(sq("a8"), Piece(Color.Black, PieceType.Queen)),
|
||||||
|
turn = Color.Black,
|
||||||
|
castlingRights = CastlingRights(true, true, false, false),
|
||||||
|
enPassantSquare = None,
|
||||||
|
halfMoveClock = 0,
|
||||||
|
moves = List.empty
|
||||||
|
)
|
||||||
|
|
||||||
|
val afterA1Capture = DefaultRules.applyMove(context, Move(sq("a8"), sq("a1"), MoveType.Normal(isCapture = true)))
|
||||||
|
val afterH1Capture = DefaultRules.applyMove(afterA1Capture, Move(sq("a1"), sq("h1"), MoveType.Normal(isCapture = true)))
|
||||||
|
|
||||||
|
afterH1Capture.castlingRights.whiteKingSide shouldBe false
|
||||||
|
afterH1Capture.castlingRights.whiteQueenSide shouldBe false
|
||||||
|
|
||||||
|
test("isInsufficientMaterial returns true for opposite color bishops only"):
|
||||||
|
val context = contextFromFen("8/8/8/8/8/8/4k1b1/3BK3 w - - 0 1")
|
||||||
|
|
||||||
|
DefaultRules.isInsufficientMaterial(context) shouldBe true
|
||||||
|
|
||||||
|
test("candidateMoves for rook includes enemy capture move"):
|
||||||
|
val context = contextFromFen("4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
|
||||||
|
|
||||||
|
val rookMoves = DefaultRules.candidateMoves(context, sq("a1"))
|
||||||
|
|
||||||
|
rookMoves.exists(m => m.to == sq("h1") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
|
||||||
|
|
||||||
|
test("candidateMoves for knight includes enemy capture move"):
|
||||||
|
val context = contextFromFen("4k3/8/8/8/8/3p4/5N2/4K3 w - - 0 1")
|
||||||
|
|
||||||
|
val knightMoves = DefaultRules.candidateMoves(context, sq("f2"))
|
||||||
|
|
||||||
|
knightMoves.exists(m => m.to == sq("d3") && m.moveType == MoveType.Normal(isCapture = true)) shouldBe true
|
||||||
|
|
||||||
|
test("candidateMoves includes black kingside and queenside castling options"):
|
||||||
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||||
|
|
||||||
|
val kingMoves = DefaultRules.candidateMoves(context, sq("e8"))
|
||||||
|
|
||||||
|
kingMoves.exists(_.moveType == MoveType.CastleKingside) shouldBe true
|
||||||
|
kingMoves.exists(_.moveType == MoveType.CastleQueenside) shouldBe true
|
||||||
|
|
||||||
|
test("applyMove executes black kingside castling and repositions pieces on rank 8"):
|
||||||
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||||
|
val move = Move(sq("e8"), sq("g8"), MoveType.CastleKingside)
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.board.pieceAt(sq("g8")) shouldBe Some(Piece(Color.Black, PieceType.King))
|
||||||
|
next.board.pieceAt(sq("f8")) shouldBe Some(Piece(Color.Black, PieceType.Rook))
|
||||||
|
next.board.pieceAt(sq("e8")) shouldBe None
|
||||||
|
next.board.pieceAt(sq("h8")) shouldBe None
|
||||||
|
|
||||||
|
test("applyMove revokes black castling rights when black rook moves from h8"):
|
||||||
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||||
|
val move = Move(sq("h8"), sq("h7"))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.castlingRights.blackKingSide shouldBe false
|
||||||
|
next.castlingRights.blackQueenSide shouldBe true
|
||||||
|
|
||||||
|
test("applyMove revokes black queenside castling right when black rook moves from a8"):
|
||||||
|
val context = contextFromFen("r3k2r/8/8/8/8/8/8/4K3 b kq - 0 1")
|
||||||
|
val move = Move(sq("a8"), sq("a7"))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.castlingRights.blackKingSide shouldBe true
|
||||||
|
next.castlingRights.blackQueenSide shouldBe false
|
||||||
|
|
||||||
|
test("applyMove revokes black kingside castling right when rook on h8 is captured"):
|
||||||
|
val context = contextFromFen("4k2r/8/8/8/8/8/8/4K2R w Kk - 0 1")
|
||||||
|
val move = Move(sq("h1"), sq("h8"), MoveType.Normal(isCapture = true))
|
||||||
|
|
||||||
|
val next = DefaultRules.applyMove(context, move)
|
||||||
|
|
||||||
|
next.castlingRights.blackKingSide shouldBe false
|
||||||
|
|
||||||
|
test("candidateMoves creates all promotion move variants for black pawn"):
|
||||||
|
val context = contextFromFen("4k3/8/8/8/8/8/p7/4K3 b - - 0 1")
|
||||||
|
val to = sq("a1")
|
||||||
|
|
||||||
|
val pawnMoves = DefaultRules.candidateMoves(context, sq("a2"))
|
||||||
|
val promotions = pawnMoves.collect { case Move(_, `to`, MoveType.Promotion(piece)) => piece }
|
||||||
|
|
||||||
|
promotions.toSet shouldBe Set(
|
||||||
|
PromotionPiece.Queen,
|
||||||
|
PromotionPiece.Rook,
|
||||||
|
PromotionPiece.Bishop,
|
||||||
|
PromotionPiece.Knight
|
||||||
|
)
|
||||||
|
|
||||||
|
test("applyMove promotion supports queen rook and bishop targets"):
|
||||||
|
val base = contextFromFen("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
|
||||||
|
|
||||||
|
val queen = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Queen)))
|
||||||
|
val rook = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Rook)))
|
||||||
|
val bishop = DefaultRules.applyMove(base, Move(sq("a7"), sq("a8"), MoveType.Promotion(PromotionPiece.Bishop)))
|
||||||
|
|
||||||
|
queen.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Queen))
|
||||||
|
rook.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Rook))
|
||||||
|
bishop.board.pieceAt(sq("a8")) shouldBe Some(Piece(Color.White, PieceType.Bishop))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.nowchess.ui.utils
|
||||||
|
|
||||||
|
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
|
||||||
|
import org.scalatest.funsuite.AnyFunSuite
|
||||||
|
import org.scalatest.matchers.should.Matchers
|
||||||
|
|
||||||
|
class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
|
||||||
|
|
||||||
|
private val whiteKing = Piece(Color.White, PieceType.King)
|
||||||
|
private val blackPawn = Piece(Color.Black, PieceType.Pawn)
|
||||||
|
|
||||||
|
test("unicode returns the correct symbol for white king"):
|
||||||
|
whiteKing.unicode shouldBe "\u2654"
|
||||||
|
|
||||||
|
test("unicode returns the correct symbol for black pawn"):
|
||||||
|
blackPawn.unicode shouldBe "\u265F"
|
||||||
|
|
||||||
|
test("render includes board coordinates on top and bottom"):
|
||||||
|
val rendered = Renderer.render(Board(Map.empty))
|
||||||
|
val lines = rendered.trim.split("\\n").toList.map(_.trim)
|
||||||
|
|
||||||
|
lines.head shouldBe "a b c d e f g h"
|
||||||
|
lines.last shouldBe "a b c d e f g h"
|
||||||
|
|
||||||
|
test("render includes rank labels from 8 down to 1"):
|
||||||
|
val rendered = Renderer.render(Board(Map.empty))
|
||||||
|
|
||||||
|
rendered should include("8")
|
||||||
|
rendered should include("1")
|
||||||
|
|
||||||
|
test("render places a piece unicode glyph on occupied square"):
|
||||||
|
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
|
||||||
|
val rendered = Renderer.render(board)
|
||||||
|
|
||||||
|
rendered should include("\u2655")
|
||||||
|
rendered should include("\u001b[")
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user