feat: true-microservices (#40)

Reviewed-on: #40
This commit is contained in:
2026-04-29 22:06:01 +02:00
parent 67511fc649
commit 590924254e
328 changed files with 10672 additions and 2939 deletions
@@ -0,0 +1,24 @@
quarkus:
grpc:
clients:
rule-grpc:
host: localhost
port: 9082
io-grpc:
host: localhost
port: 9081
rest-client:
store-service:
url: http://localhost:8085
nowchess:
internal:
secret: test-secret
auth:
enabled: false
coordinator:
enabled: false
redis:
host: localhost
port: 6379
prefix: test-core
@@ -1,153 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
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)
private case class FailingCommand() extends Command:
override def execute(): Boolean = false
override def undo(): Boolean = false
override def description: String = "Failing command"
private class ConditionalFailCommand(
initialShouldFailOnUndo: Boolean = false,
initialShouldFailOnExecute: Boolean = false,
) extends Command:
val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
override def execute(): Boolean = !shouldFailOnExecute.get()
override def undo(): Boolean = !shouldFailOnUndo.get()
override def description: String = "Conditional fail"
private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
previousContext = Some(GameContext.initial),
)
test("execute rejects failing commands and keeps history unchanged"):
val invoker = new CommandInvoker()
val cmd = FailingCommand()
invoker.execute(cmd) shouldBe false
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
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.head shouldBe successCmd
test("undo redo and history trimming cover all command state transitions"):
{
val invoker = new CommandInvoker()
invoker.undo() shouldBe false
invoker.canUndo shouldBe false
invoker.undo() shouldBe 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.undo()
invoker.undo()
invoker.undo() shouldBe false
}
{
val invoker = new CommandInvoker()
val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true)
invoker.execute(failingUndoCmd) shouldBe true
invoker.canUndo shouldBe true
invoker.undo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
{
val invoker = new CommandInvoker()
val successUndoCmd = ConditionalFailCommand()
invoker.execute(successUndoCmd) shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
}
{
val invoker = new CommandInvoker()
invoker.redo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd)
invoker.canRedo shouldBe false
invoker.redo() shouldBe false
}
{
val invoker = new CommandInvoker()
val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
val redoFailCmd = ConditionalFailCommand()
invoker.execute(cmd1)
invoker.execute(redoFailCmd)
invoker.undo()
invoker.canRedo shouldBe true
redoFailCmd.shouldFailOnExecute.set(true)
invoker.redo() shouldBe false
invoker.getCurrentIndex shouldBe 0
}
{
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
}
{
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.canRedo shouldBe true
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history(1) shouldBe cmd3
}
{
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
}
@@ -1,67 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandInvokerTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
private def createMoveCommand(from: Square, to: Square): MoveCommand =
MoveCommand(
from = from,
to = to,
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial),
)
test("execute appends commands and updates index"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.execute(cmd) shouldBe true
invoker.history.size shouldBe 1
invoker.getCurrentIndex shouldBe 0
val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
invoker.execute(cmd2) shouldBe true
invoker.history.size shouldBe 2
invoker.getCurrentIndex shouldBe 1
test("undo and redo update index and availability flags"):
val invoker = new CommandInvoker()
val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
invoker.canUndo shouldBe false
invoker.execute(cmd)
invoker.canUndo shouldBe true
invoker.undo() shouldBe true
invoker.getCurrentIndex shouldBe -1
invoker.canRedo shouldBe true
invoker.redo() shouldBe true
invoker.getCurrentIndex shouldBe 0
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.clear()
invoker.history.size shouldBe 0
invoker.getCurrentIndex shouldBe -1
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.getCurrentIndex shouldBe 0
invoker.canRedo shouldBe true
invoker.execute(cmd3)
invoker.canRedo shouldBe false
invoker.history.size shouldBe 2
invoker.history.head shouldBe cmd1
invoker.history(1) shouldBe cmd3
invoker.getCurrentIndex shouldBe 1
@@ -1,23 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class CommandTest extends AnyFunSuite with Matchers:
test("QuitCommand properties and behavior"):
val cmd = QuitCommand()
cmd.execute() shouldBe true
cmd.undo() shouldBe false
cmd.description shouldBe "Quit game"
test("ResetCommand behavior depends on previousContext"):
val noState = ResetCommand()
noState.execute() shouldBe true
noState.undo() shouldBe false
noState.description shouldBe "Reset board"
val withState = ResetCommand(previousContext = Some(GameContext.initial))
withState.execute() shouldBe true
withState.undo() shouldBe true
@@ -1,70 +0,0 @@
package de.nowchess.chess.command
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.game.GameContext
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class MoveCommandTest extends AnyFunSuite with Matchers:
private def sq(f: File, r: Rank): Square = Square(f, r)
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))
cmd.moveResult shouldBe None
cmd.previousContext shouldBe None
cmd.execute() shouldBe false
cmd.undo() shouldBe false
cmd.description shouldBe "Move from e2 to e4"
test("MoveCommand execute/undo succeed when state is present"):
val executable = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
)
executable.execute() shouldBe true
val undoable = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
previousContext = Some(GameContext.initial),
)
undoable.undo() shouldBe true
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 result = MoveResult.Successful(GameContext.initial, None)
val cmd2 = cmd1.copy(
moveResult = Some(result),
previousContext = Some(GameContext.initial),
)
cmd1.moveResult shouldBe None
cmd1.previousContext shouldBe None
cmd2.moveResult shouldBe Some(result)
cmd2.previousContext shouldBe Some(GameContext.initial)
val eq1 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None,
)
val eq2 = MoveCommand(
from = sq(File.E, Rank.R2),
to = sq(File.E, Rank.R4),
moveResult = None,
previousContext = None,
)
eq1 shouldBe eq2
eq1.hashCode shouldBe eq2.hashCode
val hash1 = eq1.hashCode
val hash2 = eq1.hashCode
hash1 shouldBe hash2
@@ -0,0 +1,17 @@
package de.nowchess.chess.config
import io.quarkus.redis.datasource.RedisDataSource
import jakarta.annotation.Priority
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Alternative
import jakarta.enterprise.inject.Produces
import org.mockito.Mockito
@Alternative
@Priority(1)
@ApplicationScoped
class MockRedisDataSourceProducer:
@Produces
@ApplicationScoped
def produceRedisDataSource(): RedisDataSource =
Mockito.mock(classOf[RedisDataSource], Mockito.RETURNS_DEEP_STUBS)
@@ -0,0 +1,155 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.{
ClockState,
CorrespondenceClockState,
DrawReason,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.time.Instant
import java.time.temporal.ChronoUnit
class GameEngineClockTest extends AnyFunSuite with Matchers:
private def makeClockEngine(tc: TimeControl): GameEngine =
new GameEngine(ruleSet = DefaultRules, timeControl = tc)
// ── Unlimited ─────────────────────────────────────────────────────────────
test("Unlimited time control: no clock state"):
val engine = makeClockEngine(TimeControl.Unlimited)
engine.currentClockState shouldBe None
// ── Live clock initialisation ─────────────────────────────────────────────
test("Clock(300,3) initialises both sides to 300,000ms"):
val engine = makeClockEngine(TimeControl.Clock(300, 3))
engine.currentClockState match
case Some(cs: LiveClockState) =>
cs.whiteRemainingMs shouldBe 300_000L
cs.blackRemainingMs shouldBe 300_000L
cs.incrementMs shouldBe 3_000L
cs.activeColor shouldBe Color.White
case other => fail(s"Expected Some(LiveClockState), got $other")
// ── Clock advances after move ─────────────────────────────────────────────
test("After White move, activeColor flips to Black and white time decreases"):
val engine = makeClockEngine(TimeControl.Clock(300, 3))
engine.processUserInput("e2e4")
engine.currentClockState match
case Some(cs: LiveClockState) =>
cs.activeColor shouldBe Color.Black
cs.whiteRemainingMs should be < 300_000L + 3_000L
cs.blackRemainingMs shouldBe 300_000L
case other => fail(s"Expected Some(LiveClockState), got $other")
// ── Time flag via injection ───────────────────────────────────────────────
test("TimeFlagEvent fires and result is Win(opponent) when White flags on move"):
val engine = makeClockEngine(TimeControl.Clock(300, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Inject nearly-exhausted clock: White has 1ms, will flag on move
val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
engine.injectClockState(Some(expiredClock))
engine.processUserInput("e2e4")
observer.hasEvent[TimeFlagEvent] shouldBe true
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
engine.context.result shouldBe Some(GameResult.Win(Color.Black, WinReason.TimeControl))
test("TimeFlagEvent fires and result is Win(Black) when Black flags on move"):
val engine = makeClockEngine(TimeControl.Clock(300, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
observer.clear()
val expiredClock = LiveClockState(300_000L, 1L, 0L, Instant.now().minusSeconds(10), Color.Black)
engine.injectClockState(Some(expiredClock))
engine.processUserInput("e7e5")
observer.hasEvent[TimeFlagEvent] shouldBe true
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.Black)
engine.context.result shouldBe Some(GameResult.Win(Color.White, WinReason.TimeControl))
test("Flag with insufficient material gives Draw(InsufficientMaterial)"):
// King vs King — White flags but Black can't mate
// White king e4, Black king e6: e4d3 is a legal move (not adjacent to e6)
val engine = makeClockEngine(TimeControl.Clock(300, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
EngineTestHelpers.loadFen(engine, "8/8/4k3/8/4K3/8/8/8 w - - 0 1")
observer.clear()
val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
engine.injectClockState(Some(expiredClock))
engine.processUserInput("e4d3")
observer.hasEvent[TimeFlagEvent] shouldBe true
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial))
// ── Correspondence clock ──────────────────────────────────────────────────
test("Correspondence(3): after move, deadline is ~3 days from move time"):
val engine = makeClockEngine(TimeControl.Correspondence(3))
val before = Instant.now()
engine.processUserInput("e2e4")
val after = Instant.now()
engine.currentClockState match
case Some(cs: CorrespondenceClockState) =>
val expectedMin = before.plus(3L, ChronoUnit.DAYS)
val expectedMax = after.plus(3L, ChronoUnit.DAYS)
cs.moveDeadline.isAfter(expectedMin.minusSeconds(1)) shouldBe true
cs.moveDeadline.isBefore(expectedMax.plusSeconds(1)) shouldBe true
cs.activeColor shouldBe Color.Black
case other => fail(s"Expected Some(CorrespondenceClockState), got $other")
test("Correspondence flag fires TimeFlagEvent when move past deadline"):
val engine = makeClockEngine(TimeControl.Correspondence(3))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Inject expired deadline
val expired = CorrespondenceClockState(Instant.now().minusSeconds(60), 3, Color.White)
engine.injectClockState(Some(expired))
engine.processUserInput("e2e4")
observer.hasEvent[TimeFlagEvent] shouldBe true
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
// ── reset() restarts clock ────────────────────────────────────────────────
test("reset() restarts clock to full time"):
val engine = makeClockEngine(TimeControl.Clock(300, 3))
engine.processUserInput("e2e4")
engine.reset()
engine.currentClockState match
case Some(cs: LiveClockState) =>
cs.whiteRemainingMs shouldBe 300_000L
cs.blackRemainingMs shouldBe 300_000L
cs.activeColor shouldBe Color.White
case other => fail(s"Expected Some(LiveClockState), got $other")
// ── Passive expiry via scheduler ──────────────────────────────────────────
test("Scheduler fires TimeFlagEvent when active player's clock expires passively"):
// Scheduler starts on engine creation, so TimeFlagEvent fires without a move being made
val engine = new GameEngine(ruleSet = DefaultRules, timeControl = TimeControl.Clock(1, 0))
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
Thread.sleep(1500)
observer.hasEvent[TimeFlagEvent] shouldBe true
@@ -202,7 +202,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.White)
observer.events.clear()
engine.resign(Color.Black)
engine.resign()
// Try to accept the now-cleared draw offer
observer.events.clear()
@@ -222,7 +222,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.White)
observer.events.clear()
engine.resign(Color.Black)
engine.resign()
// Try to accept the now-cleared draw offer
observer.events.clear()
@@ -3,7 +3,8 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer}
import de.nowchess.api.error.GameError
import de.nowchess.api.io.GameContextImport
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
@@ -20,15 +21,6 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.subscribe((event: GameEvent) => events += event)
events
test("accessors expose redo availability and command history"):
val engine = new GameEngine(ruleSet = DefaultRules)
engine.canRedo shouldBe false
engine.commandHistory shouldBe empty
engine.processUserInput("e2e4")
engine.commandHistory.nonEmpty shouldBe true
test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine)
@@ -58,9 +50,9 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val engine = new GameEngine(ruleSet = DefaultRules)
val failingImporter = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
def importGameContext(input: String): Either[GameError, GameContext] = Left(GameError.ParseError("boom"))
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
engine.loadGame(failingImporter, "ignored") shouldBe Left(GameError.ParseError("boom"))
test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine(ruleSet = DefaultRules)
@@ -71,26 +63,11 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.loadPosition(target)
engine.context shouldBe target
engine.commandHistory shouldBe empty
events.lastOption.exists {
case _: de.nowchess.chess.observer.BoardResetEvent => true
case _ => false
} shouldBe true
test("redo event includes captured piece description when replaying a capture"):
val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine)
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
events.clear()
engine.processUserInput("a1h1")
engine.processUserInput("undo")
engine.processUserInput("redo")
val redo = events.collectFirst { case e: MoveRedoneEvent => e }
redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
test("loadGame replay handles promotion moves when pending promotion exists"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
@@ -109,7 +86,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val engine = new GameEngine(ruleSet = permissiveRules)
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
engine.loadGame(importer, "ignored") shouldBe Right(())
@@ -134,13 +111,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val saved = engine.context
val importer = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
val result = engine.loadGame(importer, "ignored")
result.isLeft shouldBe true
result.left.toOption.get should include("Illegal move")
result shouldBe Left(GameError.IllegalMove)
engine.context shouldBe saved
test("loadGame replay executes non-promotion moves through default replay branch"):
@@ -156,7 +132,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
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("Illegal move.")
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left(GameError.IllegalMove)
engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"):
@@ -1,6 +1,7 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
import de.nowchess.api.game.WinReason.Checkmate
import de.nowchess.api.game.{DrawReason, GameResult}
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
@@ -23,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("d8h4")
observer.hasEvent[CheckmateEvent] shouldBe true
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
engine.context.result shouldBe Some(GameResult.Win(Color.Black, Checkmate))
test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine()
@@ -43,7 +44,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
val evt = observer.getEvent[CheckmateEvent]
evt.isDefined shouldBe true
evt.get.winner shouldBe Color.White
engine.context.result shouldBe Some(GameResult.Win(Color.White))
engine.context.result shouldBe Some(GameResult.Win(Color.White, Checkmate))
// ── Stalemate ───────────────────────────────────────────────────
@@ -1,9 +1,12 @@
package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.board.Color.{Black, White}
import de.nowchess.api.game.GameResult
import de.nowchess.api.game.WinReason.Resignation
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer, ResignEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -15,13 +18,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
val observer = new ResignMockObserver()
engine.subscribe(observer)
engine.resign(Color.White)
engine.resign(White)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.White
event.context.result shouldBe Some(GameResult.Win(Color.Black))
event.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
case other =>
fail(s"Expected ResignEvent, but got $other")
@@ -30,13 +33,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
val observer = new ResignMockObserver()
engine.subscribe(observer)
engine.resign(Color.Black)
engine.resign(Black)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.Black
event.context.result shouldBe Some(GameResult.Win(Color.White))
event.context.result shouldBe Some(GameResult.Win(Color.White, Resignation))
case other =>
fail(s"Expected ResignEvent, but got $other")
@@ -54,7 +57,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
// Try to resign
observer.events.clear()
engine.resign(Color.White)
engine.resign()
// Should get InvalidMoveEvent with GameAlreadyOver reason
observer.events.length shouldBe 1
@@ -71,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
engine.resign()
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
engine.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
test("resign() without color does nothing when game already over"):
val engine = new GameEngine(ruleSet = DefaultRules)
@@ -1,266 +0,0 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.{BotParticipant, GameContext, Human}
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.bot.{BotController, BotDifficulty}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
private class NoMoveBot extends Bot:
def name: String = "nomove"
def nextMove(context: GameContext): Option[Move] = None
private class FixedMoveBot(move: Move) extends Bot:
def name: String = "fixed"
def nextMove(context: GameContext): Option[Move] = Some(move)
class GameEngineWithBotTest extends AnyFunSuite with Matchers:
test("GameEngine can play against a ClassicalBot"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
// Collect events
val moveCount = new AtomicInteger(0)
val checkmateDetected = new AtomicBoolean(false)
val gameEnded = new AtomicBoolean(false)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent =>
moveCount.incrementAndGet()
case _: CheckmateEvent =>
checkmateDetected.set(true)
gameEnded.set(true)
case _: DrawEvent =>
gameEnded.set(true)
case _ => ()
engine.subscribe(observer)
// Play a few moves: e2e4, then let the bot respond
engine.processUserInput("e2e4")
// Wait a bit for the bot to respond asynchronously
Thread.sleep(5000)
// White should have moved, then Black (bot) should have responded
moveCount.get() should be >= 2
test("BotController can list and retrieve bots"):
val bots = BotController.listBots
bots should contain("easy")
bots should contain("medium")
bots should contain("hard")
bots should contain("expert")
BotController.getBot("easy") should not be None
BotController.getBot("medium") should not be None
BotController.getBot("hard") should not be None
BotController.getBot("expert") should not be None
BotController.getBot("unknown") should be(None)
test("GameEngine handles bot with different difficulty"):
val hardBot = BotController.getBot("hard").get
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(hardBot)),
)
engine.turn should equal(Color.White)
val movesMade = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// White moves
engine.processUserInput("d2d4")
Thread.sleep(500) // Wait for bot response
// At least white moved, possibly black also responded
movesMade.get() should be >= 1
test("GameEngine plays valid bot moves"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val moveCount = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => moveCount.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// Play a normal move
engine.processUserInput("e2e4")
Thread.sleep(1000)
// The game should have progressed with at least one move
moveCount.get() should be >= 1
// Game should not be ended (checkmate/stalemate)
engine.context.moves.nonEmpty should be(true)
test("startGame triggers bot when the starting player is a bot"):
val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val movesMade = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
movesMade.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"):
val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal())
val bot = new FixedMoveBot(illegalMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"):
val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal())
val bot = new FixedMoveBot(invalidMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("handleBotNoMove fires CheckmateEvent when position is checkmate"):
// White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R2) -> Piece.BlackQueen,
Square(File.B, Rank.R8) -> Piece.BlackRook,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val checkmateCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => checkmateCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
checkmateCount.get() should be >= 1
test("handleBotNoMove fires DrawEvent when position is stalemate"):
// White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2)
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R3) -> Piece.BlackQueen,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val drawCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: DrawEvent => drawCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
drawCount.get() should be >= 1
test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"):
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val unexpectedEvents = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => unexpectedEvents.incrementAndGet()
case _: DrawEvent => unexpectedEvents.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
unexpectedEvents.get() shouldBe 0
@@ -1,87 +1,31 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.api.move.{MoveType, PromotionPiece}
import de.nowchess.api.move.MoveType
import de.nowchess.chess.config.JacksonConfig
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonSerializersTest extends AnyFunSuite with Matchers:
private val mapper: ObjectMapper =
val m = new ObjectMapper()
val mod = new SimpleModule()
m.registerModule(DefaultScalaModule)
mod.addSerializer(classOf[Square], new SquareSerializer())
mod.addDeserializer(classOf[Square], new SquareDeserializer())
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
m.registerModule(mod)
val m = new ObjectMapper()
new JacksonConfig().customize(m)
m
private val e4 = Square(File.E, Rank.R4)
test("customize enables Option serialization via DefaultScalaModule"):
mapper.writeValueAsString(None) shouldBe "null"
mapper.writeValueAsString(Some("hello")) shouldBe """"hello""""
// ── SquareSerializer ──────────────────────────────────────────────
test("customize registers SquareSerializer"):
mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4""""
test("SquareSerializer writes square as string"):
mapper.writeValueAsString(e4) shouldBe """"e4""""
test("customize registers SquareDeserializer"):
mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4)
// ── SquareDeserializer ────────────────────────────────────────────
test("SquareDeserializer reads valid square string"):
mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4
// scalafix:off DisableSyntax.null
test("SquareDeserializer returns null for invalid square string"):
mapper.readValue(""""z9"""", classOf[Square]) shouldBe null
// scalafix:on DisableSyntax.null
// ── MoveTypeSerializer ────────────────────────────────────────────
test("MoveTypeSerializer serializes Normal non-capture"):
mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}"""
test("MoveTypeSerializer serializes Normal capture"):
mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}"""
test("MoveTypeSerializer serializes CastleKingside"):
test("customize registers MoveTypeSerializer"):
mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
test("MoveTypeSerializer serializes CastleQueenside"):
mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}"""
test("MoveTypeSerializer serializes EnPassant"):
mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}"""
test("MoveTypeSerializer serializes Promotion"):
mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe
"""{"type":"promotion","piece":"Queen"}"""
// ── MoveTypeDeserializer ──────────────────────────────────────────
test("MoveTypeDeserializer deserializes normal non-capture"):
mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe
MoveType.Normal(false)
test("MoveTypeDeserializer deserializes normal capture"):
mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe
MoveType.Normal(true)
test("MoveTypeDeserializer deserializes castleKingside"):
mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside
test("MoveTypeDeserializer deserializes castleQueenside"):
mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside
test("MoveTypeDeserializer deserializes enPassant"):
test("customize registers MoveTypeDeserializer"):
mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
test("MoveTypeDeserializer deserializes promotion"):
mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe
MoveType.Promotion(PromotionPiece.Rook)
test("MoveTypeDeserializer throws for unknown type"):
an[Exception] should be thrownBy
mapper.readValue("""{"type":"unknown"}""", classOf[MoveType])
@@ -1,12 +1,22 @@
package de.nowchess.chess.registry
import de.nowchess.api.game.GameContext
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.client.{CombinedExportResponse, StoreServiceClient}
import de.nowchess.chess.grpc.IoGrpcClientWrapper
import de.nowchess.io.fen.FenExporter
import de.nowchess.io.pgn.PgnExporter
import de.nowchess.rules.sets.DefaultRules
import de.nowchess.chess.engine.GameEngine
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.junit.jupiter.api.{DisplayName, Test}
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.when
import org.mockito.invocation.InvocationOnMock
import scala.compiletime.uninitialized
@@ -18,6 +28,25 @@ class GameRegistryImplTest:
@Inject
var registry: GameRegistry = uninitialized
@InjectMock
var ioWrapper: IoGrpcClientWrapper = uninitialized
@InjectMock
@RestClient
var storeClient: StoreServiceClient = uninitialized
@BeforeEach
def setupMocks(): Unit =
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
val ctx = inv.getArgument[GameContext](0)
CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
de.nowchess.io.pgn.PgnParser
.importGameContext(inv.getArgument[String](0))
.getOrElse(GameContext.initial),
)
@Test
@DisplayName("store saves entry")
def testStore(): Unit =
@@ -3,15 +3,16 @@ package de.nowchess.chess.resource
import de.nowchess.api.board.Square
import de.nowchess.api.dto.*
import de.nowchess.api.game.GameContext
import de.nowchess.chess.client.{IoServiceClient, RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
import de.nowchess.chess.client.CombinedExportResponse
import de.nowchess.chess.exception.BadRequestException
import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
import de.nowchess.io.fen.FenExporter
import de.nowchess.io.pgn.PgnParser
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.eclipse.microprofile.jwt.JsonWebToken
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
@@ -29,52 +30,71 @@ class GameResourceIntegrationTest:
var resource: GameResource = uninitialized
@InjectMock
@RestClient
var ioClient: IoServiceClient = uninitialized
var ruleAdapter: RuleSetGrpcAdapter = uninitialized
@InjectMock
@RestClient
var ruleClient: RuleServiceClient = uninitialized
var ioWrapper: IoGrpcClientWrapper = uninitialized
@InjectMock
var jwt: JsonWebToken = uninitialized
@BeforeEach
def setupMocks(): Unit =
when(ioClient.importFen(any())).thenReturn(GameContext.initial)
when(ioClient.importPgn(any())).thenReturn(
PgnParser.importGameContext("1. e4 c5").toOption.get,
when(jwt.getClaim[AnyRef]("type")).thenReturn("user")
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
)
when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial))
when(ioClient.exportPgn(any())).thenReturn("1. e4 c5")
when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
val req = inv.getArgument[RuleSquareRequest](0)
DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get),
when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
val ctx = inv.getArgument[GameContext](0)
CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
)
when(ruleClient.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
when(ioWrapper.exportFen(any())).thenAnswer((inv: InvocationOnMock) =>
FenExporter.exportGameContext(inv.getArgument[GameContext](0)),
)
when(ioWrapper.exportPgn(any())).thenAnswer((inv: InvocationOnMock) =>
PgnExporter.exportGameContext(inv.getArgument[GameContext](0)),
)
when(ruleAdapter.legalMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.legalMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),
)
when(ruleAdapter.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.allLegalMoves(inv.getArgument[GameContext](0)),
)
when(ruleClient.applyMove(any())).thenAnswer((inv: InvocationOnMock) =>
val req = inv.getArgument[RuleMoveRequest](0)
DefaultRules.applyMove(req.context)(req.move),
when(ruleAdapter.applyMove(any())(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.applyMove(inv.getArgument[GameContext](0))(inv.getArgument[de.nowchess.api.move.Move](1)),
)
when(ruleClient.isCheck(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isCheck(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isCheck(inv.getArgument[GameContext](0)),
)
when(ruleClient.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isCheckmate(inv.getArgument[GameContext](0)),
)
when(ruleClient.isStalemate(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isStalemate(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isStalemate(inv.getArgument[GameContext](0)),
)
when(ruleClient.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isInsufficientMaterial(inv.getArgument[GameContext](0)),
)
when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
when(ruleAdapter.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)),
)
when(ruleAdapter.postMoveStatus(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.postMoveStatus(inv.getArgument[GameContext](0)),
)
when(ruleAdapter.candidateMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.candidateMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),
)
when(ruleAdapter.isFiftyMoveRule(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isFiftyMoveRule(inv.getArgument[GameContext](0)),
)
@Test
@DisplayName("createGame returns 201")
def testCreateGame(): Unit =
val req = CreateGameRequestDto(None, None)
val req = CreateGameRequestDto(None, None, None)
val resp = resource.createGame(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
@@ -83,7 +103,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("getGame returns 200")
def testGetGame(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val getResp = resource.getGame(gameId)
assertEquals(200, getResp.getStatus)
@@ -93,7 +113,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("makeMove advances game")
def testMakeMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val moveResp = resource.makeMove(gameId, "e2e4")
assertEquals(200, moveResp.getStatus)
@@ -103,14 +123,14 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("makeMove with invalid UCI throws")
def testMakeMoveInvalid(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows(classOf[BadRequestException], () => resource.makeMove(gameId, "invalid"))
@Test
@DisplayName("getLegalMoves returns moves")
def testGetLegalMoves(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "")
assertEquals(200, movesResp.getStatus)
@@ -120,7 +140,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("resignGame updates state")
def testResignGame(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resignResp = resource.resignGame(gameId)
assertEquals(200, resignResp.getStatus)
@@ -131,7 +151,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("undoMove reverts")
def testUndoMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val undoResp = resource.undoMove(gameId)
@@ -142,7 +162,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("redoMove restores")
def testRedoMove(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
resource.undoMove(gameId)
@@ -154,7 +174,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("drawAction offer")
def testDrawActionOffer(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "offer")
assertEquals(200, resp.getStatus)
@@ -162,7 +182,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("drawAction accept")
def testDrawActionAccept(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "accept")
@@ -172,11 +192,9 @@ class GameResourceIntegrationTest:
@DisplayName("importFen creates game")
def testImportFen(): Unit =
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val req = ImportFenRequestDto(fen, None, None)
val req = ImportFenRequestDto(fen, None, None, None)
val resp = resource.importFen(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
assertEquals(fen, dto.state.fen)
@Test
@DisplayName("importPgn creates game")
@@ -190,7 +208,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("exportFen returns FEN")
def testExportFen(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.exportFen(gameId)
assertEquals(200, resp.getStatus)
@@ -199,10 +217,9 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("exportPgn returns PGN")
def testExportPgn(): Unit =
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val resp = resource.exportPgn(gameId)
assertEquals(200, resp.getStatus)
assertTrue(resp.getEntity.asInstanceOf[String].contains("1."))
// scalafix:on