feat(analysis): scaffold chess analysis microservice (NCS-71) NCI-10 (#69)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
NCS-95 NCS-96 NCS-97 NCI-10 --------- Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com> Reviewed-on: #69
This commit was merged in pull request #69.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user