diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index a99d21e..5c60cfe 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/CLAUDE.md b/CLAUDE.md
index d75a177..dccc202 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,6 +9,7 @@ Scala 3.5.1 · Gradle 9
./compile # Compile all modules — always run
./test # Run all tests
./coverage # Check coverage
+./lint # Run linters
```
Use consistently.
diff --git a/CLAUDE.original.md b/CLAUDE.original.md
index b5ab597..8439c5e 100644
--- a/CLAUDE.original.md
+++ b/CLAUDE.original.md
@@ -9,6 +9,7 @@ Scala 3.5.1 · Gradle 9
./compile # Compile all modules — always run
./test # Run all tests
./coverage # Check coverage
+./lint # Run linters
```
Try to stick to these commands for consistency.
diff --git a/bruno/draw/01 Offer Draw.bru b/bruno/board/draw/01 Offer Draw.bru
similarity index 100%
rename from bruno/draw/01 Offer Draw.bru
rename to bruno/board/draw/01 Offer Draw.bru
diff --git a/bruno/draw/02 Accept Draw.bru b/bruno/board/draw/02 Accept Draw.bru
similarity index 100%
rename from bruno/draw/02 Accept Draw.bru
rename to bruno/board/draw/02 Accept Draw.bru
diff --git a/bruno/draw/03 Decline Draw.bru b/bruno/board/draw/03 Decline Draw.bru
similarity index 100%
rename from bruno/draw/03 Decline Draw.bru
rename to bruno/board/draw/03 Decline Draw.bru
diff --git a/bruno/draw/04 Claim Draw.bru b/bruno/board/draw/04 Claim Draw.bru
similarity index 100%
rename from bruno/draw/04 Claim Draw.bru
rename to bruno/board/draw/04 Claim Draw.bru
diff --git a/bruno/draw/folder.bru b/bruno/board/draw/folder.bru
similarity index 100%
rename from bruno/draw/folder.bru
rename to bruno/board/draw/folder.bru
diff --git a/bruno/export/01 Export FEN.bru b/bruno/board/export/01 Export FEN.bru
similarity index 100%
rename from bruno/export/01 Export FEN.bru
rename to bruno/board/export/01 Export FEN.bru
diff --git a/bruno/export/02 Export PGN.bru b/bruno/board/export/02 Export PGN.bru
similarity index 100%
rename from bruno/export/02 Export PGN.bru
rename to bruno/board/export/02 Export PGN.bru
diff --git a/bruno/export/folder.bru b/bruno/board/export/folder.bru
similarity index 100%
rename from bruno/export/folder.bru
rename to bruno/board/export/folder.bru
diff --git a/bruno/game/01 Create Game.bru b/bruno/board/game/01 Create Game.bru
similarity index 100%
rename from bruno/game/01 Create Game.bru
rename to bruno/board/game/01 Create Game.bru
diff --git a/bruno/game/02 Get Game.bru b/bruno/board/game/02 Get Game.bru
similarity index 100%
rename from bruno/game/02 Get Game.bru
rename to bruno/board/game/02 Get Game.bru
diff --git a/bruno/game/03 Stream Game.bru b/bruno/board/game/03 Stream Game.bru
similarity index 100%
rename from bruno/game/03 Stream Game.bru
rename to bruno/board/game/03 Stream Game.bru
diff --git a/bruno/game/04 Resign.bru b/bruno/board/game/04 Resign.bru
similarity index 100%
rename from bruno/game/04 Resign.bru
rename to bruno/board/game/04 Resign.bru
diff --git a/bruno/game/folder.bru b/bruno/board/game/folder.bru
similarity index 100%
rename from bruno/game/folder.bru
rename to bruno/board/game/folder.bru
diff --git a/bruno/import/01 Import FEN.bru b/bruno/board/import/01 Import FEN.bru
similarity index 100%
rename from bruno/import/01 Import FEN.bru
rename to bruno/board/import/01 Import FEN.bru
diff --git a/bruno/import/02 Import PGN.bru b/bruno/board/import/02 Import PGN.bru
similarity index 100%
rename from bruno/import/02 Import PGN.bru
rename to bruno/board/import/02 Import PGN.bru
diff --git a/bruno/import/folder.bru b/bruno/board/import/folder.bru
similarity index 100%
rename from bruno/import/folder.bru
rename to bruno/board/import/folder.bru
diff --git a/bruno/move/01 Make Move.bru b/bruno/board/move/01 Make Move.bru
similarity index 100%
rename from bruno/move/01 Make Move.bru
rename to bruno/board/move/01 Make Move.bru
diff --git a/bruno/move/02 Get Legal Moves.bru b/bruno/board/move/02 Get Legal Moves.bru
similarity index 100%
rename from bruno/move/02 Get Legal Moves.bru
rename to bruno/board/move/02 Get Legal Moves.bru
diff --git a/bruno/move/03 Undo Move.bru b/bruno/board/move/03 Undo Move.bru
similarity index 100%
rename from bruno/move/03 Undo Move.bru
rename to bruno/board/move/03 Undo Move.bru
diff --git a/bruno/move/04 Redo Move.bru b/bruno/board/move/04 Redo Move.bru
similarity index 100%
rename from bruno/move/04 Redo Move.bru
rename to bruno/board/move/04 Redo Move.bru
diff --git a/bruno/move/folder.bru b/bruno/board/move/folder.bru
similarity index 100%
rename from bruno/move/folder.bru
rename to bruno/board/move/folder.bru
diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru
index 85aff34..d0f7638 100644
--- a/bruno/environments/local.bru
+++ b/bruno/environments/local.bru
@@ -1,3 +1,4 @@
vars {
baseUrl: http://localhost:8080
+ ioBaseUrl: http://localhost:8081
}
diff --git a/bruno/io/export/01 Export FEN.bru b/bruno/io/export/01 Export FEN.bru
new file mode 100644
index 0000000..27ab0d7
--- /dev/null
+++ b/bruno/io/export/01 Export FEN.bru
@@ -0,0 +1,100 @@
+meta {
+ name: Export FEN
+ type: http
+ seq: 1
+}
+
+http {
+ method: POST
+ url: {{ioBaseUrl}}/io/export/fen
+ body: json
+ auth: none
+}
+
+headers {
+ Content-Type: application/json
+}
+
+body:json {
+ {
+ "board": {
+ "a1": {"color": "White", "pieceType": "Rook"},
+ "b1": {"color": "White", "pieceType": "Knight"},
+ "c1": {"color": "White", "pieceType": "Bishop"},
+ "d1": {"color": "White", "pieceType": "Queen"},
+ "e1": {"color": "White", "pieceType": "King"},
+ "f1": {"color": "White", "pieceType": "Bishop"},
+ "g1": {"color": "White", "pieceType": "Knight"},
+ "h1": {"color": "White", "pieceType": "Rook"},
+ "a2": {"color": "White", "pieceType": "Pawn"},
+ "b2": {"color": "White", "pieceType": "Pawn"},
+ "c2": {"color": "White", "pieceType": "Pawn"},
+ "d2": {"color": "White", "pieceType": "Pawn"},
+ "e2": {"color": "White", "pieceType": "Pawn"},
+ "f2": {"color": "White", "pieceType": "Pawn"},
+ "g2": {"color": "White", "pieceType": "Pawn"},
+ "h2": {"color": "White", "pieceType": "Pawn"},
+ "a7": {"color": "Black", "pieceType": "Pawn"},
+ "b7": {"color": "Black", "pieceType": "Pawn"},
+ "c7": {"color": "Black", "pieceType": "Pawn"},
+ "d7": {"color": "Black", "pieceType": "Pawn"},
+ "e7": {"color": "Black", "pieceType": "Pawn"},
+ "f7": {"color": "Black", "pieceType": "Pawn"},
+ "g7": {"color": "Black", "pieceType": "Pawn"},
+ "h7": {"color": "Black", "pieceType": "Pawn"},
+ "a8": {"color": "Black", "pieceType": "Rook"},
+ "b8": {"color": "Black", "pieceType": "Knight"},
+ "c8": {"color": "Black", "pieceType": "Bishop"},
+ "d8": {"color": "Black", "pieceType": "Queen"},
+ "e8": {"color": "Black", "pieceType": "King"},
+ "f8": {"color": "Black", "pieceType": "Bishop"},
+ "g8": {"color": "Black", "pieceType": "Knight"},
+ "h8": {"color": "Black", "pieceType": "Rook"}
+ },
+ "turn": "White",
+ "castlingRights": {
+ "whiteKingSide": true,
+ "whiteQueenSide": true,
+ "blackKingSide": true,
+ "blackQueenSide": true
+ },
+ "enPassantSquare": null,
+ "halfMoveClock": 0,
+ "moves": [],
+ "result": null,
+ "initialBoard": {
+ "a1": {"color": "White", "pieceType": "Rook"},
+ "b1": {"color": "White", "pieceType": "Knight"},
+ "c1": {"color": "White", "pieceType": "Bishop"},
+ "d1": {"color": "White", "pieceType": "Queen"},
+ "e1": {"color": "White", "pieceType": "King"},
+ "f1": {"color": "White", "pieceType": "Bishop"},
+ "g1": {"color": "White", "pieceType": "Knight"},
+ "h1": {"color": "White", "pieceType": "Rook"},
+ "a2": {"color": "White", "pieceType": "Pawn"},
+ "b2": {"color": "White", "pieceType": "Pawn"},
+ "c2": {"color": "White", "pieceType": "Pawn"},
+ "d2": {"color": "White", "pieceType": "Pawn"},
+ "e2": {"color": "White", "pieceType": "Pawn"},
+ "f2": {"color": "White", "pieceType": "Pawn"},
+ "g2": {"color": "White", "pieceType": "Pawn"},
+ "h2": {"color": "White", "pieceType": "Pawn"},
+ "a7": {"color": "Black", "pieceType": "Pawn"},
+ "b7": {"color": "Black", "pieceType": "Pawn"},
+ "c7": {"color": "Black", "pieceType": "Pawn"},
+ "d7": {"color": "Black", "pieceType": "Pawn"},
+ "e7": {"color": "Black", "pieceType": "Pawn"},
+ "f7": {"color": "Black", "pieceType": "Pawn"},
+ "g7": {"color": "Black", "pieceType": "Pawn"},
+ "h7": {"color": "Black", "pieceType": "Pawn"},
+ "a8": {"color": "Black", "pieceType": "Rook"},
+ "b8": {"color": "Black", "pieceType": "Knight"},
+ "c8": {"color": "Black", "pieceType": "Bishop"},
+ "d8": {"color": "Black", "pieceType": "Queen"},
+ "e8": {"color": "Black", "pieceType": "King"},
+ "f8": {"color": "Black", "pieceType": "Bishop"},
+ "g8": {"color": "Black", "pieceType": "Knight"},
+ "h8": {"color": "Black", "pieceType": "Rook"}
+ }
+ }
+}
diff --git a/bruno/io/export/02 Export PGN.bru b/bruno/io/export/02 Export PGN.bru
new file mode 100644
index 0000000..e1395a3
--- /dev/null
+++ b/bruno/io/export/02 Export PGN.bru
@@ -0,0 +1,100 @@
+meta {
+ name: Export PGN
+ type: http
+ seq: 2
+}
+
+http {
+ method: POST
+ url: {{ioBaseUrl}}/io/export/pgn
+ body: json
+ auth: none
+}
+
+headers {
+ Content-Type: application/json
+}
+
+body:json {
+ {
+ "board": {
+ "a1": {"color": "White", "pieceType": "Rook"},
+ "b1": {"color": "White", "pieceType": "Knight"},
+ "c1": {"color": "White", "pieceType": "Bishop"},
+ "d1": {"color": "White", "pieceType": "Queen"},
+ "e1": {"color": "White", "pieceType": "King"},
+ "f1": {"color": "White", "pieceType": "Bishop"},
+ "g1": {"color": "White", "pieceType": "Knight"},
+ "h1": {"color": "White", "pieceType": "Rook"},
+ "a2": {"color": "White", "pieceType": "Pawn"},
+ "b2": {"color": "White", "pieceType": "Pawn"},
+ "c2": {"color": "White", "pieceType": "Pawn"},
+ "d2": {"color": "White", "pieceType": "Pawn"},
+ "e2": {"color": "White", "pieceType": "Pawn"},
+ "f2": {"color": "White", "pieceType": "Pawn"},
+ "g2": {"color": "White", "pieceType": "Pawn"},
+ "h2": {"color": "White", "pieceType": "Pawn"},
+ "a7": {"color": "Black", "pieceType": "Pawn"},
+ "b7": {"color": "Black", "pieceType": "Pawn"},
+ "c7": {"color": "Black", "pieceType": "Pawn"},
+ "d7": {"color": "Black", "pieceType": "Pawn"},
+ "e7": {"color": "Black", "pieceType": "Pawn"},
+ "f7": {"color": "Black", "pieceType": "Pawn"},
+ "g7": {"color": "Black", "pieceType": "Pawn"},
+ "h7": {"color": "Black", "pieceType": "Pawn"},
+ "a8": {"color": "Black", "pieceType": "Rook"},
+ "b8": {"color": "Black", "pieceType": "Knight"},
+ "c8": {"color": "Black", "pieceType": "Bishop"},
+ "d8": {"color": "Black", "pieceType": "Queen"},
+ "e8": {"color": "Black", "pieceType": "King"},
+ "f8": {"color": "Black", "pieceType": "Bishop"},
+ "g8": {"color": "Black", "pieceType": "Knight"},
+ "h8": {"color": "Black", "pieceType": "Rook"}
+ },
+ "turn": "White",
+ "castlingRights": {
+ "whiteKingSide": true,
+ "whiteQueenSide": true,
+ "blackKingSide": true,
+ "blackQueenSide": true
+ },
+ "enPassantSquare": null,
+ "halfMoveClock": 0,
+ "moves": [],
+ "result": null,
+ "initialBoard": {
+ "a1": {"color": "White", "pieceType": "Rook"},
+ "b1": {"color": "White", "pieceType": "Knight"},
+ "c1": {"color": "White", "pieceType": "Bishop"},
+ "d1": {"color": "White", "pieceType": "Queen"},
+ "e1": {"color": "White", "pieceType": "King"},
+ "f1": {"color": "White", "pieceType": "Bishop"},
+ "g1": {"color": "White", "pieceType": "Knight"},
+ "h1": {"color": "White", "pieceType": "Rook"},
+ "a2": {"color": "White", "pieceType": "Pawn"},
+ "b2": {"color": "White", "pieceType": "Pawn"},
+ "c2": {"color": "White", "pieceType": "Pawn"},
+ "d2": {"color": "White", "pieceType": "Pawn"},
+ "e2": {"color": "White", "pieceType": "Pawn"},
+ "f2": {"color": "White", "pieceType": "Pawn"},
+ "g2": {"color": "White", "pieceType": "Pawn"},
+ "h2": {"color": "White", "pieceType": "Pawn"},
+ "a7": {"color": "Black", "pieceType": "Pawn"},
+ "b7": {"color": "Black", "pieceType": "Pawn"},
+ "c7": {"color": "Black", "pieceType": "Pawn"},
+ "d7": {"color": "Black", "pieceType": "Pawn"},
+ "e7": {"color": "Black", "pieceType": "Pawn"},
+ "f7": {"color": "Black", "pieceType": "Pawn"},
+ "g7": {"color": "Black", "pieceType": "Pawn"},
+ "h7": {"color": "Black", "pieceType": "Pawn"},
+ "a8": {"color": "Black", "pieceType": "Rook"},
+ "b8": {"color": "Black", "pieceType": "Knight"},
+ "c8": {"color": "Black", "pieceType": "Bishop"},
+ "d8": {"color": "Black", "pieceType": "Queen"},
+ "e8": {"color": "Black", "pieceType": "King"},
+ "f8": {"color": "Black", "pieceType": "Bishop"},
+ "g8": {"color": "Black", "pieceType": "Knight"},
+ "h8": {"color": "Black", "pieceType": "Rook"}
+ }
+ }
+}
diff --git a/bruno/io/export/folder.bru b/bruno/io/export/folder.bru
new file mode 100644
index 0000000..ba07dba
--- /dev/null
+++ b/bruno/io/export/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: export
+ seq: 2
+}
diff --git a/bruno/io/import/01 Import FEN.bru b/bruno/io/import/01 Import FEN.bru
new file mode 100644
index 0000000..ec901c6
--- /dev/null
+++ b/bruno/io/import/01 Import FEN.bru
@@ -0,0 +1,22 @@
+meta {
+ name: Import FEN
+ type: http
+ seq: 1
+}
+
+http {
+ method: POST
+ url: {{ioBaseUrl}}/io/import/fen
+ body: json
+ auth: none
+}
+
+headers {
+ Content-Type: application/json
+}
+
+body:json {
+ {
+ "fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
+ }
+}
diff --git a/bruno/io/import/02 Import PGN.bru b/bruno/io/import/02 Import PGN.bru
new file mode 100644
index 0000000..b7e038d
--- /dev/null
+++ b/bruno/io/import/02 Import PGN.bru
@@ -0,0 +1,22 @@
+meta {
+ name: Import PGN
+ type: http
+ seq: 2
+}
+
+http {
+ method: POST
+ url: {{ioBaseUrl}}/io/import/pgn
+ body: json
+ auth: none
+}
+
+headers {
+ Content-Type: application/json
+}
+
+body:json {
+ {
+ "pgn": "1. e4 e5 2. Nf3 Nc6 *"
+ }
+}
diff --git a/bruno/io/import/folder.bru b/bruno/io/import/folder.bru
new file mode 100644
index 0000000..86dcb7c
--- /dev/null
+++ b/bruno/io/import/folder.bru
@@ -0,0 +1,4 @@
+meta {
+ name: import
+ seq: 1
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index e8fff64..39ce424 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -36,7 +36,11 @@ val coverageExclusions = listOf(
"**/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala",
"**/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala",
// GameResource — REST integration layer with @Inject var fields; mocking dependencies for unit tests is infeasible with Quarkus DI; integration tests would require @QuarkusTest which Scoverage doesn't instrument
- "**/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala"
+ "**/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala",
+ // IoResource — same rationale as GameResource; @QuarkusTest not instrumented by Scoverage
+ "**/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala",
+ // JacksonConfig — Quarkus lifecycle hook, no testable logic beyond ObjectMapper registration
+ "**/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala",
)
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
@@ -72,7 +76,7 @@ sonar {
val versions = mapOf(
"QUARKUS_SCALA3" to "1.0.0",
"SCALA3" to "3.5.1",
- "SCALA_LIBRARY" to "2.13.18",
+ "SCALA_LIBRARY" to "2.13.16",
"SCALATEST" to "3.2.19",
"SCALATEST_JUNIT" to "0.1.11",
"SCOVERAGE" to "2.1.1",
diff --git a/lint b/lint
new file mode 100755
index 0000000..848a455
--- /dev/null
+++ b/lint
@@ -0,0 +1,3 @@
+#! /usr/bin/env bash
+
+./gradlew scalafix spotlessCheck
diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts
index a02a07c..d572500 100644
--- a/modules/core/build.gradle.kts
+++ b/modules/core/build.gradle.kts
@@ -74,6 +74,7 @@ dependencies {
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")
diff --git a/modules/core/src/main/resources/application.yml b/modules/core/src/main/resources/application.yml
index 0d92446..1663bc0 100644
--- a/modules/core/src/main/resources/application.yml
+++ b/modules/core/src/main/resources/application.yml
@@ -1,3 +1,8 @@
-greeting:
- message: "hello"
-
+quarkus:
+ http:
+ port: 8080
+ application:
+ name: nowchess-core
+ rest-client:
+ io-service:
+ url: http://localhost:8081
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
new file mode 100644
index 0000000..dac4c3d
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
@@ -0,0 +1,35 @@
+package de.nowchess.chess.client
+
+import de.nowchess.api.game.GameContext
+import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest}
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
+
+@Path("/io")
+@RegisterRestClient(configKey = "io-service")
+trait IoServiceClient:
+
+ @POST
+ @Path("/import/fen")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def importFen(body: ImportFenRequest): GameContext
+
+ @POST
+ @Path("/import/pgn")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def importPgn(body: ImportPgnRequest): GameContext
+
+ @POST
+ @Path("/export/fen")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.TEXT_PLAIN))
+ def exportFen(ctx: GameContext): String
+
+ @POST
+ @Path("/export/pgn")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array("application/x-chess-pgn"))
+ def exportPgn(ctx: GameContext): String
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
index b252cec..816cb0b 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
@@ -2,7 +2,10 @@ package de.nowchess.chess.config
import com.fasterxml.jackson.core.Version
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.Square
+import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer}
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@@ -15,3 +18,7 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
+ val squareModule = new SimpleModule()
+ squareModule.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
+ squareModule.addKeySerializer(classOf[Square], new SquareKeySerializer())
+ mapper.registerModule(squareModule)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
index c8ce0fe..42e4cd4 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
@@ -1,6 +1,9 @@
package de.nowchess.chess.config
+import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.dto.*
+import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
@@ -18,6 +21,19 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[LegalMovesResponseDto],
classOf[OkResponseDto],
classOf[PlayerInfoDto],
+ classOf[GameContext],
+ classOf[Color],
+ classOf[Piece],
+ classOf[PieceType],
+ classOf[CastlingRights],
+ classOf[Square],
+ classOf[File],
+ classOf[Rank],
+ classOf[Move],
+ classOf[MoveType],
+ classOf[PromotionPiece],
+ classOf[GameResult],
+ classOf[DrawReason],
),
)
class NativeReflectionConfig
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
index e8258bb..ece9752 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
@@ -6,18 +6,21 @@ import de.nowchess.api.dto.*
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.client.IoServiceClient
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
import de.nowchess.chess.observer.*
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
-import de.nowchess.io.fen.{FenExporter, FenParser}
-import de.nowchess.io.pgn.{PgnExporter, PgnParser}
+import de.nowchess.io.fen.FenExporter
+import de.nowchess.io.pgn.PgnExporter
+import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest}
import io.smallrye.mutiny.Multi
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
+import org.eclipse.microprofile.rest.client.inject.RestClient
import java.util.concurrent.atomic.AtomicReference
import scala.compiletime.uninitialized
@@ -32,6 +35,10 @@ class GameResource:
@Inject
var objectMapper: ObjectMapper = uninitialized
+
+ @Inject
+ @RestClient
+ var ioClient: IoServiceClient = uninitialized
// scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
@@ -142,7 +149,6 @@ class GameResource:
val black = playerInfoFrom(req.black, DefaultBlack)
val entry = newEntry(GameContext.initial, white, black)
registry.store(entry)
- println(s"Created game ${entry.gameId}")
created(toGameFullDto(entry))
@GET
@@ -264,9 +270,7 @@ class GameResource:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def importFen(body: ImportFenRequestDto): Response =
- val ctx = FenParser.parseFen(body.fen) match
- case Left(err) => throw BadRequestException("INVALID_FEN", err, Some("fen"))
- case Right(ctx) => ctx
+ val ctx = ioClient.importFen(ImportFenRequest(body.fen))
val white = playerInfoFrom(body.white, DefaultWhite)
val black = playerInfoFrom(body.black, DefaultBlack)
val entry = newEntry(ctx, white, black)
@@ -278,11 +282,8 @@ class GameResource:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def importPgn(body: ImportPgnRequestDto): Response =
- val engine = GameEngine()
- engine.loadGame(PgnParser, body.pgn) match
- case Left(err) => throw BadRequestException("INVALID_PGN", err, Some("pgn"))
- case Right(_) => ()
- val entry = GameEntry(registry.generateId(), engine, DefaultWhite, DefaultBlack)
+ val ctx = ioClient.importPgn(ImportPgnRequest(body.pgn))
+ val entry = newEntry(ctx, DefaultWhite, DefaultBlack)
registry.store(entry)
created(toGameFullDto(entry))
@@ -291,21 +292,12 @@ class GameResource:
@Produces(Array(MediaType.TEXT_PLAIN))
def exportFen(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
- ok(FenExporter.exportGameContext(entry.engine.context))
+ ok(ioClient.exportFen(entry.engine.context))
@GET
@Path("/{gameId}/export/pgn")
@Produces(Array("application/x-chess-pgn"))
def exportPgn(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
- val pgn = PgnExporter.exportGame(
- Map(
- "Event" -> "NowChess game",
- "White" -> entry.white.displayName,
- "Black" -> entry.black.displayName,
- "Result" -> "*",
- ),
- entry.engine.context.moves,
- )
- ok(pgn)
+ ok(ioClient.exportPgn(entry.engine.context))
// scalafix:on DisableSyntax.throw
diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
index bf165cc..1ee458e 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
@@ -1,11 +1,19 @@
package de.nowchess.chess.resource
import de.nowchess.api.dto.*
+import de.nowchess.api.game.GameContext
+import de.nowchess.chess.client.IoServiceClient
import de.nowchess.chess.exception.BadRequestException
+import de.nowchess.io.fen.FenExporter
+import de.nowchess.io.pgn.PgnParser
+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 scala.compiletime.uninitialized
@@ -17,6 +25,19 @@ class GameResourceIntegrationTest:
@Inject
var resource: GameResource = uninitialized
+ @InjectMock
+ @RestClient
+ var ioClient: IoServiceClient = 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(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial))
+ when(ioClient.exportPgn(any())).thenReturn("1. e4 c5")
+
@Test
@DisplayName("createGame returns 201")
def testCreateGame(): Unit =
diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts
index 84475e4..61ea69d 100644
--- a/modules/io/build.gradle.kts
+++ b/modules/io/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
+ id("io.quarkus")
}
group = "de.nowchess"
@@ -28,6 +29,10 @@ 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") {
@@ -53,19 +58,51 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${versions["JACKSON"]!!}")
+ implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-smallrye-openapi")
+
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.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")
+ includeEngines("scalatest", "junit-jupiter")
testLogging {
- events("skipped", "failed")
+ events("passed", "skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
@@ -73,3 +110,6 @@ tasks.test {
tasks.reportScoverage {
dependsOn(tasks.test)
}
+tasks.jar {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
diff --git a/modules/io/src/main/resources/application.yml b/modules/io/src/main/resources/application.yml
new file mode 100644
index 0000000..b80a867
--- /dev/null
+++ b/modules/io/src/main/resources/application.yml
@@ -0,0 +1,13 @@
+quarkus:
+ http:
+ port: 8081
+ application:
+ name: nowchess-io
+ smallrye-openapi:
+ info-title: NowChess IO Service
+ info-version: 1.0.0
+ info-description: Chess notation import and export — FEN and PGN
+ path: /openapi
+ swagger-ui:
+ always-include: true
+ path: /swagger-ui
diff --git a/modules/io/src/main/scala/de/nowchess/io/json/SquareKeyDeserializer.scala b/modules/io/src/main/scala/de/nowchess/io/json/SquareKeyDeserializer.scala
new file mode 100644
index 0000000..223f077
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/json/SquareKeyDeserializer.scala
@@ -0,0 +1,8 @@
+package de.nowchess.io.json
+
+import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
+import de.nowchess.api.board.Square
+
+class SquareKeyDeserializer extends KeyDeserializer:
+ override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef =
+ Square.fromAlgebraic(key).orNull
diff --git a/modules/io/src/main/scala/de/nowchess/io/json/SquareKeySerializer.scala b/modules/io/src/main/scala/de/nowchess/io/json/SquareKeySerializer.scala
new file mode 100644
index 0000000..93bcbad
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/json/SquareKeySerializer.scala
@@ -0,0 +1,9 @@
+package de.nowchess.io.json
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
+import de.nowchess.api.board.Square
+
+class SquareKeySerializer extends JsonSerializer[Square]:
+ override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
+ gen.writeFieldName(value.toString)
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala b/modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala
new file mode 100644
index 0000000..b0a0c5c
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala
@@ -0,0 +1,24 @@
+package de.nowchess.io.service.config
+
+import com.fasterxml.jackson.core.Version
+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.Square
+import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer}
+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
+ })
+ val squareModule = new SimpleModule()
+ squareModule.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
+ squareModule.addKeySerializer(classOf[Square], new SquareKeySerializer())
+ mapper.registerModule(squareModule)
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/config/NativeReflectionConfig.scala b/modules/io/src/main/scala/de/nowchess/io/service/config/NativeReflectionConfig.scala
new file mode 100644
index 0000000..a9a15e8
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/service/config/NativeReflectionConfig.scala
@@ -0,0 +1,29 @@
+package de.nowchess.io.service.config
+
+import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
+import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
+import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest, IoErrorDto}
+import io.quarkus.runtime.annotations.RegisterForReflection
+
+@RegisterForReflection(
+ targets = Array(
+ classOf[ImportFenRequest],
+ classOf[ImportPgnRequest],
+ classOf[IoErrorDto],
+ classOf[GameContext],
+ classOf[GameResult],
+ classOf[DrawReason],
+ classOf[Color],
+ classOf[Piece],
+ classOf[PieceType],
+ classOf[CastlingRights],
+ classOf[Square],
+ classOf[File],
+ classOf[Rank],
+ classOf[Move],
+ classOf[MoveType],
+ classOf[PromotionPiece],
+ ),
+)
+class NativeReflectionConfig
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/dto/ImportFenRequest.scala b/modules/io/src/main/scala/de/nowchess/io/service/dto/ImportFenRequest.scala
new file mode 100644
index 0000000..fe7a139
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/service/dto/ImportFenRequest.scala
@@ -0,0 +1,3 @@
+package de.nowchess.io.service.dto
+
+case class ImportFenRequest(fen: String)
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/dto/ImportPgnRequest.scala b/modules/io/src/main/scala/de/nowchess/io/service/dto/ImportPgnRequest.scala
new file mode 100644
index 0000000..83ed7f7
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/service/dto/ImportPgnRequest.scala
@@ -0,0 +1,3 @@
+package de.nowchess.io.service.dto
+
+case class ImportPgnRequest(pgn: String)
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/dto/IoErrorDto.scala b/modules/io/src/main/scala/de/nowchess/io/service/dto/IoErrorDto.scala
new file mode 100644
index 0000000..04c9805
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/service/dto/IoErrorDto.scala
@@ -0,0 +1,3 @@
+package de.nowchess.io.service.dto
+
+case class IoErrorDto(code: String, message: String)
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
new file mode 100644
index 0000000..393d74e
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
@@ -0,0 +1,77 @@
+package de.nowchess.io.service.resource
+
+import de.nowchess.api.game.GameContext
+import de.nowchess.io.fen.{FenExporter, FenParser}
+import de.nowchess.io.pgn.{PgnExporter, PgnParser}
+import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest, IoErrorDto}
+import io.smallrye.mutiny.Uni
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+import org.eclipse.microprofile.openapi.annotations.Operation
+import org.eclipse.microprofile.openapi.annotations.media.{Content, Schema}
+import org.eclipse.microprofile.openapi.annotations.responses.{APIResponse, APIResponses}
+import org.eclipse.microprofile.openapi.annotations.tags.Tag
+
+@Path("/io")
+@ApplicationScoped
+@Tag(name = "IO", description = "Chess notation import and export")
+class IoResource:
+
+ @POST
+ @Path("/import/fen")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ @Operation(summary = "Import FEN", description = "Parse a FEN string into a GameContext")
+ @APIResponses(
+ Array(
+ new APIResponse(responseCode = "200", description = "Parsed GameContext"),
+ new APIResponse(responseCode = "400", description = "Invalid FEN"),
+ ),
+ )
+ def importFen(body: ImportFenRequest): Uni[Response] =
+ Uni.createFrom().item {
+ FenParser.parseFen(body.fen) match
+ case Left(err) =>
+ Response.status(400).entity(IoErrorDto("INVALID_FEN", err)).build()
+ case Right(ctx) =>
+ Response.ok(ctx).build()
+ }
+
+ @POST
+ @Path("/import/pgn")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ @Operation(summary = "Import PGN", description = "Parse a PGN string into a GameContext")
+ @APIResponses(
+ Array(
+ new APIResponse(responseCode = "200", description = "Parsed GameContext"),
+ new APIResponse(responseCode = "400", description = "Invalid PGN"),
+ ),
+ )
+ def importPgn(body: ImportPgnRequest): Uni[Response] =
+ Uni.createFrom().item {
+ PgnParser.importGameContext(body.pgn) match
+ case Left(err) =>
+ Response.status(400).entity(IoErrorDto("INVALID_PGN", err)).build()
+ case Right(ctx) =>
+ Response.ok(ctx).build()
+ }
+
+ @POST
+ @Path("/export/fen")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.TEXT_PLAIN))
+ @Operation(summary = "Export FEN", description = "Serialize a GameContext to FEN notation")
+ @APIResponse(responseCode = "200", description = "FEN string")
+ def exportFen(ctx: GameContext): Uni[Response] =
+ Uni.createFrom().item(Response.ok(FenExporter.exportGameContext(ctx)).build())
+
+ @POST
+ @Path("/export/pgn")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array("application/x-chess-pgn"))
+ @Operation(summary = "Export PGN", description = "Serialize a GameContext to PGN notation")
+ @APIResponse(responseCode = "200", description = "PGN text")
+ def exportPgn(ctx: GameContext): Uni[Response] =
+ Uni.createFrom().item(Response.ok(PgnExporter.exportGameContext(ctx)).build())
diff --git a/modules/io/src/test/scala/de/nowchess/io/json/SquareKeyDeserializerTest.scala b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeyDeserializerTest.scala
new file mode 100644
index 0000000..70908d2
--- /dev/null
+++ b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeyDeserializerTest.scala
@@ -0,0 +1,62 @@
+package de.nowchess.io.json
+
+import com.fasterxml.jackson.core.`type`.TypeReference
+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 org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+
+class SquareKeyDeserializerTest extends AnyFunSuite with Matchers:
+
+ private def mapper: ObjectMapper =
+ val m = new ObjectMapper()
+ val mod = new SimpleModule()
+ mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
+ m.registerModule(DefaultScalaModule)
+ m.registerModule(mod)
+ m
+
+ private def readMap(json: String): Map[Square, Int] =
+ mapper.readValue(json, new TypeReference[Map[Square, Int]] {})
+
+ test("deserializes valid algebraic key") {
+ val result = readMap("""{"e4":1}""")
+ result(Square(File.E, Rank.R4)) shouldBe 1
+ }
+
+ test("deserializes a1 corner") {
+ val result = readMap("""{"a1":1}""")
+ result(Square(File.A, Rank.R1)) shouldBe 1
+ }
+
+ test("deserializes h8 corner") {
+ val result = readMap("""{"h8":1}""")
+ result(Square(File.H, Rank.R8)) shouldBe 1
+ }
+
+ test("deserializes multiple squares") {
+ val result = readMap("""{"a1":1,"h8":2,"e4":3}""")
+ result(Square(File.A, Rank.R1)) shouldBe 1
+ result(Square(File.H, Rank.R8)) shouldBe 2
+ result(Square(File.E, Rank.R4)) shouldBe 3
+ }
+
+ // scalafix:off DisableSyntax.null
+ test("deserializeKey returns null for invalid square") {
+ new SquareKeyDeserializer().deserializeKey("invalid", null) shouldBe null
+ }
+
+ test("deserializeKey returns null for wrong-length key") {
+ new SquareKeyDeserializer().deserializeKey("e44", null) shouldBe null
+ }
+
+ test("deserializeKey returns null for bad file") {
+ new SquareKeyDeserializer().deserializeKey("z4", null) shouldBe null
+ }
+
+ test("deserializeKey returns null for bad rank") {
+ new SquareKeyDeserializer().deserializeKey("e9", null) shouldBe null
+ }
+ // scalafix:on DisableSyntax.null
diff --git a/modules/io/src/test/scala/de/nowchess/io/json/SquareKeySerializerTest.scala b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeySerializerTest.scala
new file mode 100644
index 0000000..bcc2f0f
--- /dev/null
+++ b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeySerializerTest.scala
@@ -0,0 +1,50 @@
+package de.nowchess.io.json
+
+import com.fasterxml.jackson.core.`type`.TypeReference
+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 org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+
+class SquareKeySerializerTest extends AnyFunSuite with Matchers:
+
+ private def mapper: ObjectMapper =
+ val m = new ObjectMapper()
+ val mod = new SimpleModule()
+ mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
+ m.registerModule(DefaultScalaModule)
+ m.registerModule(mod)
+ m
+
+ test("serializes square as algebraic notation") {
+ val json = mapper.writeValueAsString(Map(Square(File.E, Rank.R4) -> 1))
+ json should include("\"e4\"")
+ }
+
+ test("serializes a1 corner") {
+ val json = mapper.writeValueAsString(Map(Square(File.A, Rank.R1) -> 1))
+ json should include("\"a1\"")
+ }
+
+ test("serializes h8 corner") {
+ val json = mapper.writeValueAsString(Map(Square(File.H, Rank.R8) -> 1))
+ json should include("\"h8\"")
+ }
+
+ test("round-trips with SquareKeyDeserializer") {
+ val rt = {
+ val m = new ObjectMapper()
+ val mod = new SimpleModule()
+ mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
+ mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
+ m.registerModule(DefaultScalaModule)
+ m.registerModule(mod)
+ m
+ }
+ val original = Map(Square(File.D, Rank.R5) -> 99)
+ val json = rt.writeValueAsString(original)
+ val result = rt.readValue(json, new TypeReference[Map[Square, Int]] {})
+ result shouldBe original
+ }
diff --git a/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala b/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala
new file mode 100644
index 0000000..2091794
--- /dev/null
+++ b/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala
@@ -0,0 +1,81 @@
+package de.nowchess.io.service.resource
+
+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.Square
+import de.nowchess.api.game.GameContext
+import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer}
+import io.quarkus.test.junit.QuarkusTest
+import io.restassured.RestAssured
+import io.restassured.http.ContentType
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+@QuarkusTest
+class IoResourceTest:
+ private lazy val testMapper: ObjectMapper =
+ val m = new ObjectMapper()
+ val mod = new SimpleModule()
+ mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
+ mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
+ m.registerModule(new DefaultScalaModule())
+ m.registerModule(mod)
+ m
+
+ private def contextJson(ctx: GameContext): String = testMapper.writeValueAsString(ctx)
+
+ @Test
+ def importFenReturns200(): Unit =
+ val resp = RestAssured
+ .`given`()
+ .contentType(ContentType.JSON)
+ .body("""{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"}""")
+ .post("/io/import/fen")
+ assertEquals(200, resp.statusCode())
+
+ @Test
+ def importFenInvalidReturns400(): Unit =
+ val resp = RestAssured
+ .`given`()
+ .contentType(ContentType.JSON)
+ .body("""{"fen":"not-a-fen"}""")
+ .post("/io/import/fen")
+ assertEquals(400, resp.statusCode())
+
+ @Test
+ def importPgnReturns200(): Unit =
+ val resp = RestAssured
+ .`given`()
+ .contentType(ContentType.JSON)
+ .body("""{"pgn":"1. e4 e5"}""")
+ .post("/io/import/pgn")
+ assertEquals(200, resp.statusCode())
+
+ @Test
+ def importPgnInvalidReturns400(): Unit =
+ val resp = RestAssured
+ .`given`()
+ .contentType(ContentType.JSON)
+ .body("""{"pgn":"not valid pgn !!!###"}""")
+ .post("/io/import/pgn")
+ assertEquals(400, resp.statusCode())
+
+ @Test
+ def exportFenReturns200WithFen(): Unit =
+ val resp = RestAssured
+ .`given`()
+ .contentType(ContentType.JSON)
+ .body(contextJson(GameContext.initial))
+ .post("/io/export/fen")
+ assertEquals(200, resp.statusCode())
+ assertTrue(resp.getBody.asString().contains("rnbqkbnr"))
+
+ @Test
+ def exportPgnReturns200(): Unit =
+ val resp = RestAssured
+ .`given`()
+ .contentType(ContentType.JSON)
+ .body(contextJson(GameContext.initial))
+ .post("/io/export/pgn")
+ assertEquals(200, resp.statusCode())