feat(analysis): scaffold chess analysis microservice (NCS-71)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Add new `modules/analysis` Quarkus microservice that proxies chess-api.com to provide position analysis endpoints for the frontend. - NCS-95: Scaffold analysis module with build.gradle.kts, application.yml, Jackson config, and native reflection registration - NCS-96: Implement POST /api/analysis/position endpoint wrapping chess-api.com (ChessApiClient, AnalysisService, AnalysisResource, error handling) - NCS-97: 13 unit/integration tests covering service logic and REST layer (AnalysisServiceTest, AnalysisResourceTest) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,10 @@ val coverageExclusions = listOf(
|
||||
"**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
|
||||
// Coordinator infrastructure — gRPC, microservice orchestration
|
||||
"**/coordinator/src/main/scala/**",
|
||||
// Analysis resource/config — REST integration layer; @QuarkusTest not instrumented by Scoverage
|
||||
"**/analysis/src/main/scala/de/nowchess/analysis/resource/**",
|
||||
"**/analysis/src/main/scala/de/nowchess/analysis/config/**",
|
||||
"**/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisExceptionMapper.scala",
|
||||
)
|
||||
|
||||
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Changelog — analysis
|
||||
|
||||
## 0.1.0 (NCS-71)
|
||||
|
||||
- Initial scaffold: chess analysis microservice
|
||||
- REST endpoint `POST /api/analysis/position` wrapping chess-api.com
|
||||
- REST endpoint `GET /api/analysis/health`
|
||||
@@ -0,0 +1,111 @@
|
||||
plugins {
|
||||
id("scala")
|
||||
id("org.scoverage") version "8.1"
|
||||
id("io.quarkus")
|
||||
}
|
||||
|
||||
group = "de.nowchess"
|
||||
version = "1.0-SNAPSHOT"
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
scala {
|
||||
scalaVersion = versions["SCALA3"]!!
|
||||
}
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
excludedFiles.set(scoverageExcluded)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
val quarkusPlatformGroupId: String by project
|
||||
val quarkusPlatformArtifactId: String by project
|
||||
val quarkusPlatformVersion: String by project
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
implementation("org.scala-lang:scala3-library_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
|
||||
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
implementation("io.quarkus:quarkus-rest")
|
||||
implementation("io.quarkus:quarkus-rest-client")
|
||||
implementation("io.quarkus:quarkus-rest-client-jackson")
|
||||
implementation("io.quarkus:quarkus-rest-jackson")
|
||||
implementation("io.quarkus:quarkus-config-yaml")
|
||||
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
|
||||
implementation("io.quarkus:quarkus-smallrye-health")
|
||||
implementation("io.quarkus:quarkus-logging-json")
|
||||
implementation("io.quarkus:quarkus-micrometer")
|
||||
implementation("io.quarkus:quarkus-micrometer-registry-prometheus")
|
||||
implementation("io.quarkus:quarkus-opentelemetry")
|
||||
implementation("io.quarkus:quarkus-arc")
|
||||
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||
testImplementation("io.quarkus:quarkus-junit5")
|
||||
testImplementation("io.quarkus:quarkus-junit5-mockito")
|
||||
testImplementation("io.rest-assured:rest-assured")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
||||
|
||||
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
|
||||
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
|
||||
}
|
||||
configurations.scoverage {
|
||||
resolutionStrategy.eachDependency {
|
||||
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
|
||||
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
options.compilerArgs.add("-parameters")
|
||||
}
|
||||
|
||||
tasks.withType<Jar>().configureEach {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest", "junit-jupiter")
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
quarkus:
|
||||
http:
|
||||
port: 8087
|
||||
application:
|
||||
name: nowchess-analysis
|
||||
config:
|
||||
yaml:
|
||||
enabled: true
|
||||
|
||||
nowchess:
|
||||
analysis:
|
||||
chess-api:
|
||||
base-url: ${CHESS_API_URL:https://chess-api.com/v1}
|
||||
timeout-ms: ${CHESS_API_TIMEOUT_MS:5000}
|
||||
|
||||
"%dev":
|
||||
quarkus:
|
||||
rest-client:
|
||||
chess-api:
|
||||
url: https://chess-api.com/v1
|
||||
connect-timeout: 5000
|
||||
read-timeout: 5000
|
||||
|
||||
"%deployed":
|
||||
quarkus:
|
||||
log:
|
||||
console:
|
||||
json: true
|
||||
otel:
|
||||
traces:
|
||||
sampler: parentbased_traceidratio
|
||||
sampler-arg: 0.1
|
||||
exporter:
|
||||
otlp:
|
||||
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
|
||||
rest-client:
|
||||
chess-api:
|
||||
url: ${CHESS_API_URL:https://chess-api.com/v1}
|
||||
connect-timeout: ${CHESS_API_CONNECT_TIMEOUT_MS:5000}
|
||||
read-timeout: ${CHESS_API_TIMEOUT_MS:5000}
|
||||
@@ -0,0 +1,18 @@
|
||||
package de.nowchess.analysis.client
|
||||
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
/** MicroProfile REST client for chess-api.com v1.
|
||||
*
|
||||
* Base URL is resolved from `quarkus.rest-client.chess-api.url` in application.yml.
|
||||
*/
|
||||
@Path("/")
|
||||
@RegisterRestClient(configKey = "chess-api")
|
||||
trait ChessApiClient:
|
||||
|
||||
@POST
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def analyse(body: ChessApiRequestDto): ChessApiResponseDto
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.nowchess.analysis.client
|
||||
|
||||
/** Request body sent to chess-api.com v1 `/` endpoint. */
|
||||
case class ChessApiRequestDto(fen: String, depth: Int)
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.nowchess.analysis.client
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
/** Response from chess-api.com v1 analysis endpoint.
|
||||
*
|
||||
* The API returns a JSON object. Fields not listed here are ignored.
|
||||
*/
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
case class ChessApiResponseDto(
|
||||
/** Best move in UCI format (e.g. "e2e4"). */
|
||||
move: Option[String] = None,
|
||||
/** Centipawn evaluation (from white's perspective). */
|
||||
centipawns: Option[Double] = None,
|
||||
/** Mate-in-N (positive = white wins, negative = black wins). */
|
||||
mate: Option[Int] = None,
|
||||
/** Principal variation: space-separated UCI moves. */
|
||||
pv: Option[String] = None,
|
||||
/** Actual depth searched. */
|
||||
depth: Option[Int] = None,
|
||||
/** Text description of the position/move quality. */
|
||||
text: Option[String] = None,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.nowchess.analysis.config
|
||||
|
||||
import com.fasterxml.jackson.core.Version
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import io.quarkus.jackson.ObjectMapperCustomizer
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JacksonConfig extends ObjectMapperCustomizer:
|
||||
def customize(mapper: ObjectMapper): Unit =
|
||||
mapper.registerModule(new DefaultScalaModule() {
|
||||
override def version(): Version =
|
||||
// scalafix:off DisableSyntax.null
|
||||
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
|
||||
// scalafix:on DisableSyntax.null
|
||||
})
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package de.nowchess.analysis.config
|
||||
|
||||
import de.nowchess.analysis.client.{ChessApiRequestDto, ChessApiResponseDto}
|
||||
import de.nowchess.analysis.dto.{AnalysisRequestDto, AnalysisResponseDto}
|
||||
import de.nowchess.analysis.error.AnalysisErrorDto
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[AnalysisRequestDto],
|
||||
classOf[AnalysisResponseDto],
|
||||
classOf[ChessApiRequestDto],
|
||||
classOf[ChessApiResponseDto],
|
||||
classOf[AnalysisErrorDto],
|
||||
),
|
||||
registerFullHierarchy = true,
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.nowchess.analysis.dto
|
||||
|
||||
/** Request body for the analysis endpoint.
|
||||
*
|
||||
* @param fen
|
||||
* FEN string representing the position to analyse.
|
||||
* @param depth
|
||||
* Engine search depth (1-99). Defaults to 12 when absent.
|
||||
*/
|
||||
case class AnalysisRequestDto(fen: String, depth: Option[Int] = None)
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.nowchess.analysis.dto
|
||||
|
||||
/** Response from the analysis endpoint.
|
||||
*
|
||||
* @param fen
|
||||
* The analysed FEN.
|
||||
* @param depth
|
||||
* The search depth used.
|
||||
* @param bestMove
|
||||
* Best move in UCI notation (e.g. "e2e4"), or None if not available.
|
||||
* @param evaluation
|
||||
* Centipawn evaluation from white's perspective, or None.
|
||||
* @param mate
|
||||
* Mate-in-N value (positive = white wins, negative = black wins), or None.
|
||||
* @param continuationMoves
|
||||
* Principal variation as list of UCI moves.
|
||||
*/
|
||||
case class AnalysisResponseDto(
|
||||
fen: String,
|
||||
depth: Int,
|
||||
bestMove: Option[String],
|
||||
evaluation: Option[Double],
|
||||
mate: Option[Int],
|
||||
continuationMoves: List[String],
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.nowchess.analysis.error
|
||||
|
||||
case class AnalysisErrorDto(code: String, message: String)
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.nowchess.analysis.error
|
||||
|
||||
sealed class AnalysisException(val status: Int, val code: String, message: String) extends RuntimeException(message)
|
||||
|
||||
class InvalidFenException(fen: String) extends AnalysisException(400, "INVALID_FEN", s"Invalid FEN string: $fen")
|
||||
|
||||
class AnalysisUpstreamException(cause: Throwable)
|
||||
extends AnalysisException(502, "UPSTREAM_ERROR", s"Chess API unavailable: ${cause.getMessage}")
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package de.nowchess.analysis.error
|
||||
|
||||
import jakarta.ws.rs.core.{MediaType, Response}
|
||||
import jakarta.ws.rs.ext.{ExceptionMapper, Provider}
|
||||
|
||||
@Provider
|
||||
class AnalysisExceptionMapper extends ExceptionMapper[AnalysisException]:
|
||||
def toResponse(ex: AnalysisException): Response =
|
||||
Response
|
||||
.status(ex.status)
|
||||
.entity(AnalysisErrorDto(ex.code, ex.getMessage))
|
||||
.`type`(MediaType.APPLICATION_JSON)
|
||||
.build()
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.nowchess.analysis.resource
|
||||
|
||||
import de.nowchess.analysis.dto.{AnalysisRequestDto, AnalysisResponseDto}
|
||||
import de.nowchess.analysis.service.AnalysisService
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.{MediaType, Response}
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@Path("/api/analysis")
|
||||
@ApplicationScoped
|
||||
class AnalysisResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var analysisService: AnalysisService = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
/** Analyse a chess position.
|
||||
*
|
||||
* Accepts a FEN string and optional depth, proxies to chess-api.com, and returns structured analysis data.
|
||||
*/
|
||||
@POST
|
||||
@Path("/position")
|
||||
@PermitAll
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def analysePosition(body: AnalysisRequestDto): Response =
|
||||
val result = analysisService.analyse(body)
|
||||
Response.ok(result).build()
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.nowchess.analysis.service
|
||||
|
||||
import de.nowchess.analysis.client.{ChessApiClient, ChessApiRequestDto}
|
||||
import de.nowchess.analysis.dto.{AnalysisRequestDto, AnalysisResponseDto}
|
||||
import de.nowchess.analysis.error.{AnalysisUpstreamException, InvalidFenException}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.jboss.logging.Logger
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class AnalysisService:
|
||||
|
||||
private val log = Logger.getLogger(classOf[AnalysisService])
|
||||
|
||||
private val DefaultDepth = 12
|
||||
private val MinDepth = 1
|
||||
private val MaxDepth = 99
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
@RestClient
|
||||
var chessApiClient: ChessApiClient = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
// scalafix:off DisableSyntax.throw
|
||||
def analyse(request: AnalysisRequestDto): AnalysisResponseDto =
|
||||
val fen = request.fen.trim
|
||||
if fen.isEmpty then throw InvalidFenException(fen)
|
||||
validateFen(fen)
|
||||
|
||||
val depth = request.depth
|
||||
.map(d => d.max(MinDepth).min(MaxDepth))
|
||||
.getOrElse(DefaultDepth)
|
||||
|
||||
log.debugf("Analysing FEN '%s' at depth %d", fen, depth)
|
||||
|
||||
val apiResponse =
|
||||
try chessApiClient.analyse(ChessApiRequestDto(fen, depth))
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Chess API call failed for FEN '%s'", fen)
|
||||
throw AnalysisUpstreamException(ex)
|
||||
|
||||
val continuationMoves = apiResponse.pv
|
||||
.map(_.split(" ").toList.filter(_.nonEmpty))
|
||||
.getOrElse(List.empty)
|
||||
|
||||
AnalysisResponseDto(
|
||||
fen = fen,
|
||||
depth = apiResponse.depth.getOrElse(depth),
|
||||
bestMove = apiResponse.move,
|
||||
evaluation = apiResponse.centipawns,
|
||||
mate = apiResponse.mate,
|
||||
continuationMoves = continuationMoves,
|
||||
)
|
||||
// scalafix:on DisableSyntax.throw
|
||||
|
||||
/** Rudimentary FEN structure validation — checks the board part has 8 ranks. */
|
||||
// scalafix:off DisableSyntax.throw
|
||||
private def validateFen(fen: String): Unit =
|
||||
val parts = fen.split(" ")
|
||||
if parts.length < 1 then throw InvalidFenException(fen)
|
||||
val ranks = parts(0).split("/")
|
||||
if ranks.length != 8 then throw InvalidFenException(fen)
|
||||
// scalafix:on DisableSyntax.throw
|
||||
@@ -0,0 +1,4 @@
|
||||
quarkus:
|
||||
rest-client:
|
||||
chess-api:
|
||||
url: http://localhost:9999
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package de.nowchess.analysis.resource
|
||||
|
||||
import de.nowchess.analysis.dto.{AnalysisRequestDto, AnalysisResponseDto}
|
||||
import de.nowchess.analysis.error.{AnalysisUpstreamException, InvalidFenException}
|
||||
import de.nowchess.analysis.service.AnalysisService
|
||||
import io.quarkus.test.InjectMock
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import io.restassured.http.ContentType
|
||||
import org.hamcrest.Matchers.*
|
||||
import org.junit.jupiter.api.{DisplayName, Test}
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.Mockito.when
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
// scalafix:off
|
||||
@QuarkusTest
|
||||
@DisplayName("AnalysisResource")
|
||||
class AnalysisResourceTest:
|
||||
|
||||
@InjectMock
|
||||
var analysisService: AnalysisService = uninitialized
|
||||
|
||||
private val validFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
|
||||
private def givenJson() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/analysis/position returns 200 with analysis data")
|
||||
def testAnalysePositionOk(): Unit =
|
||||
when(analysisService.analyse(any()))
|
||||
.thenReturn(
|
||||
AnalysisResponseDto(
|
||||
fen = validFen,
|
||||
depth = 12,
|
||||
bestMove = Some("e2e4"),
|
||||
evaluation = Some(0.3),
|
||||
mate = None,
|
||||
continuationMoves = List("e2e4", "e7e5"),
|
||||
),
|
||||
)
|
||||
|
||||
givenJson()
|
||||
.body(s"""{"fen": "$validFen"}""")
|
||||
.when()
|
||||
.post("/api/analysis/position")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("fen", equalTo(validFen))
|
||||
.body("depth", equalTo(12))
|
||||
.body("bestMove", equalTo("e2e4"))
|
||||
.body("evaluation", equalTo(0.3f))
|
||||
.body("continuationMoves", hasItems("e2e4", "e7e5"))
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/analysis/position returns 400 for invalid FEN")
|
||||
def testAnalysePositionInvalidFen(): Unit =
|
||||
when(analysisService.analyse(any()))
|
||||
.thenThrow(new InvalidFenException("bad-fen"))
|
||||
|
||||
givenJson()
|
||||
.body("""{"fen": "bad-fen"}""")
|
||||
.when()
|
||||
.post("/api/analysis/position")
|
||||
.`then`()
|
||||
.statusCode(400)
|
||||
.body("code", equalTo("INVALID_FEN"))
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/analysis/position returns 502 on upstream failure")
|
||||
def testAnalysePositionUpstreamError(): Unit =
|
||||
when(analysisService.analyse(any()))
|
||||
.thenThrow(new AnalysisUpstreamException(new RuntimeException("timeout")))
|
||||
|
||||
givenJson()
|
||||
.body(s"""{"fen": "$validFen"}""")
|
||||
.when()
|
||||
.post("/api/analysis/position")
|
||||
.`then`()
|
||||
.statusCode(502)
|
||||
.body("code", equalTo("UPSTREAM_ERROR"))
|
||||
|
||||
@Test
|
||||
@DisplayName("POST /api/analysis/position accepts custom depth")
|
||||
def testAnalysePositionCustomDepth(): Unit =
|
||||
when(analysisService.analyse(any()))
|
||||
.thenReturn(
|
||||
AnalysisResponseDto(
|
||||
fen = validFen,
|
||||
depth = 20,
|
||||
bestMove = Some("d2d4"),
|
||||
evaluation = Some(0.15),
|
||||
mate = None,
|
||||
continuationMoves = List.empty,
|
||||
),
|
||||
)
|
||||
|
||||
givenJson()
|
||||
.body(s"""{"fen": "$validFen", "depth": 20}""")
|
||||
.when()
|
||||
.post("/api/analysis/position")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("depth", equalTo(20))
|
||||
// scalafix:on
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package de.nowchess.analysis.service
|
||||
|
||||
import de.nowchess.analysis.client.{ChessApiClient, ChessApiRequestDto, ChessApiResponseDto}
|
||||
import de.nowchess.analysis.dto.AnalysisRequestDto
|
||||
import de.nowchess.analysis.error.{AnalysisUpstreamException, InvalidFenException}
|
||||
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.junit.jupiter.api.Assertions.*
|
||||
import org.junit.jupiter.api.{DisplayName, Test}
|
||||
import org.mockito.ArgumentMatchers.any
|
||||
import org.mockito.Mockito.{verify, when}
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
// scalafix:off
|
||||
@QuarkusTest
|
||||
@DisplayName("AnalysisService")
|
||||
class AnalysisServiceTest:
|
||||
|
||||
@Inject
|
||||
var service: AnalysisService = uninitialized
|
||||
|
||||
@InjectMock
|
||||
@RestClient
|
||||
var chessApiClient: ChessApiClient = uninitialized
|
||||
|
||||
private val validFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse returns response with best move from chess-api.com")
|
||||
def testAnalyseReturnsBestMove(): Unit =
|
||||
when(chessApiClient.analyse(any()))
|
||||
.thenReturn(
|
||||
ChessApiResponseDto(
|
||||
move = Some("e2e4"),
|
||||
centipawns = Some(0.3),
|
||||
mate = None,
|
||||
pv = Some("e2e4 e7e5 g1f3"),
|
||||
depth = Some(12),
|
||||
),
|
||||
)
|
||||
|
||||
val response = service.analyse(AnalysisRequestDto(validFen, Some(12)))
|
||||
|
||||
assertEquals(validFen, response.fen)
|
||||
assertEquals(12, response.depth)
|
||||
assertEquals(Some("e2e4"), response.bestMove)
|
||||
assertEquals(Some(0.3), response.evaluation)
|
||||
assertEquals(None, response.mate)
|
||||
assertEquals(List("e2e4", "e7e5", "g1f3"), response.continuationMoves)
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse uses default depth 12 when not specified")
|
||||
def testAnalyseUsesDefaultDepth(): Unit =
|
||||
when(chessApiClient.analyse(any()))
|
||||
.thenReturn(ChessApiResponseDto(move = Some("d2d4"), depth = Some(12)))
|
||||
|
||||
val response = service.analyse(AnalysisRequestDto(validFen))
|
||||
|
||||
verify(chessApiClient).analyse(ChessApiRequestDto(validFen, 12))
|
||||
assertEquals(12, response.depth)
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse clamps depth to [1, 99]")
|
||||
def testAnalyseClampsDepth(): Unit =
|
||||
when(chessApiClient.analyse(any()))
|
||||
.thenReturn(ChessApiResponseDto(move = Some("e2e4"), depth = Some(99)))
|
||||
|
||||
service.analyse(AnalysisRequestDto(validFen, Some(200)))
|
||||
|
||||
verify(chessApiClient).analyse(ChessApiRequestDto(validFen, 99))
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse clamps depth minimum to 1")
|
||||
def testAnalyseClampsDepthMin(): Unit =
|
||||
when(chessApiClient.analyse(any()))
|
||||
.thenReturn(ChessApiResponseDto(move = Some("e2e4"), depth = Some(1)))
|
||||
|
||||
service.analyse(AnalysisRequestDto(validFen, Some(0)))
|
||||
|
||||
verify(chessApiClient).analyse(ChessApiRequestDto(validFen, 1))
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse handles empty pv gracefully")
|
||||
def testAnalyseEmptyPv(): Unit =
|
||||
when(chessApiClient.analyse(any()))
|
||||
.thenReturn(ChessApiResponseDto(move = Some("e2e4"), pv = None, depth = Some(5)))
|
||||
|
||||
val response = service.analyse(AnalysisRequestDto(validFen, Some(5)))
|
||||
|
||||
assertEquals(List.empty, response.continuationMoves)
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse throws InvalidFenException for empty FEN")
|
||||
def testAnalyseThrowsOnEmptyFen(): Unit =
|
||||
assertThrows(
|
||||
classOf[InvalidFenException],
|
||||
() => service.analyse(AnalysisRequestDto("")),
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse throws InvalidFenException for malformed FEN")
|
||||
def testAnalyseThrowsOnMalformedFen(): Unit =
|
||||
assertThrows(
|
||||
classOf[InvalidFenException],
|
||||
() => service.analyse(AnalysisRequestDto("not/a/valid/fen")),
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse wraps chess-api.com exception in AnalysisUpstreamException")
|
||||
def testAnalyseWrapsUpstreamException(): Unit =
|
||||
when(chessApiClient.analyse(any()))
|
||||
.thenThrow(new RuntimeException("connection refused"))
|
||||
|
||||
assertThrows(
|
||||
classOf[AnalysisUpstreamException],
|
||||
() => service.analyse(AnalysisRequestDto(validFen)),
|
||||
)
|
||||
|
||||
@Test
|
||||
@DisplayName("analyse returns mate value from chess-api.com response")
|
||||
def testAnalyseReturnsMate(): Unit =
|
||||
when(chessApiClient.analyse(any()))
|
||||
.thenReturn(
|
||||
ChessApiResponseDto(
|
||||
move = Some("d1h5"),
|
||||
centipawns = None,
|
||||
mate = Some(3),
|
||||
depth = Some(10),
|
||||
),
|
||||
)
|
||||
|
||||
val response = service.analyse(AnalysisRequestDto(validFen, Some(10)))
|
||||
|
||||
assertEquals(Some(3), response.mate)
|
||||
assertEquals(None, response.evaluation)
|
||||
// scalafix:on
|
||||
@@ -0,0 +1 @@
|
||||
VERSION=0.1.0
|
||||
@@ -27,4 +27,5 @@ include(
|
||||
"modules:store",
|
||||
"modules:coordinator",
|
||||
"modules:tournament",
|
||||
"modules:analysis",
|
||||
)
|
||||
Reference in New Issue
Block a user