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
@@ -163,13 +163,15 @@ class GameEngine(
moves.foreach: move => moves.foreach: move =>
if error.isEmpty then if error.isEmpty then
handleParsedMove(move.from, move.to) handleParsedMove(move.from, move.to)
move.moveType match
move.moveType match {
case MoveType.Promotion(pp) => case MoveType.Promotion(pp) =>
if pendingPromotion.isDefined then if pendingPromotion.isDefined then
completePromotion(pp) completePromotion(pp)
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
@@ -27,136 +27,122 @@ class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
previousContext = Some(GameContext.initial) previousContext = Some(GameContext.initial)
) )
test("CommandInvoker.execute() with failing command returns false"): test("execute rejects failing commands and keeps history unchanged"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = FailingCommand() val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1 invoker.getCurrentIndex shouldBe -1
test("CommandInvoker.execute() does not add failed command to history"):
val invoker = new CommandInvoker()
val failingCmd = FailingCommand() val failingCmd = FailingCommand()
val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(failingCmd) shouldBe false invoker.execute(failingCmd) shouldBe false
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
invoker.execute(successCmd) shouldBe true invoker.execute(successCmd) shouldBe true
invoker.history.size shouldBe 1 invoker.history.size shouldBe 1
invoker.history(0) shouldBe successCmd invoker.history.head shouldBe successCmd
test("CommandInvoker.undo() returns false when currentIndex < 0"): test("undo redo and history trimming cover all command state transitions"):
val invoker = new CommandInvoker() {
invoker.undo() shouldBe false val invoker = new CommandInvoker()
invoker.undo() shouldBe false
invoker.canUndo shouldBe false
invoker.undo() shouldBe false
}
test("CommandInvoker.undo() returns false when empty history"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
invoker.canUndo shouldBe false val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.undo() shouldBe false val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.undo()
invoker.undo() shouldBe false
}
test("CommandInvoker.undo() returns false when currentIndex >= history size"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val failingUndoCmd = ConditionalFailCommand(shouldFailOnUndo = true)
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) invoker.execute(failingUndoCmd) shouldBe true
invoker.execute(cmd1) invoker.canUndo shouldBe true
invoker.execute(cmd2) invoker.undo() shouldBe false
invoker.undo() invoker.getCurrentIndex shouldBe 0
invoker.undo() }
invoker.undo()
test("CommandInvoker.undo() returns false when command.undo() fails"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true) val successUndoCmd = ConditionalFailCommand()
invoker.execute(failingCmd) shouldBe true invoker.execute(successUndoCmd) shouldBe true
invoker.canUndo shouldBe true invoker.undo() shouldBe true
invoker.undo() shouldBe false invoker.getCurrentIndex shouldBe -1
invoker.getCurrentIndex shouldBe 0 }
test("CommandInvoker.undo() returns true when command.undo() succeeds"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val successCmd = ConditionalFailCommand(shouldFailOnUndo = false) invoker.redo() shouldBe false
invoker.execute(successCmd) shouldBe true }
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
test("CommandInvoker.redo() returns false when nothing to redo"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
invoker.redo() shouldBe false val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
}
test("CommandInvoker.redo() returns false when at end of history"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) val redoFailCmd = ConditionalFailCommand()
invoker.canRedo shouldBe false invoker.execute(cmd1)
invoker.redo() shouldBe false invoker.execute(redoFailCmd)
invoker.undo()
invoker.canRedo shouldBe true
redoFailCmd.shouldFailOnExecute = true
invoker.redo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) invoker.execute(cmd) shouldBe true
invoker.execute(cmd1) invoker.undo() shouldBe true
invoker.execute(cmd2) invoker.redo() shouldBe true
invoker.canRedo shouldBe false invoker.getCurrentIndex shouldBe 0
invoker.redo() shouldBe false }
test("CommandInvoker.redo() returns false when command.execute() fails"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1) val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(redoFailCmd) invoker.execute(cmd1)
invoker.undo() invoker.execute(cmd2)
invoker.canRedo shouldBe true invoker.undo()
redoFailCmd.shouldFailOnExecute = true invoker.canRedo shouldBe true
invoker.redo() shouldBe false invoker.execute(cmd3)
invoker.getCurrentIndex shouldBe 0 invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
}
test("CommandInvoker.redo() returns true when command.execute() succeeds"): {
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.undo() shouldBe true val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
invoker.redo() shouldBe true val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.getCurrentIndex shouldBe 0 invoker.execute(cmd1)
invoker.execute(cmd2)
test("CommandInvoker.execute() discards redo history via while loop"): invoker.execute(cmd3)
val invoker = new CommandInvoker() invoker.execute(cmd4)
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) invoker.undo()
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) invoker.undo()
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) invoker.canRedo shouldBe true
invoker.execute(cmd1) val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
invoker.execute(cmd2) invoker.execute(newCmd)
invoker.undo() invoker.history.size shouldBe 3
invoker.canRedo shouldBe true invoker.canRedo shouldBe false
invoker.execute(cmd3) }
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
test("CommandInvoker.execute() discards multiple redo commands"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.execute(cmd3)
invoker.execute(cmd4)
invoker.undo()
invoker.undo()
invoker.canRedo shouldBe true
val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
invoker.execute(newCmd)
invoker.history.size shouldBe 3
invoker.canRedo shouldBe false
test("CommandInvoker.execute() with no redo history (while condition false)"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.canRedo shouldBe false
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd3)
invoker.history.size shouldBe 3
@@ -17,63 +17,31 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
previousContext = Some(GameContext.initial) previousContext = Some(GameContext.initial)
) )
test("CommandInvoker executes a command and adds it to history"): test("execute appends commands and updates index"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true invoker.execute(cmd) shouldBe true
invoker.history.size shouldBe 1 invoker.history.size shouldBe 1
invoker.getCurrentIndex shouldBe 0 invoker.getCurrentIndex shouldBe 0
test("CommandInvoker executes multiple commands in sequence"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd1) shouldBe true
invoker.execute(cmd2) shouldBe true invoker.execute(cmd2) shouldBe true
invoker.history.size shouldBe 2 invoker.history.size shouldBe 2
invoker.getCurrentIndex shouldBe 1 invoker.getCurrentIndex shouldBe 1
test("CommandInvoker.canUndo returns false when empty"): test("undo and redo update index and availability flags"):
val invoker = new CommandInvoker()
invoker.canUndo shouldBe false
test("CommandInvoker.canUndo returns true after execution"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.canUndo shouldBe false
invoker.execute(cmd) invoker.execute(cmd)
invoker.canUndo shouldBe true invoker.canUndo shouldBe true
test("CommandInvoker.undo decrements current index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.getCurrentIndex shouldBe 0
invoker.undo() shouldBe true invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1 invoker.getCurrentIndex shouldBe -1
test("CommandInvoker.canRedo returns true after undo"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo()
invoker.canRedo shouldBe true invoker.canRedo shouldBe true
test("CommandInvoker.redo re-executes a command"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo() shouldBe true
invoker.redo() shouldBe true invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0 invoker.getCurrentIndex shouldBe 0
test("CommandInvoker.canUndo returns false when at beginning"): test("clear removes full history and resets index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.undo()
invoker.canUndo shouldBe false
test("CommandInvoker clear removes all history"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) invoker.execute(cmd)
@@ -81,24 +49,7 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
invoker.history.size shouldBe 0 invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1 invoker.getCurrentIndex shouldBe -1
test("CommandInvoker discards all history when executing after undoing all"): test("execute after undo discards redo history"):
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
invoker.execute(cmd1)
invoker.execute(cmd2)
invoker.undo()
invoker.undo()
invoker.getCurrentIndex shouldBe -1
invoker.canRedo shouldBe true
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 1
invoker.history(0) shouldBe cmd3
invoker.getCurrentIndex shouldBe 0
test("CommandInvoker discards redo history when executing mid-history"):
val invoker = new CommandInvoker() val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5)) val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
@@ -111,6 +62,6 @@ class CommandInvokerTest extends AnyFunSuite with Matchers:
invoker.execute(cmd3) invoker.execute(cmd3)
invoker.canRedo shouldBe false invoker.canRedo shouldBe false
invoker.history.size shouldBe 2 invoker.history.size shouldBe 2
invoker.history(0) shouldBe cmd1 invoker.history.head shouldBe cmd1
invoker.history(1) shouldBe cmd3 invoker.history(1) shouldBe cmd3
invoker.getCurrentIndex shouldBe 1 invoker.getCurrentIndex shouldBe 1
@@ -6,37 +6,19 @@ import org.scalatest.matchers.should.Matchers
class CommandTest extends AnyFunSuite with Matchers: class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand can be created"): test("QuitCommand properties and behavior"):
val cmd = QuitCommand() val cmd = QuitCommand()
cmd shouldNot be(null) cmd shouldNot be(null)
test("QuitCommand execute returns true"):
val cmd = QuitCommand()
cmd.execute() shouldBe true cmd.execute() shouldBe true
test("QuitCommand undo returns false (cannot undo quit)"):
val cmd = QuitCommand()
cmd.undo() shouldBe false cmd.undo() shouldBe false
test("QuitCommand description"):
val cmd = QuitCommand()
cmd.description shouldBe "Quit game" cmd.description shouldBe "Quit game"
test("ResetCommand with no prior state"): test("ResetCommand behavior depends on previousContext"):
val cmd = ResetCommand() val noState = ResetCommand()
cmd.execute() shouldBe true noState.execute() shouldBe true
cmd.undo() shouldBe false noState.undo() shouldBe false
noState.description shouldBe "Reset board"
test("ResetCommand with prior state can undo"): val withState = ResetCommand(previousContext = Some(GameContext.initial))
val cmd = ResetCommand(previousContext = Some(GameContext.initial)) withState.execute() shouldBe true
cmd.execute() shouldBe true withState.undo() shouldBe true
cmd.undo() shouldBe true
test("ResetCommand with no context cannot undo"):
val cmd = ResetCommand(previousContext = None)
cmd.execute() shouldBe true
cmd.undo() shouldBe false
test("ResetCommand description"):
val cmd = ResetCommand()
cmd.description shouldBe "Reset board"
@@ -9,51 +9,31 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r) private def sq(f: File, r: Rank): Square = Square(f, r)
test("MoveCommand with no moveResult defaults to None"): test("MoveCommand defaults to empty optional state and false execute/undo"):
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4)) val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
cmd.moveResult shouldBe None cmd.moveResult shouldBe None
cmd.execute() shouldBe false
test("MoveCommand with no previousContext defaults to None"):
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
cmd.previousContext shouldBe None cmd.previousContext shouldBe None
cmd.execute() shouldBe false
cmd.undo() shouldBe false cmd.undo() shouldBe false
test("MoveCommand description is always returned"):
val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
cmd.description shouldBe "Move from e2 to e4" cmd.description shouldBe "Move from e2 to e4"
test("MoveCommand execute returns false when moveResult is None"): test("MoveCommand execute/undo succeed when state is present"):
val cmd = MoveCommand(from = sq(File.A, Rank.R1), to = sq(File.B, Rank.R3)) val executable = MoveCommand(
cmd.execute() shouldBe false
test("MoveCommand undo returns false when previousContext is None"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = None
)
cmd.undo() shouldBe false
test("MoveCommand execute returns true when moveResult is defined"):
val cmd = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)) moveResult = Some(MoveResult.Successful(GameContext.initial, None))
) )
cmd.execute() shouldBe true executable.execute() shouldBe true
test("MoveCommand undo returns true when previousContext is defined"): val undoable = MoveCommand(
val cmd = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)), moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial) previousContext = Some(GameContext.initial)
) )
cmd.undo() shouldBe true undoable.undo() shouldBe true
test("MoveCommand should be immutable - fields cannot be mutated after creation"): test("MoveCommand is immutable and preserves equality/hash semantics"):
val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4)) val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
val result = MoveResult.Successful(GameContext.initial, None) val result = MoveResult.Successful(GameContext.initial, None)
@@ -68,24 +48,23 @@ class MoveCommandTest extends AnyFunSuite with Matchers:
cmd2.moveResult shouldBe Some(result) cmd2.moveResult shouldBe Some(result)
cmd2.previousContext shouldBe Some(GameContext.initial) cmd2.previousContext shouldBe Some(GameContext.initial)
test("MoveCommand equals and hashCode respect immutability"): val eq1 = MoveCommand(
val cmd1 = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = None, moveResult = None,
previousContext = None previousContext = None
) )
val cmd2 = MoveCommand( val eq2 = MoveCommand(
from = sq(File.E, Rank.R2), from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4), to = sq(File.E, Rank.R4),
moveResult = None, moveResult = None,
previousContext = None previousContext = None
) )
cmd1 shouldBe cmd2 eq1 shouldBe eq2
cmd1.hashCode shouldBe cmd2.hashCode eq1.hashCode shouldBe eq2.hashCode
val hash1 = cmd1.hashCode val hash1 = eq1.hashCode
val hash2 = cmd1.hashCode val hash2 = eq1.hashCode
hash1 shouldBe hash2 hash1 shouldBe hash2
@@ -17,7 +17,7 @@ class GameEngineCoverageRegressionTest extends AnyFunSuite with Matchers:
private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] = private def captureEvents(engine: GameEngine): collection.mutable.ListBuffer[GameEvent] =
val events = collection.mutable.ListBuffer[GameEvent]() val events = collection.mutable.ListBuffer[GameEvent]()
engine.subscribe(new Observer { def onGameEvent(event: GameEvent): Unit = events += event }) engine.subscribe((event: GameEvent) => events += event)
events events
test("accessors expose redo availability and command history"): test("accessors expose redo availability and command history"):
@@ -141,6 +141,15 @@ class GameEngineCoverageRegressionTest extends AnyFunSuite with Matchers:
engine.replayMoves(List(normalMove), engine.context) shouldBe Right(()) engine.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove) engine.context.moves.lastOption shouldBe Some(normalMove)
test("replayMoves skips later moves after the first move triggers an error"):
val engine = new GameEngine()
val saved = engine.context
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4"))
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Promotion required for move e2e1")
engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"): test("normalMoveNotation handles missing source piece"):
val engine = new GameEngine() val engine = new GameEngine()
@@ -12,47 +12,31 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Observer wiring ──────────────────────────────────────────── // ── Observer wiring ────────────────────────────────────────────
test("subscribe adds observer to notification list"): test("observer subscribe and unsubscribe behavior"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
observer.hasEvent[MoveExecutedEvent] shouldBe true observer.hasEvent[MoveExecutedEvent] shouldBe true
val countBeforeUnsubscribe = observer.eventCount
test("unsubscribe removes observer from notification list"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
engine.unsubscribe(observer) engine.unsubscribe(observer)
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
observer.eventCount shouldBe 0 observer.eventCount shouldBe countBeforeUnsubscribe
// ── Initial state ────────────────────────────────────────────── // ── Initial state ──────────────────────────────────────────────
test("initial game context has standard starting position"): test("initial engine state is standard"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
engine.board.pieceAt(Square(File.E, Rank.R1)) shouldBe Some(Piece.WhiteKing) engine.board.pieceAt(Square(File.E, Rank.R1)) shouldBe Some(Piece.WhiteKing)
test("initial turn is white"):
val engine = EngineTestHelpers.makeEngine()
engine.turn shouldBe Color.White engine.turn shouldBe Color.White
// ── Quit command ────────────────────────────────────────────── // ── Quit command ──────────────────────────────────────────────
test("quit command does not error"): test("quit aliases and reset keep engine responsive"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
engine.processUserInput("quit") engine.processUserInput("quit")
// Quit just returns, no event
test("q alias for quit works"):
val engine = EngineTestHelpers.makeEngine()
engine.processUserInput("q") engine.processUserInput("q")
// Quit just returns, no event
// ── Reset command ──────────────────────────────────────────────
test("reset command returns game to initial position"):
val engine = EngineTestHelpers.makeEngine()
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.reset() engine.reset()
@@ -62,20 +46,16 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Turn toggling ────────────────────────────────────────────── // ── Turn toggling ──────────────────────────────────────────────
test("turn toggles to black after valid white move"): test("turn toggles across valid move sequence"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.turn shouldBe Color.Black engine.turn shouldBe Color.Black
test("turn toggles back to white after black move"):
val engine = EngineTestHelpers.makeEngine()
engine.processUserInput("e2e4")
engine.processUserInput("e7e5") engine.processUserInput("e7e5")
engine.turn shouldBe Color.White engine.turn shouldBe Color.White
// ── Invalid moves (minimal) ──────────────────────────────────── // ── Invalid moves (minimal) ────────────────────────────────────
test("move with no piece at source fails"): test("invalid move forms trigger InvalidMoveEvent and keep turn where relevant"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -85,20 +65,10 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
observer.hasEvent[InvalidMoveEvent] shouldBe true observer.hasEvent[InvalidMoveEvent] shouldBe true
engine.turn shouldBe Color.White // turn unchanged engine.turn shouldBe Color.White // turn unchanged
test("move with opponent piece fails"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e7e5") // try to move black pawn on white's turn engine.processUserInput("e7e5") // try to move black pawn on white's turn
observer.hasEvent[InvalidMoveEvent] shouldBe true observer.hasEvent[InvalidMoveEvent] shouldBe true
test("illegal move fails (pawn backward)"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.processUserInput("e5e4") // pawn backward engine.processUserInput("e5e4") // pawn backward
@@ -106,8 +76,15 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
// ── Undo/Redo ──────────────────────────────────────────────── // ── Undo/Redo ────────────────────────────────────────────────
test("undo restores previous position"): test("undo redo success and empty-history failures"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.undo()
observer.hasEvent[InvalidMoveEvent] shouldBe true
observer.clear()
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.undo() engine.undo()
@@ -115,37 +92,17 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
engine.board.pieceAt(Square(File.E, Rank.R2)) shouldBe Some(Piece.WhitePawn) engine.board.pieceAt(Square(File.E, Rank.R2)) shouldBe Some(Piece.WhitePawn)
engine.turn shouldBe Color.White engine.turn shouldBe Color.White
test("redo replays last undone move"):
val engine = EngineTestHelpers.makeEngine()
engine.processUserInput("e2e4")
engine.undo()
engine.redo() engine.redo()
engine.board.pieceAt(Square(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn) engine.board.pieceAt(Square(File.E, Rank.R4)) shouldBe Some(Piece.WhitePawn)
engine.turn shouldBe Color.Black engine.turn shouldBe Color.Black
observer.clear()
test("undo on empty history triggers InvalidMoveEvent"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.undo()
observer.hasEvent[InvalidMoveEvent] shouldBe true
test("redo on empty future history triggers InvalidMoveEvent"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.redo() engine.redo()
observer.hasEvent[InvalidMoveEvent] shouldBe true observer.hasEvent[InvalidMoveEvent] shouldBe true
// ── Fifty-move rule ──────────────────────────────────────────── // ── Fifty-move rule ────────────────────────────────────────────
test("FiftyMoveRuleAvailableEvent fired when half-move clock reaches 100"): test("fifty-move event and draw claim success/failure"):
val engine = EngineTestHelpers.makeEngine() val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver() val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -159,13 +116,6 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
observer.hasEvent[FiftyMoveRuleAvailableEvent] shouldBe true observer.hasEvent[FiftyMoveRuleAvailableEvent] shouldBe true
// ── Draw claiming ──────────────────────────────────────────────
test("draw claim succeeds when available"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Load position with sufficient move history for draw claim // Load position with sufficient move history for draw claim
EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 100 1") EngineTestHelpers.loadFen(engine, "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 100 1")
observer.clear() observer.clear()
@@ -174,12 +124,9 @@ class GameEngineScenarioTest extends AnyFunSuite with Matchers:
observer.hasEvent[DrawClaimedEvent] shouldBe true observer.hasEvent[DrawClaimedEvent] shouldBe true
test("draw claim fails when not available"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Initial position has no draw available // Initial position has no draw available
observer.clear()
engine.reset()
engine.processUserInput("draw") engine.processUserInput("draw")
observer.hasEvent[InvalidMoveEvent] shouldBe true observer.hasEvent[InvalidMoveEvent] shouldBe true
@@ -29,11 +29,10 @@ class FenExporterTest extends AnyFunSuite with Matchers:
moves = List.fill(moveCount)(dummyMove) moves = List.fill(moveCount)(dummyMove)
) )
test("export initial position to FEN"): test("exportGameContextToFen handles initial and typical developed position"):
val fen = FenExporter.gameContextToFen(GameContext.initial) FenExporter.gameContextToFen(GameContext.initial) shouldBe
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
test("export position after e4"):
val gameContext = context( val gameContext = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR", piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR",
turn = Color.Black, turn = Color.Black,
@@ -42,11 +41,11 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 0, halfMoveClock = 0,
moveCount = 0 moveCount = 0
) )
val fen = FenExporter.gameContextToFen(gameContext) FenExporter.gameContextToFen(gameContext) shouldBe
fen shouldBe "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1" "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
test("export position with no castling"): test("export handles castling rights variants and en-passant with counters"):
val gameContext = context( val noCastling = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
turn = Color.White, turn = Color.White,
castlingRights = CastlingRights.None, castlingRights = CastlingRights.None,
@@ -54,11 +53,10 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 0, halfMoveClock = 0,
moveCount = 0 moveCount = 0
) )
val fen = FenExporter.gameContextToFen(gameContext) FenExporter.gameContextToFen(noCastling) shouldBe
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1" "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
test("export position with partial castling"): val partialCastling = context(
val gameContext = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR", piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
turn = Color.White, turn = Color.White,
castlingRights = CastlingRights( castlingRights = CastlingRights(
@@ -71,11 +69,10 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 5, halfMoveClock = 5,
moveCount = 4 moveCount = 4
) )
val fen = FenExporter.gameContextToFen(gameContext) FenExporter.gameContextToFen(partialCastling) shouldBe
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3" "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
test("export position with en passant and move counts"): val withEnPassant = context(
val gameContext = context(
piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR", piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR",
turn = Color.White, turn = Color.White,
castlingRights = CastlingRights.All, castlingRights = CastlingRights.All,
@@ -83,8 +80,8 @@ class FenExporterTest extends AnyFunSuite with Matchers:
halfMoveClock = 2, halfMoveClock = 2,
moveCount = 4 moveCount = 4
) )
val fen = FenExporter.gameContextToFen(gameContext) FenExporter.gameContextToFen(withEnPassant) shouldBe
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3" "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
test("halfMoveClock round-trips through FEN export and import"): test("halfMoveClock round-trips through FEN export and import"):
val gameContext = GameContext( val gameContext = GameContext(
@@ -6,71 +6,43 @@ import org.scalatest.matchers.should.Matchers
class FenParserTest extends AnyFunSuite with Matchers: class FenParserTest extends AnyFunSuite with Matchers:
test("parseBoard: initial position places pieces correctly"): test("parseBoard parses canonical positions and supports round-trip"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
val board = FenParser.parseBoard(fen) val empty = "8/8/8/8/8/8/8/8"
board.map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn)) val partial = "8/8/4k3/8/4K3/8/8/8"
board.map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
test("parseBoard: empty board"): FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
val fen = "8/8/8/8/8/8/8/8" FenParser.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
val board = FenParser.parseBoard(fen) FenParser.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
board.get.pieces.size shouldBe 0 FenParser.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
test("parseBoard: partial position"): FenParser.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
val fen = "8/8/4k3/8/4K3/8/8/8" FenParser.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
val board = FenParser.parseBoard(fen)
board.map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
test("round-trip initial position"): test("parseFen parses full state for common valid inputs"):
val originalFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR" FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
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 =>
ctx.turn shouldBe Color.White ctx.turn shouldBe Color.White
ctx.castlingRights.whiteKingSide shouldBe true ctx.castlingRights.whiteKingSide shouldBe true
ctx.enPassantSquare shouldBe None ctx.enPassantSquare shouldBe None
ctx.halfMoveClock shouldBe 0 ctx.halfMoveClock shouldBe 0
) )
test("parseFen: position after e4"): FenParser.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
val context = FenParser.parseFen(fen)
context.fold(_ => fail(), ctx =>
ctx.turn shouldBe Color.Black ctx.turn shouldBe Color.Black
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3)) ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
) )
test("parseFen: no castling rights"): FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
val context = FenParser.parseFen(fen)
context.fold(_ => fail(), ctx =>
ctx.castlingRights.whiteKingSide shouldBe false ctx.castlingRights.whiteKingSide shouldBe false
ctx.castlingRights.blackQueenSide shouldBe false ctx.castlingRights.blackQueenSide shouldBe false
) )
test("parseFen: invalid color fails"): test("parseFen rejects invalid color and castling tokens"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1" FenParser.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
FenParser.parseFen(fen).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"): test("importGameContext returns Right for valid and Left for invalid FEN"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1"
FenParser.parseFen(fen).isLeft shouldBe true
test("importGameContext: valid FEN"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
FenParser.importGameContext(fen).isRight shouldBe true FenParser.importGameContext(fen).isRight shouldBe true
FenParser.importGameContext("invalid fen string").isLeft shouldBe true
test("importGameContext: invalid FEN"):
val invalidFen = "invalid fen string"
FenParser.importGameContext(invalidFen).isLeft shouldBe true
@@ -1,124 +1,65 @@
package de.nowchess.io.pgn package de.nowchess.io.pgn
import de.nowchess.api.board.{PieceType, *} import de.nowchess.api.board.*
import de.nowchess.api.move.{PromotionPiece, Move, MoveType}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
class PgnExporterTest extends AnyFunSuite with 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 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 val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
pgn.contains("[White \"A\"]") shouldBe true PgnExporter.exportGame(headers, moves).contains("1. e4") shouldBe true
pgn.contains("[Black \"B\"]") shouldBe true
}
test("export single move") { test("exportGame renders castling grouping and result markers"):
val headers = Map("Event" -> "Test", "White" -> "A", "Black" -> "B") PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))) should include("O-O")
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))) PgnExporter.exportGame(Map("Event" -> "Test"), List(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))) should include("O-O-O")
val pgn = PgnExporter.exportGame(headers, moves)
pgn.contains("1. e4") shouldBe true 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()),
test("export castling") { Move(Square(File.G, Rank.R1), Square(File.F, Rank.R3), MoveType.Normal())
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 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 val oneMove = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
pgn.contains("2. Nf3") shouldBe true 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") { test("exportGame handles promotion suffixes and normal move formatting"):
val moves = List(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false))) List(
val pgn = PgnExporter.exportGame(Map.empty, moves) 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") { test("exportGameContext preserves moves and default headers"):
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") {
val moves = List( val moves = List(
Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), 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(false)) Move(Square(File.E, Rank.R7), Square(File.E, Rank.R5), MoveType.Normal())
) )
val ctx = GameContext.initial.copy(moves = moves) val withMoves = PgnExporter.exportGameContext(GameContext.initial.copy(moves = moves))
val exported = PgnExporter.exportGameContext(ctx) withMoves.contains("e4") shouldBe true
withMoves.contains("e5") shouldBe true
exported.contains("e4") shouldBe true val empty = PgnExporter.exportGameContext(GameContext.initial)
exported.contains("e5") shouldBe true empty.contains("[Event") shouldBe true
} empty.contains("*") 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
}
@@ -9,186 +9,106 @@ import org.scalatest.matchers.should.Matchers
class PgnParserTest extends AnyFunSuite with Matchers: class PgnParserTest extends AnyFunSuite with Matchers:
test("parse PGN headers only"): test("parsePgn handles headers standard sequences captures castling and skipped tokens"):
val pgn = """[Event "Test Game"] val headerOnly = """[Event "Test Game"]
[White "Alice"] [White "Alice"]
[Black "Bob"] [Black "Bob"]
[Result "1-0"]""" [Result "1-0"]"""
val game = PgnParser.parsePgn(pgn) val onlyHeaders = PgnParser.parsePgn(headerOnly)
game.isDefined shouldBe true onlyHeaders.isDefined shouldBe true
game.get.headers("Event") shouldBe "Test Game" onlyHeaders.get.headers("Event") shouldBe "Test Game"
game.get.headers("White") shouldBe "Alice" onlyHeaders.get.headers("White") shouldBe "Alice"
test("parse simple game sequence"): val simple = PgnParser.parsePgn("""[Event "Test"]
val pgn = """[Event "Test"]
1. e4 e5 2. Nf3 Nc6""" 1. e4 e5 2. Nf3 Nc6""")
val game = PgnParser.parsePgn(pgn) simple.map(_.moves.length) shouldBe Some(4)
game.isDefined shouldBe true
game.get.moves.length shouldBe 4
test("parse move with capture"): val capture = PgnParser.parsePgn("""[Event "Test"]
val pgn = """[Event "Test"]
1. Nf3 e5 2. Nxe5""" 1. Nf3 e5 2. Nxe5""")
val game = PgnParser.parsePgn(pgn) capture.map(_.moves.length) shouldBe Some(3)
game.isDefined shouldBe true capture.get.moves(2).to shouldBe Square(File.E, Rank.R5)
game.get.moves.length shouldBe 3
game.get.moves(2).to shouldBe Square(File.E, Rank.R5)
test("parse kingside castling O-O"): val whiteKs = PgnParser.parsePgn("""[Event "Test"]
val pgn = """[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""" 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O""").get.moves.last
val game = PgnParser.parsePgn(pgn) whiteKs.moveType shouldBe MoveType.CastleKingside
game.isDefined shouldBe true whiteKs.from shouldBe Square(File.E, Rank.R1)
val lastMove = game.get.moves.last whiteKs.to shouldBe Square(File.G, Rank.R1)
lastMove.moveType shouldBe MoveType.CastleKingside
lastMove.from shouldBe Square(File.E, Rank.R1)
lastMove.to shouldBe Square(File.G, Rank.R1)
test("parse queenside castling O-O-O"): val whiteQs = PgnParser.parsePgn("""[Event "Test"]
val pgn = """[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""" 1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O""").get.moves.last
val game = PgnParser.parsePgn(pgn) whiteQs.moveType shouldBe MoveType.CastleQueenside
game.isDefined shouldBe true whiteQs.from shouldBe Square(File.E, Rank.R1)
val lastMove = game.get.moves.last whiteQs.to shouldBe Square(File.C, Rank.R1)
lastMove.moveType shouldBe MoveType.CastleQueenside
lastMove.from shouldBe Square(File.E, Rank.R1)
lastMove.to shouldBe Square(File.C, Rank.R1)
test("parse black kingside castling"): val blackKs = PgnParser.parsePgn("""[Event "Test"]
val pgn = """[Event "Test"]
1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""" 1. e4 e5 2. Nf3 Nf6 3. Bc4 Be7 4. O-O O-O""").get.moves.last
val game = PgnParser.parsePgn(pgn) blackKs.moveType shouldBe MoveType.CastleKingside
game.isDefined shouldBe true blackKs.from shouldBe Square(File.E, Rank.R8)
val blackCastle = game.get.moves.last
blackCastle.moveType shouldBe MoveType.CastleKingside
blackCastle.from shouldBe Square(File.E, Rank.R8)
test("parse black queenside castling"): val blackQs = PgnParser.parsePgn("""[Event "Test"]
val pgn = """[Event "Test"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""" 1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O O-O-O""").get.moves.last
val game = PgnParser.parsePgn(pgn) blackQs.moveType shouldBe MoveType.CastleQueenside
game.isDefined shouldBe true blackQs.from shouldBe Square(File.E, Rank.R8)
val lastMove = game.get.moves.last blackQs.to shouldBe Square(File.C, Rank.R8)
lastMove.moveType shouldBe MoveType.CastleQueenside
lastMove.from shouldBe Square(File.E, Rank.R8)
lastMove.to shouldBe Square(File.C, Rank.R8)
test("result tokens are skipped"): PgnParser.parsePgn("""[Event "Test"]
val pgn = """[Event "Test"]
1. e4 e5 1-0""" 1. e4 e5 1-0""").map(_.moves.length) shouldBe Some(2)
val game = PgnParser.parsePgn(pgn) PgnParser.parsePgn("""[Event "Test"]
game.isDefined shouldBe true
game.get.moves.length shouldBe 2
test("unrecognised tokens are skipped"): 1. e4 INVALID e5""").map(_.moves.length) shouldBe Some(2)
val pgn = """[Event "Test"]
1. e4 INVALID e5""" test("parseAlgebraicMove resolves pawn knight king and disambiguation cases"):
val game = PgnParser.parsePgn(pgn)
game.isDefined shouldBe true
game.get.moves.length shouldBe 2
test("parseAlgebraicMove: pawn to e4"):
val board = Board.initial val board = Board.initial
val result = PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White) PgnParser.parseAlgebraicMove("e4", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.E, Rank.R4)
result.isDefined shouldBe true PgnParser.parseAlgebraicMove("Nf3", GameContext.initial.withBoard(board), Color.White).get.to shouldBe Square(File.F, Rank.R3)
result.get.to shouldBe Square(File.E, Rank.R4)
test("parseAlgebraicMove: knight to f3"): val rookPieces: Map[Square, Piece] = Map(
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(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.H, 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.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
) )
val board = Board(pieces) val rankPieces: Map[Square, Piece] = Map(
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(
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook), Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
Square(File.A, Rank.R4) -> 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.R1) -> Piece(Color.White, PieceType.King),
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King) Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King)
) )
val board = Board(pieces) PgnParser.parseAlgebraicMove("Rad1", GameContext.initial.withBoard(Board(rookPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
val result = PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(board), Color.White) PgnParser.parseAlgebraicMove("R1a3", GameContext.initial.withBoard(Board(rankPieces)), Color.White).get.from shouldBe Square(File.A, Rank.R1)
result.isDefined shouldBe true
result.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"] val pgn = """[Event "Test"]
1. e4 e5""" 1. e4 e5"""
val result = PgnParser.importGameContext(pgn) PgnParser.importGameContext(pgn).isRight shouldBe true
result.isRight shouldBe true PgnParser.importGameContext("").isRight shouldBe true
test("importGameContext: invalid PGN"): test("parser edge cases: uppercase token hint chars and promotion mismatch handling"):
val invalidPgn = "" PgnParser.parseAlgebraicMove("E5", GameContext.initial, Color.White) shouldBe None
val result = PgnParser.importGameContext(invalidPgn) PgnParser.parseAlgebraicMove("N?f3", GameContext.initial, Color.White).get.to shouldBe Square(File.F, Rank.R3)
// 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"):
PgnParser.extractPromotion("e7e8=X") shouldBe None 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 board = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val result = PgnParser.parseAlgebraicMove("e8", GameContext.initial.withBoard(board), Color.White) PgnParser.parseAlgebraicMove("e8", GameContext.initial.withBoard(board), Color.White) shouldBe None
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)
@@ -7,113 +7,52 @@ import org.scalatest.matchers.should.Matchers
class PgnValidatorTest extends AnyFunSuite with 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 = val pgn =
"""[Event "Test"] """[Event "Test"]
[White "A"]
[Black "B"]
1. e4 e5 2. Nf3 Nc6 1. e4 e5 2. Nf3 Nc6
""" """
PgnParser.validatePgn(pgn) match val valid = PgnParser.validatePgn(pgn)
case Right(game) => valid.isRight shouldBe true
game.moves.length shouldBe 4 valid.toOption.get.moves.length shouldBe 4
game.headers("Event") shouldBe "Test" valid.toOption.get.moves.head.from shouldBe Square(File.E, Rank.R2)
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)")
test("validatePgn: empty move text returns Right with no moves"): val withResult = PgnParser.validatePgn("""[Event "Test"]
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"]
1. e4 e5 1-0 1. e4 e5 1-0
""" """)
PgnParser.validatePgn(pgn) match withResult.map(_.moves.length) shouldBe Right(2)
case Right(game) => game.moves.length shouldBe 2
case Left(err) => fail(s"Expected Right but got Left($err)")
test("validatePgn: valid kingside castling is accepted"): val kCastle = PgnParser.validatePgn("""[Event "Test"]
val pgn =
"""[Event "Test"]
1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O
""" """)
PgnParser.validatePgn(pgn) match kCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleKingside)
case Right(game) =>
game.moves.last.moveType shouldBe MoveType.CastleKingside
case Left(err) => fail(s"Expected Right but got Left($err)")
test("validatePgn: castling when not legal returns Left"): val qCastle = PgnParser.validatePgn("""[Event "Test"]
// 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"]
1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O 1. d4 d5 2. Nc3 Nc6 3. Bf4 Bf5 4. Qd2 Qd7 5. O-O-O
""" """)
PgnParser.validatePgn(pgn) match qCastle.map(_.moves.last.moveType) shouldBe Right(MoveType.CastleQueenside)
case Right(game) =>
game.moves.last.moveType shouldBe MoveType.CastleQueenside
case Left(err) => fail(s"Expected Right but got Left($err)")
test("validatePgn: disambiguation with two rooks is accepted"): test("validatePgn rejects impossible illegal and garbage tokens"):
val pieces: Map[Square, Piece] = Map( PgnParser.validatePgn("""[Event "Test"]
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: ambiguous move without disambiguation returns Left"): 1. Qd4
// Set up a position where two identical pieces can reach the same square """).isLeft shouldBe true
// 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 PgnParser.validatePgn("""[Event "Test"]
// This is hard to construct via PGN alone; verify via a known impossible disambiguation
val pgn = "[Event \"T\"]\n\n1. e4" 1. O-O
PgnParser.validatePgn(pgn).isRight shouldBe true """).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
+3 -1
View File
@@ -24,7 +24,9 @@ scala {
scoverage { scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!) scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(listOf( excludedPackages.set(listOf(
"de.nowchess.ui.gui" "de.nowchess.ui.gui",
"de.nowchess.ui.terminal",
"de.nowchess.ui.Main",
)) ))
} }
@@ -6,11 +6,24 @@ import org.scalatest.matchers.should.Matchers
class RendererAndUnicodeTest extends AnyFunSuite with Matchers: class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
test("unicode mapping covers representative white and black pieces"): test("unicode returns correct unicode character for all piece types"):
Piece(Color.White, PieceType.King).unicode shouldBe "\u2654" val pieces = Seq(
Piece(Color.White, PieceType.Queen).unicode shouldBe "\u2655" (Piece(Color.White, PieceType.King), "\u2654"),
Piece(Color.Black, PieceType.King).unicode shouldBe "\u265A" (Piece(Color.White, PieceType.Queen), "\u2655"),
Piece(Color.Black, PieceType.Pawn).unicode shouldBe "\u265F" (Piece(Color.White, PieceType.Rook), "\u2656"),
(Piece(Color.White, PieceType.Bishop), "\u2657"),
(Piece(Color.White, PieceType.Knight), "\u2658"),
(Piece(Color.White, PieceType.Pawn), "\u2659"),
(Piece(Color.Black, PieceType.King), "\u265A"),
(Piece(Color.Black, PieceType.Queen), "\u265B"),
(Piece(Color.Black, PieceType.Rook), "\u265C"),
(Piece(Color.Black, PieceType.Bishop), "\u265D"),
(Piece(Color.Black, PieceType.Knight), "\u265E"),
(Piece(Color.Black, PieceType.Pawn), "\u265F")
)
pieces.foreach { (piece, expected) =>
piece.unicode shouldBe expected
}
test("render outputs coordinates ranks ansi escapes and piece glyphs"): test("render outputs coordinates ranks ansi escapes and piece glyphs"):
val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen))) val board = Board(Map(Square(File.E, Rank.R4) -> Piece(Color.White, PieceType.Queen)))
@@ -24,4 +37,10 @@ class RendererAndUnicodeTest extends AnyFunSuite with Matchers:
Renderer.render(board) should include("\u2655") Renderer.render(board) should include("\u2655")
Renderer.render(board) should include("\u001b[") Renderer.render(board) should include("\u001b[")
test("render applies black piece color for black pieces"):
val board = Board(Map(Square(File.A, Rank.R1) -> Piece(Color.Black, PieceType.King)))
val rendered = Renderer.render(board)
rendered should include("\u265A") // Black king unicode
rendered should include("\u001b[30m") // ANSI black text color