@@ -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
|
||||
+17
@@ -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()
|
||||
|
||||
+8
-32
@@ -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 =
|
||||
|
||||
+57
-40
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user