refactor(tests): improve CommandInvoker tests for clarity and coverage
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-05 22:44:53 +02:00
parent 03c3b90d06
commit d2c22337aa
14 changed files with 358 additions and 712 deletions
@@ -29,11 +29,10 @@ class FenExporterTest extends AnyFunSuite with Matchers:
moves = List.fill(moveCount)(dummyMove)
)
test("export initial position to FEN"):
val fen = FenExporter.gameContextToFen(GameContext.initial)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
test("exportGameContextToFen handles initial and typical developed position"):
FenExporter.gameContextToFen(GameContext.initial) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
test("export position after e4"):
val gameContext = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR",
turn = Color.Black,
@@ -42,11 +41,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 0,
moveCount = 0
)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
FenExporter.gameContextToFen(gameContext) shouldBe
"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
test("export position with no castling"):
val gameContext = context(
test("export handles castling rights variants and en-passant with counters"):
val noCastling = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
turn = Color.White,
castlingRights = CastlingRights.None,
@@ -54,11 +53,10 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 0,
moveCount = 0
)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
FenExporter.gameContextToFen(noCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
test("export position with partial castling"):
val gameContext = context(
val partialCastling = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
turn = Color.White,
castlingRights = CastlingRights(
@@ -71,11 +69,10 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 5,
moveCount = 4
)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
FenExporter.gameContextToFen(partialCastling) shouldBe
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
test("export position with en passant and move counts"):
val gameContext = context(
val withEnPassant = context(
piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR",
turn = Color.White,
castlingRights = CastlingRights.All,
@@ -83,8 +80,8 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 2,
moveCount = 4
)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
FenExporter.gameContextToFen(withEnPassant) shouldBe
"rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
test("halfMoveClock round-trips through FEN export and import"):
val gameContext = GameContext(
@@ -6,71 +6,43 @@ import org.scalatest.matchers.should.Matchers
class FenParserTest extends AnyFunSuite with Matchers:
test("parseBoard: initial position places pieces correctly"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val board = FenParser.parseBoard(fen)
board.map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
board.map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
test("parseBoard parses canonical positions and supports round-trip"):
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val empty = "8/8/8/8/8/8/8/8"
val partial = "8/8/4k3/8/4K3/8/8/8"
test("parseBoard: empty board"):
val fen = "8/8/8/8/8/8/8/8"
val board = FenParser.parseBoard(fen)
board.get.pieces.size shouldBe 0
FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
FenParser.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
FenParser.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
test("parseBoard: partial position"):
val fen = "8/8/4k3/8/4K3/8/8/8"
val board = FenParser.parseBoard(fen)
board.map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
FenParser.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
test("round-trip initial position"):
val originalFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val exported = FenParser.parseBoard(originalFen).map(FenExporter.boardToFen)
exported shouldBe Some(originalFen)
test("round-trip empty board"):
val originalFen = "8/8/8/8/8/8/8/8"
val exported = FenParser.parseBoard(originalFen).map(FenExporter.boardToFen)
exported shouldBe Some(originalFen)
test("parseFen: initial position with all fields"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val context = FenParser.parseFen(fen)
context.fold(_ => fail(), ctx =>
test("parseFen parses full state for common valid inputs"):
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0
)
test("parseFen: position after e4"):
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
val context = FenParser.parseFen(fen)
context.fold(_ => fail(), ctx =>
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
)
test("parseFen: no castling rights"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
val context = FenParser.parseFen(fen)
context.fold(_ => fail(), ctx =>
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false
)
test("parseFen: invalid color fails"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1"
FenParser.parseFen(fen).isLeft shouldBe true
test("parseFen rejects invalid color and castling tokens"):
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1").isLeft shouldBe true
test("parseFen: invalid castling fails"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1"
FenParser.parseFen(fen).isLeft shouldBe true
test("importGameContext: valid FEN"):
test("importGameContext returns Right for valid and Left for invalid FEN"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
FenParser.importGameContext(fen).isRight shouldBe true
test("importGameContext: invalid FEN"):
val invalidFen = "invalid fen string"
FenParser.importGameContext(invalidFen).isLeft shouldBe true
FenParser.importGameContext("invalid fen string").isLeft shouldBe true
@@ -1,124 +1,65 @@
package de.nowchess.io.pgn
import de.nowchess.api.board.{PieceType, *}
import de.nowchess.api.move.{PromotionPiece, Move, MoveType}
import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class PgnExporterTest extends AnyFunSuite with Matchers:
test("export empty game") {
test("exportGame renders headers and basic move text"):
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val pgn = PgnExporter.exportGame(headers, List.empty)
val emptyPgn = PgnExporter.exportGame(headers, List.empty)
emptyPgn.contains("[Event \"Test\"]") shouldBe true
emptyPgn.contains("[White \"A\"]") shouldBe true
emptyPgn.contains("[Black \"B\"]") shouldBe true
pgn.contains("[Event \"Test\"]") shouldBe true
pgn.contains("[White \"A\"]") shouldBe true
pgn.contains("[Black \"B\"]") shouldBe true
}
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
test("export single move") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B")
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
val pgn = PgnExporter.exportGame(headers, moves)
test("exportGame renders castling grouping and result markers"):
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
pgn.contains("1. e4") shouldBe true
}
test("export castling") {
val headers = Map("Event" -> "Test")
val moves = List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))
val pgn = PgnExporter.exportGame(headers, moves)
pgn.contains("O-O") shouldBe true
}
test("export game sequence") {
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B", "Result" -> "1-0")
val moves = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)),
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal(false)),
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal(false))
val seq = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
Move(Square(File.C, Rank.R7), Square(File.C, Rank.R5), MoveType.Normal()),
Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
)
val pgn = PgnExporter.exportGame(headers, moves)
val grouped = PgnExporter.exportGame(Map("Result" -> "1-0"), seq)
grouped should include("1. e4 c5")
grouped should include("2. Nf3")
pgn.contains("1. e4 c5") shouldBe true
pgn.contains("2. Nf3") shouldBe true
}
val oneMove = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
PgnExporter.exportGame(Map.empty, oneMove) shouldBe "1. e4 *"
PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), oneMove) should endWith("1/2-1/2")
test("export game with no headers returns only move text") {
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
test("exportGame handles promotion suffixes and normal move formatting"):
List(
PromotionPiece.Queen -> "=Q",
PromotionPiece.Rook -> "=R",
PromotionPiece.Bishop -> "=B",
PromotionPiece.Knight -> "=N"
).foreach { (piece, suffix) =>
val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(piece))
PgnExporter.exportGame(Map.empty, List(move)) should include(s"e8$suffix")
}
pgn shouldBe "1. e4 *"
}
val normal = PgnExporter.exportGame(Map.empty, List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
normal should include("e4")
normal should not include "="
test("export queenside castling") {
val headers = Map("Event" -> "Test")
val moves = List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))
val pgn = PgnExporter.exportGame(headers, moves)
pgn.contains("O-O-O") shouldBe true
}
test("exportGame encodes promotion to Queen as =Q suffix") {
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include ("e8=Q")
}
test("exportGame encodes promotion to Rook as =R suffix") {
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include ("e8=R")
}
test("exportGame encodes promotion to Bishop as =B suffix") {
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include ("e8=B")
}
test("exportGame encodes promotion to Knight as =N suffix") {
val moves = List(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include ("e8=N")
}
test("exportGame does not add suffix for normal moves") {
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn should include ("e4")
pgn should not include "="
}
test("exportGame uses Result header as termination marker"):
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), moves)
pgn should endWith("1/2-1/2")
test("exportGame with no Result header still uses * as default"):
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)))
val pgn = PgnExporter.exportGame(Map.empty, moves)
pgn shouldBe "1. e4 *"
test("exportGameContext: moves are preserved in output") {
test("exportGameContext preserves moves and default headers"):
val moves = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)),
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal(false))
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()),
Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal())
)
val ctx = GameContext.initial.copy(moves = moves)
val exported = PgnExporter.exportGameContext(ctx)
val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
withMoves.contains("e4") shouldBe true
withMoves.contains("e5") shouldBe true
exported.contains("e4") shouldBe true
exported.contains("e5") shouldBe true
}
test("exportGameContext: empty game returns headers only") {
val ctx = GameContext.initial
val exported = PgnExporter.exportGameContext(ctx)
exported.contains("[Event") shouldBe true
exported.contains("*") shouldBe true // Result terminator
}
val empty = PgnExporter.exportGameContext(GameContext.initial)
empty.contains("[Event") shouldBe true
empty.contains("*") shouldBe true
@@ -9,186 +9,106 @@ import org.scalatest.matchers.should.Matchers
class PgnParserTest extends AnyFunSuite with Matchers:
test("parse PGN headers only"):
val pgn = """[Event "Test Game"]
test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
val headerOnly = """[Event "Test Game"]
[White "Alice"]
[Black "Bob"]
[Result "1-0"]"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.headers("Event") shouldBe "Test Game"
game.get.headers("White") shouldBe "Alice"
val onlyHeaders = PgnParser.parsePgn(headerOnly)
onlyHeaders.isDefined shouldBe true
onlyHeaders.get.headers("Event") shouldBe "Test Game"
onlyHeaders.get.headers("White") shouldBe "Alice"
test("parse simple game sequence"):
val pgn = """[Event "Test"]
val simple = PgnParser.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nc6"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 4
1. e4 e5 2. Nf3 Nc6""")
simple.map(_.moves.length) shouldBe Some(4)
test("parse move with capture"):
val pgn = """[Event "Test"]
val capture = PgnParser.parsePgn("""[Event "Test"]
1. Nf3 e5 2. Nxe5"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 3
game.get.moves(2).to shouldBe Square(File.E, Rank.R5)
1. Nf3 e5 2. Nxe5""")
capture.map(_.moves.length) shouldBe Some(3)
capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
test("parse kingside castling O-O"):
val pgn = """[Event "Test"]
val whiteKs = PgnParser.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.moveType shouldBe MoveType.CastleKingside
lastMove.from shouldBe Square(File.E, Rank.R1)
lastMove.to shouldBe Square(File.G, Rank.R1)
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
whiteKs.moveType shouldBe MoveType.CastleKingside
whiteKs.from shouldBe Square(File.E, Rank.R1)
whiteKs.to shouldBe Square(File.G, Rank.R1)
test("parse queenside castling O-O-O"):
val pgn = """[Event "Test"]
val whiteQs = PgnParser.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.moveType shouldBe MoveType.CastleQueenside
lastMove.from shouldBe Square(File.E, Rank.R1)
lastMove.to shouldBe Square(File.C, Rank.R1)
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last
whiteQs.moveType shouldBe MoveType.CastleQueenside
whiteQs.from shouldBe Square(File.E, Rank.R1)
whiteQs.to shouldBe Square(File.C, Rank.R1)
test("parse black kingside castling"):
val pgn = """[Event "Test"]
val blackKs = PgnParser.parsePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val blackCastle = game.get.moves.last
blackCastle.moveType shouldBe MoveType.CastleKingside
blackCastle.from shouldBe Square(File.E, Rank.R8)
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
blackKs.moveType shouldBe MoveType.CastleKingside
blackKs.from shouldBe Square(File.E, Rank.R8)
test("parse black queenside castling"):
val pgn = """[Event "Test"]
val blackQs = PgnParser.parsePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
val lastMove = game.get.moves.last
lastMove.moveType shouldBe MoveType.CastleQueenside
lastMove.from shouldBe Square(File.E, Rank.R8)
lastMove.to shouldBe Square(File.C, Rank.R8)
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last
blackQs.moveType shouldBe MoveType.CastleQueenside
blackQs.from shouldBe Square(File.E, Rank.R8)
blackQs.to shouldBe Square(File.C, Rank.R8)
test("result tokens are skipped"):
val pgn = """[Event "Test"]
PgnParser.parsePgn("""[Event "Test"]
1. e4 e5 1-0"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 2
1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
PgnParser.parsePgn("""[Event "Test"]
test("unrecognised tokens are skipped"):
val pgn = """[Event "Test"]
1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
1. e4 INVALID e5"""
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 2
test("parseAlgebraicMove: pawn to e4"):
test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
val board = Board.initial
val result = PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.to shouldBe Square(File.E, Rank.R4)
PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
test("parseAlgebraicMove: knight to f3"):
val board = Board.initial
val result = PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.to shouldBe Square(File.F, Rank.R3)
test("parseAlgebraicMove: promotion to Queen"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
test("parseAlgebraicMove: promotion to Rook"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
test("parseAlgebraicMove: promotion to Bishop"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
test("parseAlgebraicMove: promotion to Knight"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
test("file disambiguation: Rad1"):
val pieces: Map[Square, Piece] = Map(
val rookPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val board = Board(pieces)
val result = PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.from shouldBe Square(File.A, Rank.R1)
test("rank disambiguation: R1a3"):
val pieces: Map[Square, Piece] = Map(
val rankPieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.A, Rank.R4) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
val board = Board(pieces)
val result = PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(board), Color.White)
result.isDefined shouldBe true
result.get.from shouldBe Square(File.A, Rank.R1)
PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
test("importGameContext: valid PGN"):
val kingBoard = FenParser.parseBoard("4k3/8/8/8/8/8/8/4K3").get
val king = PgnParser.parseAlgebraicMove("Ke2", GameContext.initial.withBoard(kingBoard), Color.White)
king.isDefined shouldBe true
king.get.from shouldBe Square(File.E, Rank.R1)
king.get.to shouldBe Square(File.E, Rank.R2)
test("parseAlgebraicMove handles all promotion targets"):
val board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
PgnParser.parseAlgebraicMove("e7e8=Q", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Queen)
PgnParser.parseAlgebraicMove("e7e8=R", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Rook)
PgnParser.parseAlgebraicMove("e7e8=B", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Bishop)
PgnParser.parseAlgebraicMove("e7e8=N", GameContext.initial.withBoard(board), Color.White).get.moveType shouldBe MoveType.Promotion(PromotionPiece.Knight)
test("importGameContext accepts valid and empty PGN"):
val pgn = """[Event "Test"]
1. e4 e5"""
val result = PgnParser.importGameContext(pgn)
result.isRight shouldBe true
PgnParser.importGameContext(pgn).isRight shouldBe true
PgnParser.importGameContext("").isRight shouldBe true
test("importGameContext: invalid PGN"):
val invalidPgn = ""
val result = PgnParser.importGameContext(invalidPgn)
// Empty PGN is still valid (no moves), so check for reasonable parsing
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"):
test("parser edge cases: uppercase token hint chars and promotion mismatch handling"):
PgnParser.parseAlgebraicMove("E5", GameContext.initial, Color.White) shouldBe None
PgnParser.parseAlgebraicMove("N?f3", GameContext.initial, Color.White).get.to shouldBe Square(File.F, Rank.R3)
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)
PgnParser.parseAlgebraicMove("e8", GameContext.initial.withBoard(board), Color.White) shouldBe None
@@ -7,113 +7,52 @@ import org.scalatest.matchers.should.Matchers
class PgnValidatorTest extends AnyFunSuite with Matchers:
test("validatePgn: valid simple game returns Right with correct moves"):
test("validatePgn accepts valid games including castling and result tokens"):
val pgn =
"""[Event "Test"]
[White "A"]
[Black "B"]
1. e4 e5 2. Nf3 Nc6
"""
PgnParser.validatePgn(pgn) match
case Right(game) =>
game.moves.length shouldBe 4
game.headers("Event") shouldBe "Test"
game.moves(0).from shouldBe Square(File.E, Rank.R2)
game.moves(0).to shouldBe Square(File.E, Rank.R4)
case Left(err) => fail(s"Expected Right but got Left($err)")
val valid = PgnParser.validatePgn(pgn)
valid.isRight shouldBe true
valid.toOption.get.moves.length shouldBe 4
valid.toOption.get.moves.head.from shouldBe Square(File.E, Rank.R2)
test("validatePgn: empty move text returns Right with no moves"):
val pgn = "[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n"
PgnParser.validatePgn(pgn) match
case Right(game) => game.moves shouldBe empty
case Left(err) => fail(s"Expected Right but got Left($err)")
test("validatePgn: impossible position returns Left"):
// "Nf6" without any preceding moves — there is no knight that can reach f6 from f3 yet
// but e4 e5 Nf3 is OK; then Nd4 — knight on f3 can go to d4
// Let's use a clearly impossible move: "Qd4" from the initial position (queen can't move)
val pgn =
"""[Event "Test"]
1. Qd4
"""
PgnParser.validatePgn(pgn) match
case Left(_) => succeed
case Right(g) => fail(s"Expected Left but got Right with ${g.moves.length} moves")
test("validatePgn: unrecognised token returns Left"):
val pgn =
"""[Event "Test"]
1. e4 GARBAGE e5
"""
PgnParser.validatePgn(pgn) match
case Left(_) => succeed
case Right(g) => fail(s"Expected Left but got Right with ${g.moves.length} moves")
test("validatePgn: result tokens are skipped (not treated as errors)"):
val pgn =
"""[Event "Test"]
val withResult = PgnParser.validatePgn("""[Event "Test"]
1. e4 e5 1-0
"""
PgnParser.validatePgn(pgn) match
case Right(game) => game.moves.length shouldBe 2
case Left(err) => fail(s"Expected Right but got Left($err)")
""")
withResult.map(_.moves.length) shouldBe Right(2)
test("validatePgn: valid kingside castling is accepted"):
val pgn =
"""[Event "Test"]
val kCastle = PgnParser.validatePgn("""[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
"""
PgnParser.validatePgn(pgn) match
case Right(game) =>
game.moves.last.moveType shouldBe MoveType.CastleKingside
case Left(err) => fail(s"Expected Right but got Left($err)")
""")
kCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleKingside)
test("validatePgn: castling when not legal returns Left"):
// Try to castle on move 1 — impossible from initial position (pieces in the way)
val pgn =
"""[Event "Test"]
1. O-O
"""
PgnParser.validatePgn(pgn) match
case Left(_) => succeed
case Right(g) => fail(s"Expected Left but got Right with ${g.moves.length} moves")
test("validatePgn: valid queenside castling is accepted"):
val pgn =
"""[Event "Test"]
val qCastle = PgnParser.validatePgn("""[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O
"""
PgnParser.validatePgn(pgn) match
case Right(game) =>
game.moves.last.moveType shouldBe MoveType.CastleQueenside
case Left(err) => fail(s"Expected Right but got Left($err)")
""")
qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
test("validatePgn: disambiguation with two rooks is accepted"):
val pieces: Map[Square, Piece] = Map(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
)
// Build PGN from this custom board is hard, so test strictParseAlgebraicMove directly
val board = Board(pieces)
// Both rooks can reach d1 — "Rad1" should pick the a-file rook
val result = PgnParser.validatePgn("[Event \"T\"]\n\n1. e4")
// This tests the main flow; below we test disambiguation in isolation
result.isRight shouldBe true
test("validatePgn rejects impossible illegal and garbage tokens"):
PgnParser.validatePgn("""[Event "Test"]
test("validatePgn: ambiguous move without disambiguation returns Left"):
// Set up a position where two identical pieces can reach the same square
// We can test this via the strict path: two rooks, target square, no disambiguation hint
// Build it through a sequence that leads to two rooks on same file targeting same square
// This is hard to construct via PGN alone; verify via a known impossible disambiguation
val pgn = "[Event \"T\"]\n\n1. e4"
PgnParser.validatePgn(pgn).isRight shouldBe true
1. Qd4
""").isLeft shouldBe true
PgnParser.validatePgn("""[Event "Test"]
1. O-O
""").isLeft shouldBe true
PgnParser.validatePgn("""[Event "Test"]
1. e4 GARBAGE e5
""").isLeft shouldBe true
test("validatePgn accepts empty move text and minimal valid header"):
PgnParser.validatePgn("[Event \"Test\"]\n[White \"A\"]\n[Black \"B\"]\n").map(_.moves) shouldBe Right(List.empty)
PgnParser.validatePgn("[Event \"T\"]\n\n1. e4").isRight shouldBe true