From f0cd46f1324f0c32894ba9cf16ee7113c7a3638a Mon Sep 17 00:00:00 2001 From: shahdlala66 Date: Sun, 29 Mar 2026 21:08:07 +0200 Subject: [PATCH] test: more branch coverage for CommandInvoker --- .../command/CommandInvokerBranchTest.scala | 217 ++++++++++++++++++ .../nowchess/chess/command/CommandTest.scala | 53 +++++ .../chess/engine/GameEngineTest.scala | 175 +++++++++++++- 3 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala create mode 100644 modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala new file mode 100644 index 0000000..562fcc0 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala @@ -0,0 +1,217 @@ +package de.nowchess.chess.command + +import de.nowchess.api.board.{Square, File, Rank, Board, Color} +import de.nowchess.chess.logic.GameHistory +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class CommandInvokerBranchTest extends AnyFunSuite with Matchers: + + private def sq(f: File, r: Rank): Square = Square(f, r) + + // ──── Helper: Command that always fails ──── + private case class FailingCommand() extends Command: + override def execute(): Boolean = false + override def undo(): Boolean = false + override def description: String = "Failing command" + + // ──── Helper: Command that conditionally fails on undo or execute ──── + private case class ConditionalFailCommand(var shouldFailOnUndo: Boolean = false, var shouldFailOnExecute: Boolean = false) extends Command: + override def execute(): Boolean = !shouldFailOnExecute + override def undo(): Boolean = !shouldFailOnUndo + override def description: String = "Conditional fail" + + private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand = + val cmd = MoveCommand( + from = from, + to = to, + moveResult = if executeSucceeds then Some(MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)) else None, + previousBoard = Some(Board.initial), + previousHistory = Some(GameHistory.empty), + previousTurn = Some(Color.White) + ) + cmd + + // ──── BRANCH: execute() returns false ──── + test("CommandInvoker.execute() with failing command returns false"): + val invoker = new CommandInvoker() + val cmd = FailingCommand() + invoker.execute(cmd) shouldBe false + invoker.history.size shouldBe 0 + invoker.getCurrentIndex shouldBe -1 + + test("CommandInvoker.execute() does not add failed command to history"): + val invoker = new CommandInvoker() + val failingCmd = FailingCommand() + val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + + invoker.execute(failingCmd) shouldBe false + invoker.history.size shouldBe 0 + + invoker.execute(successCmd) shouldBe true + invoker.history.size shouldBe 1 + invoker.history(0) shouldBe successCmd + + // ──── BRANCH: undo() with invalid index (currentIndex < 0) ──── + test("CommandInvoker.undo() returns false when currentIndex < 0"): + val invoker = new CommandInvoker() + // currentIndex starts at -1 + invoker.undo() shouldBe false + + test("CommandInvoker.undo() returns false when empty history"): + val invoker = new CommandInvoker() + invoker.canUndo shouldBe false + invoker.undo() shouldBe false + + // ──── BRANCH: undo() with invalid index (currentIndex >= size) ──── + test("CommandInvoker.undo() returns false when currentIndex >= history size"): + 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) + // currentIndex now = 1, history.size = 2 + + invoker.undo() // currentIndex becomes 0 + invoker.undo() // currentIndex becomes -1 + invoker.undo() // currentIndex still -1, should fail + + // ──── BRANCH: undo() command returns false ──── + test("CommandInvoker.undo() returns false when command.undo() fails"): + val invoker = new CommandInvoker() + val failingCmd = ConditionalFailCommand(shouldFailOnUndo = true) + + invoker.execute(failingCmd) shouldBe true + invoker.canUndo shouldBe true + + invoker.undo() shouldBe false + // Index should not change when undo fails + invoker.getCurrentIndex shouldBe 0 + + test("CommandInvoker.undo() returns true when command.undo() succeeds"): + val invoker = new CommandInvoker() + val successCmd = ConditionalFailCommand(shouldFailOnUndo = false) + + invoker.execute(successCmd) shouldBe true + invoker.undo() shouldBe true + invoker.getCurrentIndex shouldBe -1 + + // ──── BRANCH: redo() with invalid index (currentIndex + 1 >= size) ──── + test("CommandInvoker.redo() returns false when nothing to redo"): + val invoker = new CommandInvoker() + invoker.redo() shouldBe false + + test("CommandInvoker.redo() returns false when at end of history"): + val invoker = new CommandInvoker() + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + + invoker.execute(cmd) + // currentIndex = 0, history.size = 1 + invoker.canRedo shouldBe false + invoker.redo() shouldBe false + + test("CommandInvoker.redo() returns false when currentIndex + 1 >= size"): + 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) + // currentIndex = 1, size = 2, currentIndex + 1 = 2, so 2 < 2 is false + invoker.canRedo shouldBe false + invoker.redo() shouldBe false + + // ──── BRANCH: redo() command returns false ──── + test("CommandInvoker.redo() returns false when command.execute() fails"): + val invoker = new CommandInvoker() + val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + val redoFailCmd = ConditionalFailCommand(shouldFailOnExecute = false) // Succeeds on first execute + + invoker.execute(cmd1) + invoker.execute(redoFailCmd) // Succeeds and added to history + + invoker.undo() + // currentIndex = 0, redoFailCmd is at index 1 + invoker.canRedo shouldBe true + + // Now modify to fail on next execute (redo) + redoFailCmd.shouldFailOnExecute = true + invoker.redo() shouldBe false + // currentIndex should not change + invoker.getCurrentIndex shouldBe 0 + + test("CommandInvoker.redo() returns true when command.execute() succeeds"): + val invoker = new CommandInvoker() + val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4)) + + invoker.execute(cmd) shouldBe true + invoker.undo() shouldBe true + invoker.redo() shouldBe true + invoker.getCurrentIndex shouldBe 0 + + // ──── BRANCH: execute() with redo history discarding (while loop) ──── + test("CommandInvoker.execute() discards redo history via while loop"): + 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) + // currentIndex = 1, size = 2 + + invoker.undo() + // currentIndex = 0, size = 2 + // Redo history exists: cmd2 is at index 1 + invoker.canRedo shouldBe true + + invoker.execute(cmd3) + // while loop should discard cmd2 + 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) + // currentIndex = 3, size = 4 + + invoker.undo() + invoker.undo() + // currentIndex = 1, size = 4 + // Redo history: cmd3 (idx 2), cmd4 (idx 3) + invoker.canRedo shouldBe true + + val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4)) + invoker.execute(newCmd) + // While loop should discard indices 2 and 3 (cmd3 and cmd4) + invoker.history.size shouldBe 3 + invoker.canRedo shouldBe false + + // ──── BRANCH: execute() with no redo history to discard ──── + 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) + // currentIndex = 1, size = 2 + // currentIndex < size - 1 is 1 < 1 which is false, so while loop doesn't run + + invoker.canRedo shouldBe false + + val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4)) + invoker.execute(cmd3) // While loop condition should be false, no iterations + invoker.history.size shouldBe 3 + +end CommandInvokerBranchTest diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala new file mode 100644 index 0000000..be34e2e --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala @@ -0,0 +1,53 @@ +package de.nowchess.chess.command + +import de.nowchess.api.board.{Board, Color} +import de.nowchess.chess.logic.GameHistory +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class CommandTest extends AnyFunSuite with Matchers: + + test("QuitCommand can be created"): + val cmd = QuitCommand() + cmd shouldNot be(null) + + test("QuitCommand execute returns true"): + val cmd = QuitCommand() + cmd.execute() shouldBe true + + test("QuitCommand undo returns false (cannot undo quit)"): + val cmd = QuitCommand() + cmd.undo() shouldBe false + + test("QuitCommand description"): + val cmd = QuitCommand() + cmd.description shouldBe "Quit game" + + test("ResetCommand with no prior state"): + val cmd = ResetCommand() + cmd.execute() shouldBe true + cmd.undo() shouldBe false + + test("ResetCommand with prior state can undo"): + val cmd = ResetCommand( + previousBoard = Some(Board.initial), + previousHistory = Some(GameHistory.empty), + previousTurn = Some(Color.White) + ) + cmd.execute() shouldBe true + cmd.undo() shouldBe true + + test("ResetCommand with partial state cannot undo"): + val cmd = ResetCommand( + previousBoard = Some(Board.initial), + previousHistory = None, // missing + previousTurn = Some(Color.White) + ) + cmd.execute() shouldBe true + cmd.undo() shouldBe false + + test("ResetCommand description"): + val cmd = ResetCommand() + cmd.description shouldBe "Reset board" + +end CommandTest diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala index 4798b28..a79fa6b 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineTest.scala @@ -3,7 +3,7 @@ package de.nowchess.chess.engine import scala.collection.mutable import de.nowchess.api.board.{Board, Color} import de.nowchess.chess.logic.GameHistory -import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent} +import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent} import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -96,6 +96,179 @@ class GameEngineTest extends AnyFunSuite with Matchers: t.join() observer.events.size shouldBe 1 + test("GameEngine canUndo returns false initially"): + val engine = new GameEngine() + engine.canUndo shouldBe false + + test("GameEngine canUndo returns true after move"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + engine.canUndo shouldBe true + + test("GameEngine canRedo returns false initially"): + val engine = new GameEngine() + engine.canRedo shouldBe false + + test("GameEngine undo restores previous state"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + val boardAfterMove = engine.board + engine.undo() + engine.board shouldBe Board.initial + engine.turn shouldBe Color.White + + test("GameEngine undo notifies observers"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + val observer = new MockObserver() + engine.subscribe(observer) + observer.events.clear() + engine.undo() + observer.events.size shouldBe 1 + observer.events.head shouldBe a[BoardResetEvent] + + test("GameEngine redo replays undone move"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + val boardAfterMove = engine.board + engine.undo() + engine.redo() + engine.board shouldBe boardAfterMove + engine.turn shouldBe Color.Black + + test("GameEngine canUndo false when nothing to undo"): + val engine = new GameEngine() + engine.canUndo shouldBe false + engine.processUserInput("e2e4") + engine.undo() + engine.canUndo shouldBe false + + test("GameEngine canRedo true after undo"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + engine.undo() + engine.canRedo shouldBe true + + test("GameEngine canRedo false after redo"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + engine.undo() + engine.redo() + engine.canRedo shouldBe false + + test("GameEngine undo on empty history sends invalid event"): + val engine = new GameEngine() + val observer = new MockObserver() + engine.subscribe(observer) + engine.undo() + observer.events.size shouldBe 1 + observer.events.head shouldBe a[InvalidMoveEvent] + + test("GameEngine redo on empty redo sends invalid event"): + val engine = new GameEngine() + val observer = new MockObserver() + engine.subscribe(observer) + engine.redo() + observer.events.size shouldBe 1 + observer.events.head shouldBe a[InvalidMoveEvent] + + test("GameEngine undo via processUserInput"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + val boardAfterMove = engine.board + engine.processUserInput("undo") + engine.board shouldBe Board.initial + + test("GameEngine redo via processUserInput"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + val boardAfterMove = engine.board + engine.processUserInput("undo") + engine.processUserInput("redo") + engine.board shouldBe boardAfterMove + + test("GameEngine handles empty input"): + val engine = new GameEngine() + val observer = new MockObserver() + engine.subscribe(observer) + engine.processUserInput("") + observer.events.size shouldBe 1 + observer.events.head shouldBe a[InvalidMoveEvent] + + test("GameEngine multiple undo/redo sequence"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + engine.processUserInput("e7e5") + engine.processUserInput("g1f3") + + engine.turn shouldBe Color.Black + + engine.undo() + engine.turn shouldBe Color.White + + engine.undo() + engine.turn shouldBe Color.Black + + engine.undo() + engine.turn shouldBe Color.White + engine.board shouldBe Board.initial + + test("GameEngine redo after multiple undos"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + engine.processUserInput("e7e5") + engine.processUserInput("g1f3") + + engine.undo() + engine.undo() + engine.undo() + + engine.redo() + engine.turn shouldBe Color.Black + + engine.redo() + engine.turn shouldBe Color.White + + engine.redo() + engine.turn shouldBe Color.Black + + test("GameEngine new move after undo clears redo history"): + val engine = new GameEngine() + engine.processUserInput("e2e4") + engine.processUserInput("e7e5") + engine.undo() + engine.canRedo shouldBe true + + engine.processUserInput("e7e6") // Different move + engine.canRedo shouldBe false + + test("GameEngine command history tracking"): + val engine = new GameEngine() + engine.commandHistory.size shouldBe 0 + + engine.processUserInput("e2e4") + engine.commandHistory.size shouldBe 1 + + engine.processUserInput("e7e5") + engine.commandHistory.size shouldBe 2 + + test("GameEngine quit input"): + val engine = new GameEngine() + val observer = new MockObserver() + engine.subscribe(observer) + val initialEvents = observer.events.size + engine.processUserInput("quit") + // quit should not produce an event + observer.events.size shouldBe initialEvents + + test("GameEngine quit via q"): + val engine = new GameEngine() + val observer = new MockObserver() + engine.subscribe(observer) + val initialEvents = observer.events.size + engine.processUserInput("q") + observer.events.size shouldBe initialEvents + // Mock Observer for testing private class MockObserver extends Observer: val events = mutable.ListBuffer[GameEvent]()