diff --git a/build.gradle.kts b/build.gradle.kts index 0a7e09f..fad8752 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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). diff --git a/modules/analysis/CHANGELOG.md b/modules/analysis/CHANGELOG.md new file mode 100644 index 0000000..cccfa74 --- /dev/null +++ b/modules/analysis/CHANGELOG.md @@ -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` diff --git a/modules/analysis/build.gradle.kts b/modules/analysis/build.gradle.kts new file mode 100644 index 0000000..97641aa --- /dev/null +++ b/modules/analysis/build.gradle.kts @@ -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 +@Suppress("UNCHECKED_CAST") +val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List + +repositories { + mavenCentral() +} + +scala { + scalaVersion = versions["SCALA3"]!! +} + +scoverage { + scoverageVersion.set(versions["SCOVERAGE"]!!) + excludedFiles.set(scoverageExcluded) +} + +tasks.withType { + 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 { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} + +tasks.withType().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 +} diff --git a/modules/analysis/src/main/resources/application.yml b/modules/analysis/src/main/resources/application.yml new file mode 100644 index 0000000..2165642 --- /dev/null +++ b/modules/analysis/src/main/resources/application.yml @@ -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} diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiClient.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiClient.scala new file mode 100644 index 0000000..b7c423c --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiClient.scala @@ -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 diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiRequestDto.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiRequestDto.scala new file mode 100644 index 0000000..c3bdfbb --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiRequestDto.scala @@ -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) diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiResponseDto.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiResponseDto.scala new file mode 100644 index 0000000..512b6f0 --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/client/ChessApiResponseDto.scala @@ -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, +) diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/config/JacksonConfig.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/config/JacksonConfig.scala new file mode 100644 index 0000000..4c5a765 --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/config/JacksonConfig.scala @@ -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 + }) diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/config/NativeReflectionConfig.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/config/NativeReflectionConfig.scala new file mode 100644 index 0000000..7d22e51 --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/config/NativeReflectionConfig.scala @@ -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 diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/dto/AnalysisRequestDto.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/dto/AnalysisRequestDto.scala new file mode 100644 index 0000000..718606a --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/dto/AnalysisRequestDto.scala @@ -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) diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/dto/AnalysisResponseDto.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/dto/AnalysisResponseDto.scala new file mode 100644 index 0000000..96d8caf --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/dto/AnalysisResponseDto.scala @@ -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], +) diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisErrorDto.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisErrorDto.scala new file mode 100644 index 0000000..d063086 --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisErrorDto.scala @@ -0,0 +1,3 @@ +package de.nowchess.analysis.error + +case class AnalysisErrorDto(code: String, message: String) diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisException.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisException.scala new file mode 100644 index 0000000..de85416 --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisException.scala @@ -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}") diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisExceptionMapper.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisExceptionMapper.scala new file mode 100644 index 0000000..18dfafd --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/error/AnalysisExceptionMapper.scala @@ -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() diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/resource/AnalysisResource.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/resource/AnalysisResource.scala new file mode 100644 index 0000000..7698d66 --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/resource/AnalysisResource.scala @@ -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() diff --git a/modules/analysis/src/main/scala/de/nowchess/analysis/service/AnalysisService.scala b/modules/analysis/src/main/scala/de/nowchess/analysis/service/AnalysisService.scala new file mode 100644 index 0000000..a784a88 --- /dev/null +++ b/modules/analysis/src/main/scala/de/nowchess/analysis/service/AnalysisService.scala @@ -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 diff --git a/modules/analysis/src/test/resources/application.yml b/modules/analysis/src/test/resources/application.yml new file mode 100644 index 0000000..bedd164 --- /dev/null +++ b/modules/analysis/src/test/resources/application.yml @@ -0,0 +1,4 @@ +quarkus: + rest-client: + chess-api: + url: http://localhost:9999 diff --git a/modules/analysis/src/test/scala/de/nowchess/analysis/resource/AnalysisResourceTest.scala b/modules/analysis/src/test/scala/de/nowchess/analysis/resource/AnalysisResourceTest.scala new file mode 100644 index 0000000..69da87d --- /dev/null +++ b/modules/analysis/src/test/scala/de/nowchess/analysis/resource/AnalysisResourceTest.scala @@ -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 diff --git a/modules/analysis/src/test/scala/de/nowchess/analysis/service/AnalysisServiceTest.scala b/modules/analysis/src/test/scala/de/nowchess/analysis/service/AnalysisServiceTest.scala new file mode 100644 index 0000000..e465cf5 --- /dev/null +++ b/modules/analysis/src/test/scala/de/nowchess/analysis/service/AnalysisServiceTest.scala @@ -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 diff --git a/modules/analysis/versions.env b/modules/analysis/versions.env new file mode 100644 index 0000000..413f490 --- /dev/null +++ b/modules/analysis/versions.env @@ -0,0 +1 @@ +VERSION=0.1.0 diff --git a/settings.gradle.kts b/settings.gradle.kts index 7a018a5..d27f887 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,4 +27,5 @@ include( "modules:store", "modules:coordinator", "modules:tournament", + "modules:analysis", ) \ No newline at end of file